Skip to content

Latest commit

 

History

History
501 lines (356 loc) · 109 KB

experimental.md

File metadata and controls

501 lines (356 loc) · 109 KB

Experimental transports

Part of the protocol reference: dual listening, the Classic address exchange draft and the Wi-Fi transport draft.

Experimental dual listening (off by default)

Settings > Advanced > Connection "Listen on both links" (DEBUG test_dual_listening is the benchmark switch; default OFF until its phone test passes). Design and alternatives: DECISIONS "Design D". With the switch off every byte on air and on the link is unchanged: no hint bit, no second GATT service or server, no extra attempt, and every rule below stays idle (a hint bit received from another phone is ignored). Not yet tested on phones.

Why. A call endpoint has one transport (DECISIONS Design D): the offer's transport flags name it, and the endpoint's listener, link hello and policies follow it. A phone whose LE CoC fails moves to GATT (README "Automatic BLE GATT when LE CoC fails") and becomes reachable only by phones that scan for GATT; and once it is a GATT endpoint it lists only GATT offers, so it also cannot call a CoC phone. Dual listening lets a CoC phone also take a call over GATT on the same advertising address, so a GATT-only phone can call it and a CoC caller whose CoC attempt fails can still reach it, without a new scan.

Announcement hint. capabilitySummary bit 3 (0x08, DualListeningWire.SUMMARY_BIT) of the 22-byte announcement means: "this phone also accepts a call over BLE GATT (service c8d5a105-9f88-4f65-a03d-7f5a2fb921fa, the existing GATT call service) at the advertising address of this offer, while this offer is on air". The primary transport flags are exactly what they are today (LE CoC: FLAG_GATT_TRANSPORT and FLAG_CLASSIC_TRANSPORT clear), so every existing build reads and connects the offer as before. Bits of a call offer's capabilitySummary:

Bit Meaning
0, 1 VP9, AV1 (existing)
2 H.264 (draft, Design HC; compact codec field bit 56)
3 Dual: also reachable over GATT (this section)
4 Relay consent (relayed calls)
5 BMH3 hint (version 2 offers; "Experimental BMH3 identity-hiding link handshake")
6 Zero (0x40 is the stream audio-only marker)
7 HEVC (draft, Design HC)

Who may set it (all must hold). The switch is on; the endpoint's transport is LE CoC (the user's choice, or what the CoC-to-GATT safety net left); the offer is a private-call offer (FLAG_PRIVATE_CALL_AVAILABLE, not FLAG_STREAM_AVAILABLE) at route cost 0, version 2 or 5; it is not a relay-helper offer (FLAG_RELAY_CAPABLE clear: a helper stays single-bearer, "One bearer end to end"); and the GATT call service is registered and the second server started successfully before the offer is composed. The bit is never advertised for something that does not exist: if the service cannot be registered the offer is the plain CoC offer. It is never set on stream offers, viewer requests, Classic or GATT-primary offers, relay offers or reach pages. Compact (iPhone) form: no bit exists and none is sent; an iPhone offer is GATT-primary and never carries the hint.

Who may read it. The caller honors the bit only on a decoded call offer at route cost 0 whose transport flags are clear (LE CoC primary). Anywhere else it is ignored. A DEBUG call relay clears the bit when it mirrors its child's offer one relay further (PrivateCallRelayChain.parentOffer), a relayed route's routedAnnouncement has none, and a caller ignores it at route cost above 0 whatever a relay did. A relayed route never uses a second transport: relays are single-bearer.

Old readers (verified 2026-10-11 against the code in this repository). DiscoveryAnnouncementCodec.decode accepts any capabilitySummary in 0..255 and DiscoveryAnnouncement only checks that range. The only consumers are the codec test supportsAdvertisedCodec (summary != 0 && summary and localDecoderMask != 0, unchanged by an extra bit), RelayedCalls.consents (tests bit 4 only) and LogicalStreamTarget.matches (equality of the whole byte, used for stream offers only, where the bit is never set). CompactDiscoveryAnnouncement.encode (a reference encoder for vectors; Android never advertises the compact form) refuses unrepresentable summary bits, and its decoder produces none. The iOS reference decoder (DiscoveryAnnouncement.decode, Discovery.swift) reads the byte without a mask; only its compact encoder (a.capabilitySummary & ~allowed, which throws on an unrepresentable bit) and the unreleased iOS call relay (CallRelay.swift: childOffer.capabilitySummary & 0x0f keeps bit 3, and the compact encode of the relayed offer would then throw and stop that relay advertising) look at it. The iOS relay branch must mask the summary with the codec bits and the relay consent bit before it merges (open item, iOS repository); the released iOS app does not relay. Builds outside this repository are not covered; a decoder that rejected unknown summary bits would drop only the dual phone's offer, never connect it wrongly. DualListeningWireTest pins these facts with the vectors below.

Link and endpoint hellos: unchanged. Each side signs the transport bit of the link it runs on (NodeCapabilities.TRANSPORT_BLE_L2CAP or TRANSPORT_BLE_GATT, plus the existing bits 3-7), exactly as an endpoint of that transport does today: a dual callee that accepted a GATT link signs the GATT bit, one that accepted a CoC link the CoC bit, and the caller requires the bit of the transport it used (peerCapabilities.transportMask and used.capabilityMask != 0). No bit is added to either hello, and BMH2, BMS1, BME1 and the codec/maximum-bitrate fields derived from the transport are those of the transport in use. No peer needs the hint to complete a handshake.

Listener (callee). With the switch on and an LE CoC endpoint:

  1. The endpoint takes its CoC lease and then starts the GATT call service in a second BluetoothGattServer that does not advertise (it waits at least 1.5 s after the last GATT call server closed, the existing GATT restart settle time). The one connectable advertising set stays the CoC control server's, so scanners, scan filters, the advertising-set budget and the address are unchanged. Only after the service is registered does the announcement get the hint and the control server start advertising.
  2. The first connection to arrive wins and the endpoint answers it as an ordinary incoming call: a CoC accept, or a GATT client enabling the notify characteristic's CCCD (the existing admission). acceptIncoming is serialized and refuses a second connection (callRunning). On a CoC win the second server is closed; on a GATT win the CoC lease and the control server (advertising and PSM service) are closed and the GATT server stays until the call ends. The endpoint's link transport (hello bit, codec and bitrate limits, buffer, pacing, audio batching, playout profile, close wait, link-capacity and PHY memory keys) is that of the link it accepted.
  3. Every restart of the availability (boundary rotation, end of a call, the 1-8 s rotating silence) closes both servers and reopens them in the same order. A relay duty (RELAY_SETUP) is accepted only on the primary transport: a SETUP arriving on the second link is refused (FAIL 5), because the helper's next hops are chosen on the endpoint's own bearer.
  4. The second server closes (and the hint leaves the air) whenever the offer does: a call, exchange or relay duty holding the endpoint, or the endpoint closing. The service is never registered twice: while the second server carries a link, a reopened listener advertises the plain CoC offer.
  5. Store-and-forward exchanges are not carried on the second transport: a first record of hop type 11 on a second-transport link is rejected as an unknown type, and a GATT endpoint does not offer an exchange to a row it lists only because of the hint.

Caller. The attempt list for a call to a cost-0 offer is a pure function of (this endpoint's transport, the offer, the switch): LE CoC endpoint, offer CoC with the hint: CoC then GATT; CoC without the hint: CoC; GATT endpoint, offer GATT, or CoC with the hint: GATT (a CoC offer without the hint is not listed, as today); everything else, including a GATT offer seen by a CoC endpoint (the safety net's endpoint rebuild stays as it is) and Classic: the endpoint's own transport only. A hinted CoC offer is listed by a GATT endpoint only while the switch is on.

For CoC then GATT the CoC phase ends 9 s after it started (earlier when the outer setup deadline, SetupDeadline, is earlier); at that deadline the PSM-reading GATT client and the pending CoC socket are closed, because the CoC client has no bound of its own for a connecting socket. If it fails or hits the deadline the caller opens the GATT call client to the same BluetoothDevice from the same sighting, without scanning; that attempt is an ordinary GATT setup (one attempt always, retries only while the outer budget allows). Each transport is tried at most once per attempt; the existing one 2M-to-1M setup retry reruns the whole list. The fallback is visible and logged, never silent: the status line reads "Using BLE GATT for this call: L2CAP did not connect" and BENCHMARK call transport: event=dual_fallback | requested=BLE_L2CAP | effective=BLE_GATT | automatic=true | reason=dual_coc_failed | cocError=<class> | cocPhaseMs=<n> is written. A CoC failure that is followed by a successful GATT link does not move the session to GATT (no CocAutoGattSession.onCocFailure, no onCocConnectFailed): the phone's own CoC may be fine and only that peer's CoC is not, and the call was served. When both attempts fail the call fails once, with both causes in the log, and the safety net is not told either (its retry would only repeat the GATT attempt); the CoC registration count still includes every connect, so the threshold rule keeps working. A failed plan ends after at most the 9 s CoC phase plus one GATT setup; the existing one 2M-to-1M retry may follow only while its 15 s early-loss window allows and rediscovers by scan as it does today. The phone test measures the time from the tap to the failure notice (limit 30 s). There is no per-peer memory of a failed CoC: it would be keyed by a stable node ID, which rotating IDs avoid, and the cost of a broken CoC is one bounded phase per call attempt.

Identity binding. The fallback reuses the selected announcement object, so the checks that bind a link to what the user selected run on whichever transport carried it: version 2 offers: the authenticated end-to-end endpoint identity must have the announced 48-bit prefix (a call does not compare the link identity with the prefix, an exchange does); version 5: the callee's BDO1 opening on the link and BDX1 OPENING end to end must open the selected RDI for that identity (RV15; DiscoverySelectionCheck takes no transport). Nothing is computed from the hint or from which transport answered. In addition, a link on the second transport is accepted only if the selected offer allowed that transport (DualListeningWire.offeredTransports: the primary one, plus GATT when the hint is on a CoC-primary cost-0 offer) and the peer's hello signs it. A phone answering on GATT that is not the selected phone fails the same prefix/opening check as one answering on CoC.

Known limits of the first version (measured in T12, not hidden). (1) The call settings the activity chooses when it creates the endpoint (video settings and per-direction caps, codec choice, buffer size, Automatic rate-control memory, the GATT restart delay after a call) follow the endpoint's primary transport; the hello, bitrate and codec limits, pacing, audio batching, playout profile, close wait and link-capacity/PHY memory keys follow the link in use. A call over the second transport therefore starts with the CoC caps (200 against 180 kbps per direction) and adapts through the usual rate control; it compares its first 30 s with a native GATT call. (2) A late CoC accept: if the caller's CoC deadline closes its socket after the callee already accepted it, the callee treats that as a CoC win (second server and advertising closed, endpoint rebuilt after the failed handshake) and the caller's GATT attempt may fail on the stale database; the next attempt works. Closing the losing listener only after the winner authenticated would remove it but changes the accept rules of every endpoint. (3) The CoC phase deadline cuts the connecting socket and closes the PSM-reading GATT client when it exists; the PSM read itself is bounded by its own 6 s control timeout and the budget-gated retry, and the fresh-ACL wait (Android 12/13 stacks) can add up to 2 s. A socket created after the deadline is closed at once.

Rotating IDs. One announcement, one RDI, whichever transport answers: the version 5 call offer carries the hint exactly as it carries the consent bit, and the RDI, epoch pins (OpeningPins role 0, evaluated when the connection is accepted), BDO1 and BDX1 are transport-independent. A boundary restart closes and reopens the second server together with the control server (a new GATT server each epoch, as for a GATT endpoint) and the second service is not advertised, so no new advertising set or address exists to link epochs. Stated residuals: the hint is one more stable capability bit on a rotating phone (like the relay consent bit; added to "Threats that remain" of the rotating IDs section), which is why the switch is separate and default off; the second service UUID is an application constant visible to any central that connects, the same population that can already read the PSM service before BMH2; the GAP Device Name stays the neutral name. A stable identifier is never placed in the second service.

Relayed calls and reach pages. Reach pages and SETUP carry no summary byte and are unchanged. An on-demand helper, a DEBUG call relay and any relay hop are single-bearer: they advertise no hint, accept SETUP only on the primary transport, and a caller ignores the bit at route cost above 0. A target that is dual is reached through a chain on its primary (CoC) offer as today.

Classic. Classic endpoints are never dual (Classic call availability keeps its permanent address and version 2). A hybrid Classic call offer carries no hint.

Security notes. The hint is an unauthenticated hint like every announcement field. Forging it on a copy of a victim's advertisement can only cause a caller whose CoC attempt failed to open one GATT connection to that address within the budget; the user started that attempt, nothing connects automatically, and the handshakes decide who is on the other end. An attacker who blocks CoC can force the GATT link: same BMH2/BMS1/BME1, only slower, and visible to the user. A stranger can occupy the second server (first CCCD writer) exactly as a stranger can already connect to the CoC listener or to a GATT endpoint; the endpoint is then busy until the handshake deadline and the RV8 tarpit applies. The dual endpoint's exposure is the union of the two transports' exposures, with the same bounds and no new unauthenticated parser.

Debug and logs. BENCHMARK dual_listening: event=offer | hint=<bool> | summary=0x<hh> | secondary=ready|failed|absent | reason=; event=secondary | state=started|failed|closed | gattServers=<n> | adSets=1; event=link | accepted=BLE_L2CAP|BLE_GATT | primary=; event=plan | endpoint= | attempts=; event=attempt_failed | transport= | attempt= | error= | elapsedMs=; event=coc_phase_deadline; event=incoming_deadline; scanner side (DEBUG, whatever the switch) event=seen | node= | hint= | summary=; BENCHMARK call transport: event=dual_fallback ... above. The server counts in event=secondary are computed from the design constants: the phone test reads the real ones from dumpsys bluetooth_manager. DEBUG fault injection test_dual_listening_inject=coc_connect fails the CoC attempt at once on the caller, coc_hang blocks it until the CoC phase deadline and then fails it, so the real GATT attempt can be tested (ignored in release builds and with the switch off).

Vectors (DualListeningWireTest):

Input Bytes (hex)
Version 2 call offer, prefix 010203040506, flags 0x41, VP9+AV1, cost 0 (today) 02010203040506410300000000000000000000000000
Same with the dual hint (capabilitySummary 0x0b) 02010203040506410b00000000000000000000000000
Same, GATT-primary (flags 0x49), no hint 02010203040506490300000000000000000000000000
Version 5 call offer, role-0 RDI 44e69c0a7a7d, flags 0x41, VP9+AV1+dual 0544e69c0a7a7d410b00000000000000000000000000

Draft: Classic address exchange and media upgrade (design 2026-10-14, revised after five reviews, not implemented)

Stage-1 scope. Direct calls and direct stream links whose old (LE) link has real identities on both sides (BMH2, or BMH3 where both blocks revealed the real identity), with the peer's full fingerprint verified on both phones and a confirmed Classic pairing key K_cx shared (below), a fixed handshake version, and pinned identities. No pseudonym continuity, no relay hop, no iPhone, no Classic inquiry or visibility request by this feature (the user types the address, below). Stream forwarder hops are a later stage.

Reserved elements (verified free on 2026-10-14 against SecureRecordType, NodeCapabilities and the capability tables in this document).

Element Value Why this one
Link-hello transport-mask bit 5 (0x20): "sends and accepts hop type 14 BCX1" The only free link-hello bit (3 BDO1, 4 SETUP, 6 call relay, 7 build tag; end-to-end hellos use bit 5 for LABELS, a different signature domain that never shares a reader with a link hello). capabilitySummary bit 7 is not free and no advertisement bit is used or wanted
BMS1 hop type 14 CLASSIC_UPGRADE Types 4 to 13 are taken. A reader rejects it as unknown unless its owner opted in, like types 11, 12 and 13. The reader checks the header's ciphertext length against the per-type limit (type 14: 64 bytes plus the 16-byte tag; 128 bytes on an upgrade socket before BIND) before allocating; the ordinary secure reader would otherwise accept records of about 4 MiB
RFCOMM service one random UUID per attempt, UUID = first 16 bytes of HMAC-SHA-256(UP, "svc" | ticket) with the version and variant bits set No fixed upgrade UUID exists for a scanner to look for; the permanent Classic call listener (ClassicTransport.APP_SERVICE) and its SDP record are untouched. The guest computes it from UP and the ticket, so it never needs to find it by search
Raw frame magic BCU1 on the upgrade socket only Read before any handshake byte on that socket

Capability rules. Bit 5 is signed in a link hello only while the exchange switch is on, on any transport, in BMH2 hellos and in the BMH3 identity block. A phone sends a BCX1 record only on a link where both hellos carry bit 5, the link has real identities on both sides, and the sender holds a key for the peer (PAIR excepted); an older build never sees one. It is never set in an end-to-end hello (there bit 5 means LABELS), a BGK1 group hello or on an iPhone. Bit 5 is itself a stable capability bit: a passive observer of a cleartext BMH2 hello reads it, and any peer that connects reads it in BMH3; PRIVACY discloses it. The implementation keeps bit 5 in peerCapabilities (it is not stripped like the build-tag bit) and must change the two transport-overlap tests of the call relay (BluetoothPrivateCallRelay.kt, lines 693 and 723, which compare the full masks) to test the bearer's own bit.

Never in an advertisement, never end to end. BCX1 records are hop records of one link. A relay or forwarder terminates them, never copies one, and never sends an address learned from one to anyone else (hop-local). They are not part of the end-to-end BME1 session, so a call relay cannot read them.

Bilateral authorization: the Classic pairing key K_cx

Being a verified contact is one-sided by nature (a QR scanned from a poster), and a message that says "I verified you" tells the peer so. So no BCX1 record except PAIR is ever sent to a peer unless this phone holds a key that both sides established. K_cx proves one thing: the peer opted in to Classic with this phone at pairing time. It does not prove that the peer still verifies this phone; each phone checks its own verified-contact store and per-contact switch again at every use.

  • Per-contact opt-in. Settings > Features > Classic Bluetooth, per contact: "Allow Classic Bluetooth with this contact" (default off, only for a verified contact, only while the feature is on).
  • States and epochs (recoverable). Per contact a phone stores (sealed with the verified-contact Keystore key, file identity-verification/classic-contacts.bin): switch, a 64-bit pairing epoch counter (monotonic, never reused), at most one CONFIRMED key and at most one PENDING key with their epochs and creation times. PENDING expires after 7 days or 3 failed sessions. A received PAIR whose epoch is more than 1,000 above this phone's own is ignored and counted (it would otherwise let a peer burn the counter).
  • Establishment (PAIR). In an answered session a phone whose switch is on and whose peer is verified sends PAIR(epoch) when it holds no CONFIRMED key and also answers a received PAIR when it holds one (the peer lost its key or reset), and sends PAIR when a CONTACT tag from the peer verifies under none of the keys it holds, even if it holds a CONFIRMED key (divergence repair: one phone confirmed a new key while the other let its PENDING expire; bounded to one PAIR per session and three per contact per 24 h). When both sent and received PAIR in this session, both derive a candidate K = HKDF-Expand(X(link), "BluetoothVideoMesh/ClassicUpgrade/1/pair" | e (8), 32) with e = max(own epoch, peer epoch) + 1 and stage it as PENDING(e); a PENDING never replaces a CONFIRMED key until promoted. PAIR is the only record that reveals "I verified you and want Classic"; it goes only to a contact the user chose, like the presence-lease offer.
  • Use and promotion (CONTACT). A phone sends CONTACT only if it holds a key for the peer and the switch is on; the tag uses the PENDING key if one exists, else the CONFIRMED one, and the verifier tries both. A valid tag under PENDING promotes it to CONFIRMED (replacing the old key; lower epochs are ignored for rollback protection). Both sides send unconditionally from local state, so there is no deadlock; if one phone crashed before staging the key it simply cannot verify, ignores the tag and sends PAIR in the next session, while the other (switch on) answers it.
  • Stale CONTACT disclosure (accepted). A phone that holds a key the peer has since deleted still sends CONTACT; the peer sees a tag it cannot verify. That tells the peer only that this phone still holds a key for it, a contact it once paired with. It is acknowledged here and in PRIVACY. No HOST is sent and no dial is made unless both sides sent and received a valid CONTACT in this very session (fresh mutual proof).
  • Reset and revocation. Turning the per-contact switch off, "Reset Classic pairing" (increments the epoch and deletes both keys), forgetting the contact, resetting the verified contacts, the emergency wipe or turning the feature off deletes the keys (and that contact's votes, claims and stored address). The other phone's key stays but is unusable; it sees only silence. A contact that learned this phone's BD_ADDR keeps it; that cannot be revoked.

BCX1 records (hop type 14)

Plaintext of the BMS1 record: "BCX1" (4) | kind (1) | body. Integers are big-endian; a record longer than the limit above, an unknown kind, a wrong body length, a record out of the order of "Record order" or a nonzero reserved byte is a protocol error that closes that link. Records follow the normal exact-next BMS1 ordering, which also stops replay. X(link) below is the exporter of the link the record travels on (BMH3: its EXP; BMH2: the exporter of "Binding"). Each carrier has one ordered writer (the existing synchronized BMS1 sequence); a control record is a record in that writer's queue (control lanes are served before media lanes between records, never inside a record; SWITCH is the exception: it waits in the old link's ordinary media FIFO behind every record sealed before its boundary, so it never overtakes a backlog queued on the old link, and the sender starts no normal switch while that queue's oldest record has waited half the hold, 500 ms, or more; review 6 item 1).

Kind Name Body Rules
1 ECHO address (6) | tag (16) Acceptor to dialer of a Classic (RFCOMM) link, once; only if the acceptor holds a key for the dialer and the dialer's hello carries bit 5, and never on a link that carries a relay SETUP. address is BluetoothSocket.getRemoteDevice().getAddress() of that socket: the dialer's own BD_ADDR, a routing observation by an authenticated peer, not proof. tag = HMAC-SHA-256(K, "echo" | X(link) | address)[0:16]; the dialer counts it only if it holds a key for the acceptor and the tag verifies. The all-zero address and 02:00:00:00:00:00 are protocol errors
2 PAIR epoch (8) Establishment only, see above
3 CONTACT flags (1) | tag (16): flag bit 1 "I hold an address I may offer" Either direction on the old link after admission (the call is answered, or the viewer admitted) and 3 s of media. tag = HMAC-SHA-256(K, "contact" | X(link) | sender role (1))[0:16]
4 HOST status (1) | address (6) | ticket (8) | validMs (2) After fresh mutual CONTACT in this session. Status 1: address and an open upgrade listener, ticket valid for validMs (at most 20,000); status 0: nothing to offer. A phone ignores and counts a HOST that arrives before mutual CONTACT, and never dials on one
5 DECLINE reason (1) Guest to host after HOST: 1 policy or switch, 2 busy, 3 no Classic radio or permission, 4 limit, 5 not now, 6 dial failed or timed out (the host counts it toward the failure limit below). The host closes its listener
6 BIND role (1) | mac (32) On the upgrade link only, see "Binding"
7 READY recvCeilingKbps (2) | familyCapKbps (2) | transportBudgetKbps (2) | reserved (2) On the upgrade link after both BINDs, each direction, independent of any media: this phone's receive ceiling for the new carrier, its device-family cap for this peer (0 = none) and the transport budget it measured or the family default
8 SWITCH gen (4) | ticket (8) | e2eBoundary (8) | meshBoundary (8) Sender to receiver, in-band on the old link after the last record before the boundary (the normal move), or on the new link when the sender sees the old carrier dead (then the old-link tail is lost): from the BME1 content sequence e2eBoundary (it covers every content type: media, lifecycle, signals, labels, discovery, key requests) and the mesh envelope sequence meshBoundary (hop type 7; 0 if none) on, this direction travels on the new link. The boundary is the next sealed sequence at a frame boundary (for an audio-only or idle direction simply the next sealed sequence). Idempotent: a repeat of the same gen and boundary is ignored. ticket is the 8-byte ticket of the upgrade attempt (HOST, BCU1) and names the new-carrier instance the marker is about: a receiver acts on a marker only for its current new carrier, or for the attempt whose BIND and READY are still running here (it then keeps the boundary and applies it when READY completes, because a SWITCH can overtake the peer's READY); a marker for any other ticket, such as one delayed on the old link from an attempt that has since died, changes nothing and burns its generation (stage 0 findings 3 and 4)
9 RESUME gen (4) | ticket (8) | e2eNext (8) | meshNext (8) Sender to receiver on the old/standby link: from these sequences on, this direction uses the old link again; the new link is finished for this session. Ignored (and the generation burned) if the receiver is already reading this direction from the old link, or is reading it from a new carrier other than the one ticket names
10 CLOSE reason (1) Either direction on every live carrier that has completed BIND and READY (never on an upgrade socket before that): the call or stream is ending (a hang-up); idempotent; carries no sequence, so the fenced carrier can carry it

Record order. Ordinary Classic link (existing roles): link handshake, then BDO1 where negotiated, then SETUP where a relay applies, then (acceptor only, if allowed) ECHO, then the link's normal first record. ECHO is omitted whenever SETUP applies, so the first-record rules of BDO1 and SETUP win; the link layer consumes every type-14 record itself and never hands it to the endpoint-handshake or content reader. Upgrade socket: raw challenge and pre-proof frames, link handshake, then BIND in both directions with nothing else accepted until both verify, then (host only) an optional ECHO, then READY in both directions, then the sender's SWITCH and media. Old link: PAIR, CONTACT, HOST, DECLINE, SWITCH, RESUME, CLOSE only after admission. The link layer acts directly on ECHO, PAIR, CONTACT, HOST, DECLINE, BIND, READY and CLOSE from the reader thread; only SWITCH and RESUME, which are order-relevant to the content, travel as markers through the content queue.

Self-address record and stored claims

Local state, not a wire format.

  • Sources. A typed address (below) and echo votes: each ECHO from a key-holding contact is a vote (contact hash, address, time); the contact hash is the first 8 bytes of SHA-256(install salt | contact fingerprint); one vote per contact (a newer vote from the same contact replaces its older one; at most 8 contacts). Counting is by distinct stable verified identities, never by session or pseudonym. There is no promotion by elapsed time.
  • Typed address. The user enters the Bluetooth address that Android shows in Settings > About phone > Status twice; the two entries must match and pass a format check (never the all-zero or 02:00:00:00:00:00 value), and the app shows the last four hex digits of the saved value next to the setting for comparison. The app never reads the address itself. A typed address is a user claim, not proof.
  • Proven, scoped to the contact. A ticket-bound BIND on this phone's listener proves only that that contact reached this phone; the accepted socket does not show which address it dialed (a contact could echo a false address X and then dial the real address Y). A proof therefore never makes an address offerable to anyone else; it is recorded as provenFor(contact).
  • What is offered (poisoning rule). To any contact a HOST carries only the typed address (user-verified). To a contact C it may also carry an address that C itself voted for or that is provenFor(C); so a malicious contact can only be sent back what it supplied. Corroboration by several contacts is not accepted as a substitute, because colluding contacts can corroborate each other. A newer vote never replaces the typed address; an echo-only or proven incumbent for C changes only by a newer vote of C itself or a bound connection by C. The previous selection stays offerable for 7 days after replacement. Candidate order for C: typed, then provenFor(C) (newest), then C's own vote. The record is disputed (nothing offered) only when two or more distinct contacts disagree with every candidate. Three failed dials (the host counts DECLINE reason 6) mark an unconfirmed typed address failed and tell the user to check the number. Without a typed address a phone can serve only the contacts whose votes or proofs it holds; that is the price of the rule and is acceptable for an opt-in power-user feature.
  • Not hardware proof. An application-layer tunnel can authenticate the real contact while the accepted socket reports the tunnel's address, and a physical Bluetooth address cannot be attested to software; the record is routing confidence only.
  • Claims received in HOST go to a separate claim store (64 entries, 30 days, one per key-holding contact, replaced only by a newer claim of the same contact) and never touch ClassicAddressBook. A claim is promoted into the address book only after a connection at that address authenticated as the expected full identity. The address book changes its own rule: provenance (ticket over observed over nothing) is enforced across all entries that share an address, so a claim can neither replace nor evict nor refresh another identity's mapping, and an unbound observation does not overwrite a ticket-bound one for 30 days.
  • Storage is no-backup, removed by the emergency wipe, when the exchange is turned off, and per contact on revocation. (This concerns this phone's own address and claims; the existing address book keeps, locally, the addresses at which it reached authenticated peers, as today.)

Upgrade sequence

  1. Offer (old link). Fresh mutual CONTACT in this session. The side that accepted the session hosts when it holds an offerable address; otherwise the initiator may host. The host opens an insecure RFCOMM listener on the per-attempt UUID and sends HOST. If both sent a status 1 HOST, the larger node ID yields. At most one outstanding HOST per link, a fresh ticket and UUID for every retry. The guest dials at most once per 30 s per contact.
  2. Dial. The guest pages the address (12 s), connects, reads the host's 16-byte challenge, and sends the pre-proof frame immediately followed by its first handshake message:
host -> guest on accept:    "BCU1" (4) | 0x01 | challenge (16)                              21 bytes, fresh random per socket
guest -> host:              "BCU1" (4) | 0x02 | ticket (8) | pre (16)                       29 bytes
                            then the guest's first handshake message exactly as it will be sent (BMH3 msg1, or the complete framed BMH2 hello, length prefix included, at most 2,052 bytes)
pre = HMAC-SHA-256(UP, "pre" | ticket | challenge | SHA-256(first handshake message) | guest node ID (16) | host node ID (16))[0:16]

The complete first message is built once (a split handshake API: build, hash, then write the same bytes), for BMH2 and BMH3. 3. Host admission. The host reads the 29 bytes and then the first handshake message under one absolute 2 s deadline, verifies pre in constant time before sending any handshake byte, and atomically consumes the ticket only on a valid proof; an invalid proof closes silently and does not touch the ticket. A consumed ticket is never restored: after any failure the retry uses a fresh ticket, UUID and HOST. 4. Link handshake (upgrade-specific options). The same protocol version as the old link, fixed, with: the expected peer is the pinned old-link node ID and fingerprint (any other identity closes), buildTag = null (no automatic BMH2 tag and no BMH3 BUILD_TAG extension), no stranger pseudonym, no advertisement-bound contact tag (16 random bytes), no BDO1 (bit 3 clear), bit 5 set, the Classic transport bit set. 5. Bind and ready. Each side sends BIND, verifies the other's (the expected opposite role byte, the expected peer, this attempt's ticket and challenge), then READY. Nothing else is accepted before that. 6. Cutover (below). 7. Limits. Effective send limit on the new carrier = min(requested rate, peer READY.recvCeiling, peer READY.familyCap if nonzero, own family cap, min of both transportBudgets). A requested bitrate is never raised; the cap only lets the existing rate control use headroom. The endpoint hello's maximum is not edited. On fallback the previous LE limits apply again, and transport-keyed state (close wait, pacer seed, flush interval, playback delay profile, audio headroom) is re-derived for the carrier in use.

Cutover and fallback: lossy by design, never fatal

Invariant. No cutover race may ever end the session while a carrier is alive; a race may only lose records. Session failure happens only on an authentication or protocol violation, or when no carrier is alive. Consequences: at the consumer any record that is stale, from a fenced carrier or out of order is dropped, never thrown; indispensable state survives by the existing state-only re-assertion; video recovers with a keyframe request after any gap. The switch is therefore not required to be lossless, and nothing in it waits for an acknowledgement.

Two carriers, one state per direction. O is the old (LE) link, N the new (Classic) link; every upgrade attempt uses a fresh socket, so a carrier-instance id never recurs. The sender holds SEND_ON ∈ {O, N}; the receiver holds ACCEPT_FROM ∈ {O, N} and the last boundary and generation, per direction. A single carrier owner seals records (assigning BME1 and mesh sequences) and chooses the carrier by sequence, so a sequence is never assigned on one carrier and sent on another. Each carrier has one ordered writer; every blocking write is abandoned by a watchdog that closes that carrier's socket after 2 s; timers run on a scheduler independent of any I/O. Each carrier has its own liveness: a socket error or end, a write stalled 2 s, or no inbound byte (heartbeat or record) for 2 s closes that carrier only. Closing N ends the upgrade for both directions (fate sharing is just that: N is gone). The session ends only when no carrier is left.

Sender (SEND_ON), three rows.

Event Action
N ready both ways (BIND, READY), the call is ACTIVE for 3 s, no earlier failure in the last 30 s, fewer than 2 fallbacks in this session, the next sealed record starts a frame and the old link's oldest queued record has waited less than 500 ms gen = last + 1; choose the boundary at the next frame boundary; write SWITCH(gen, ticket, boundary) in-band on O through the ordinary media FIFO, after the last pre-boundary record; SEND_ON = N; re-assert the current lifecycle, microphone and camera state once. Nothing waits for an answer
N fails or is closed (watchdog, stall, error, or the peer closed it) while O lives write RESUME(gen, ticket, next sequences) on O whatever SEND_ON was, because the peer may still be accepting or holding N and this tells it at once (half-open reduction b). If SEND_ON was N: SEND_ON = O, the RESUME goes before any fallback content, the unsent fragments of the current frame are dropped and the next frame starts at fragment 0, force a local keyframe, re-assert state once. Cooldown 30 s
O dies while N lives (whatever SEND_ON was, and whatever the cooldown) SEND_ON = N; write SWITCH(gen, ticket, boundary) on N (gen = last + 1 if it was on O, the same gen and boundary if it was already on N, because SWITCH may not have arrived); re-assert state once

Receiver (ACCEPT_FROM), per direction, six rows. Every row runs on the single consumer (below).

Event Action
SWITCH(g, t, b) dequeued from O, g newer than any seen, t this phone's current N ACCEPT_FROM = N from b (the earlier O records were already opened, since the marker is in order); open the gate; release the held N records. If t is the attempt whose BIND/READY is still running here, keep (t, b) and apply it when READY completes; for any other t change nothing and burn g
SWITCH(g, t, b) from N (the sender found O dead) the sender found its O dead, but the marker names no instance of O: log it (old_dead_hint) and keep O, which closes only by this phone's own liveness rule or the session end (review F-6, 2026-11-04: delayed past an in-call renewal it would close the healthy new standby); this direction stays where it is; ACCEPT_FROM = N from b at once; the missing O tail is lost; fragment suppress mode; ask the peer for a keyframe. A repeat of the same g and b is ignored; a different b for the same or an older g is dropped and logged (never fatal)
RESUME(g, t, next) dequeued If t names this phone's current N (or the attempt still in READY), whether or not ACCEPT_FROM is already N: close that N; if ACCEPT_FROM == N, ACCEPT_FROM = O, fragment suppress mode, ask the peer for a keyframe; in every case the old link delivers only records from next on afterwards (a lower sequence is stale; next is kept as the floor, not set to 0), and this phone's own direction falls back if it was on N. If t names another carrier: ignore it and burn g. A marker that carries no ticket is never acted on
O dies while N lives ACCEPT_FROM = N at once, accepting N records at or above the opener's next sequence (anything lower is stale and dropped); no wait, no hold; fragment suppress mode; request a keyframe
N closes or fails while O lives ACCEPT_FROM = O; held N records dropped; request a keyframe
N records arrive while ACCEPT_FROM == O and O is alive in this phone's view Held, bounded to 1 s and 5 MiB (a video frame is one record of up to 4 MiB; no frame count); the hold is a fixed ceiling because no latency is announced. On expiry or overflow: take N: ACCEPT_FROM = N at the opener's next sequence, release the held records in order, fragment suppress mode, request a keyframe. Nothing is closed: the first draft closed N here, which ended a live session when O had died silently and the SWITCH was lost with it (stage 0 finding 1). The late SWITCH then changes nothing, and what the old link still delivers below the new boundary is stale and dropped. A record from a closed carrier instance is dropped at once

One inbound consumer. Reader threads only parse and stamp each record with its carrier instance. Records that are order-relevant to the content (media and other BME1 records, mesh envelopes, and the SWITCH and RESUME markers) go into one ordered content queue; a single consumer thread applies the receiver rows above at dequeue and hands accepted records to the existing endpoint dispatch queue, which keeps its bounds and age shedding. The consumer is non-blocking and is a stage before the endpoint dispatch, so a blocking wait in the dispatch (such as awaitTerminalAck in AndroidVideoReceiver) cannot delay a marker. There is no epoch stamp: the fence is the carrier-instance id plus the consumer's per-direction state, so a valid record that follows RESUME or SWITCH in the queue is not discarded. Carrier events are queue entries too: READY completing (carrier up), a read error or end, a liveness or write-watchdog close, and the start of an attempt are put into the same queue by the thread that detects them, in reader order relative to that carrier's content, and the consumer applies them like any record; so a carrier's records are always handled before its death, and the machine is only ever called from the consumer. Everything else (ECHO, PAIR, CONTACT, HOST, DECLINE, BIND, READY, CLOSE) is handled directly by the link layer or the closing coordinator from the reader thread. Defensive rule: before the opener, the consumer reads the cleartext BME1 sequence (EndToEndContentProtocol.inspect) and the mesh sequence (lastMeshSequence in EndToEndStreamReader.readContent) and drops any record not above the last opened one, so a stale or duplicate record can never make the opener throw.

Liveness after losing O. There is no standby then: N's own heartbeat timeout applies, and a stall on N is a session failure through the existing path (the only carrier is gone). RESUME is never written to a dead carrier. Losing O while N is still in BIND/READY does not end the session: the phone writes nothing and opens nothing (records sealed meanwhile are lost; state is re-asserted afterwards), waits for that attempt and applies the O-dies rows when READY completes; the attempt is closed at its ceiling (5 s from accept, the deadline table below), and only then, with O gone, does the session end (stage 0 finding 2).

Fragmented call video. At a normal switch the boundary is a frame boundary, so no frame is split. Wherever the receiver rows say fragment suppress mode, the consumer drops the reassembler's partial buffer and skips every call-video fragment until one with offset 0 arrives while the buffer is empty (offset 0 is the only resync point); while suppressing, the reassembler's requireIdleFor check (CallVideoFragments.kt) must not fire for lifecycle records or signals; outside suppress mode all strict checks are unchanged. "Request a keyframe" in the receiver rows means asking the peer's encoder; "force a local keyframe" and "drop the unsent fragments" in the sender rows act on this phone's own encoder and writer (the machine has separate outputs for the three).

Controls (state-only re-assertion, no acknowledgements).

  • After every carrier event (a SWITCH the sender writes, a RESUME, a fallback) the sender re-asserts once, under fresh sequences, the current value of each indispensable state: the stream lifecycle state (BME1 type 4), the microphone and camera state of a call, and the stream ENDED state if one is pending (inside the sender's existing 2 s STREAM_END_ACK_TIMEOUT_MS wait). A superseded value is never sent; old ciphertext is never replayed. Within one carrier and direction the opener's strict order means a re-assertion always arrives after the originals, and the fence forbids two carriers delivering one direction, so a late re-assertion cannot revert a newer value; no wire revision field is needed. Nothing is acknowledged or retried, so there is no CTRL_ACK and the opener need not return a sequence.
  • Pre-ACTIVE signals (invite, ringing, accept and the rest of call setup) have no message ID and a duplicate would end the call: they are never re-sent, and no cutover starts before the call has been ACTIVE for 3 s.
  • HANG_UP. The hang-up sender seals HANG_UP on the current carrier and writes CLOSE on every live carrier that finished BIND and READY; it releases the microphone, camera and screen at once, drains the writers in the background and waits for all carrier readers to end or 3 s (the current PRIVATE_CALL_PEER_CLOSE_WAIT_MS behavior). A received HANG_UP or CLOSE ends the call once; they may arrive in either order and the first wins; a second one, and a simultaneous remote hang-up while already ending, is benign. One session-level closing coordinator and a single finalizer own the shutdown of both carriers, the endpoint keys and the UI. A new carrier's reader inherits the per-link reader limits (such as maxCiphertextBytes) of the link it replaces.
  • Labels, discovery records and presence offers are best effort (their own protocols resend them in a later session).

Implementation stage 0: the pure state machine and its model test (the gate before any app wiring)

The sender rows, the receiver rows, the content queue rules, the hold and the re-assertion trigger are written first as a pure Kotlin state machine with no Android types (inputs: local seal of a record, arrive(carrier, record), carrierDied(c), carrierClosed(c), timer ticks; outputs: write on carrier c, deliver to the opener, drop, close c, request keyframe, enter or leave suppress mode, re-assert state, end session). A JVM model-checking test runs it before any AndroidPrivateVideoCall, receiver or forwarder code is touched:

  • Exhaustive bounded search over small alphabets: each side seals up to k records (a mix of media and indispensable-state records), with both directions switching independently and simultaneously; arrival on O and N in order within a carrier but with arbitrary delay and interleaving between carriers; either carrier dying at any step on either side (seen at different times by the two sides); SWITCH, RESUME and hold-expiry timings; duplicates and drops of whole records. Randomized long runs with recorded seeds add larger cases.
  • Invariants checked at every step. (1) The session never ends while a carrier lives (it ends only on a protocol violation or with no carrier), and no rule closes the last carrier that is alive. (2) The opener is never fed a record that is not strictly above the last opened one (it never throws). (3) The delivered records are a strictly increasing subsequence of the sealed ones (records may be lost, never duplicated or reordered). (4) After the last carrier event and a quiet period, every indispensable state is equal on both sides (the re-assertion works). (5) No deadlock: from every reachable state with a live carrier, bounded timer ticks bring each direction to SEND_ON and ACCEPT_FROM naming the same live carrier. (6) A closed carrier instance never delivers. (7) Nothing is written to a carrier this phone has already closed. (8) A normal switch loses no media record: with no fault, duplicate or drop, and no record delayed beyond the hold, every sealed media record is opened, whatever the old link had queued at the switch.

Status (2026-10-14): implemented and passing, after the corrections to the rules above (DECISIONS "Stage 0 model findings": four from the model, then review 6). The machine is CarrierCutover (named ClassicCutover until 2026-11-04): pure Kotlin, injected clock, no Android types, not wired into the app. Its inputs are the local seal of a record, a record read from a carrier, an upgrade attempt starting, becoming ready or dying, the call becoming active and the clock tick (the owner also injects the age of the old link's oldest queued record and whether the next sealed record starts a frame); its outputs are write on carrier c, deliver to the opener, drop, close carrier, re-assert state, force local keyframe, request peer keyframe, reset send fragments, suppress fragments and end session. The model separates the reader threads from the consumer (a record read from a carrier waits in the content queue while carrier events are handled, so the consumer can meet records of a carrier closed in the meantime), lets each phone notice a carrier's death up to 2 s after the other, and checks that no policy rule closes a carrier that is the last one alive. Only the machine in this state, with the corrected rules, may be wired into the carrier owner and the consumer. This replaces reasoning about interleavings in prose.

Binding

EXP_old = BMH3: the link key schedule's EXP ("reserved for channel binding", above); the link exporter, never the endpoint reveal exporter
          BMH2: HKDF-Expand(HKDF-Extract(salt = 32 zero bytes, IKM = k_i2r | k_r2i),
                            "BluetoothVideoMesh/ClassicUpgrade/1/exporter", 32)    canonical initiator-to-responder then responder-to-initiator order, not local send/receive order
X(link) = EXP_old of that link
UP      = HKDF-Expand(EXP_old, "BluetoothVideoMesh/ClassicUpgrade/1/key", 32)
TH_new  = BMH3: TH(T_final) of the new link; BMH2: SHA-256(initiator hello | responder hello), the encoded signed hellos, canonical order
mac_X   = HMAC-SHA-256(UP, "bind" | role (1 guest, 2 host) | ticket | challenge | TH_new)
BIND_X  = role | mac_X

Lifecycle: the exporter, X(link) and UP are derived at the end of the old link's handshake before its handshake keys are erased (SecureBluetoothLink.kt around line 286 erases them today; Bmh3LinkHandshake.session wipes EXP); the final transcript hash is exposed to the session object; UP is held for the whole old session (a retry needs a fresh ticket under the same UP; there is no re-derivation after the keys are gone) while the feature is on and a key is held, and wiped at session teardown; K_cx is a separate long-lived secret. The pre-proof binds a fresh host challenge and the exact first handshake message, so an intercepted proof cannot be replayed on another socket or with another handshake; BIND binds the new transcript; the pinned identity ties the new link to the old one; the expected opposite role byte stops reflection. A link-layer attacker on the Classic link can at most drop or delay it (media stays sealed by BME1).

Deadlines and limits

Item Value
HOST ticket validity at most 20 s
Page, SDP and connect 12 s; at most one dial per 30 s per contact
Pre-proof read (29 bytes + first handshake message) 2 s absolute, from accept
Link handshake, then BIND, READY 5 s absolute from accept to READY verified (a watchdog closes the socket)
Cutover no acknowledgement and no latency probe; receiver hold of early N records 1 s and 5 MiB; no normal switch while the old link's oldest queued record has waited 500 ms (half the hold) and not before the next frame start; carrier stall or silence 2 s; 30 s cooldown after a fallback and at most 2 fallbacks per session. The media interruption at the receiver is a measured target of 200 ms with margin, not a guaranteed bound
Whole attempt from HOST to the first record on the new link 25 s
Phone-wide accept budget at most 2 concurrent upgrade sockets and 6 accepted sockets per 30 s; the listener is closed at ticket expiry, never because of unrelated invalid connections (easy suppression of an upgrade by a radio attacker is an accepted residual: LE continues)
Pre-auth bytes per socket at most 21 sent and 2,081 read; one HMAC and one SHA-256 of at most 2,052 bytes before a decision; no signing
Failures a failed attempt (including a guest DECLINE reason 6) gets one retry after 30 s, then none this session; 3 failures for one address in an hour stop hosting and dialing it for an hour
Classic media children at most 2 per sender or forwarder until measured

An attempt is cancelled and logged abort reason=old_link_degraded if the old link's oldest queued record exceeds 2 s while it runs.

Threats and residuals

Spoofing, wrong echoes and claims, tracking, replay, hijack and denial of service are covered in DECISIONS "Design CX" (threat model). Stated residuals: a Classic session exposes parts of the BD_ADDR to any radio in range (the LAP in every access code, Core 5.4 Vol 2 Part B) and the address can be paged later while Bluetooth is on; a contact that learned the address keeps it after the user forgets the contact, and no revocation reaches it; PAIR tells the contact the user chose that this phone verified it; a stale CONTACT tells a peer that deleted its key that this phone still holds one; a mistyped address that belongs to a nearby responsive device would see the dialing contact's address during paging before any UUID lookup or BIND can reject it (a random 48-bit typo hitting a nearby device is negligible; a malicious contact already learns the guest's address in this design); bit 5 is a stable capability bit visible to strangers who connect (and to a sniffer of a cleartext BMH2 hello); a malicious contact can make a phone page an arbitrary address about once per 30 s. Nothing in BMH2, BMH3, BMS1, BME1, BFG1 or BGK1 changes in structure.

Logs and vectors (to be written with the code)

BENCHMARK classic_upgrade: event=pair|contact|echo|self_address|host|listen|dial|bind|ready|switch|switch_on_n|resume|hold_expired|reassert|close|revert|abort | requested= | effective= | address=...XX:XX | source=typed|echo|proven | gen= | msFromAnswer= | pauseMs= | reason=. Vectors to pin in a JVM test: each BCX1 record, the 21- and 29-byte frames, the per-attempt UUID, the exporter, X(link), the candidate key and the CONTACT/ECHO tags for a fixed pair of directional keys in canonical order, the pre-proof over a fixed challenge and first message, and a BIND over a fixed transcript hash; plus the reader's length check before allocation, and the stage-0 model test below (every row of both tables, plus the exhaustive and randomized interleavings) and a fragment-reset test.

Draft: Wi-Fi transport (Design WF revision 3, not built)

Status: wire draft only, revision 3. It supersedes revision 2 (calls only, Android 13 and newer, Wi-Fi Aware only). No build sends or accepts any byte below except where "built" is stated, and no phone has run it. Reasons, research, the phone capability census, the measurement plan and the owner's answers are in DECISIONS "Design WF" and "Multi-radio mesh (2026-11-04)". With every switch off (the three Settings switches below and the DEBUG extras of SWITCHES), every byte on a link and in the air is exactly as in the sections above except (a) the build marker WIFI inside the encrypted BMH3 block (below), which older builds skip, and (b) one CAPS record each way per hop between two builds that both implement BWF1, with every bit clear (CAPS is sent whatever the switches say, so the peer cannot tell why Wi-Fi is not offered); a BWF1 record is rejected as an unknown hop type exactly as by an older build.

What it is. An optional, faster carrier for every hop: a direct private call, a stream parent-to-child hop and a relayed-call hop. Bluetooth LE stays the discovery, control and security plane: two phones find each other, connect and authenticate exactly as today, and only then may they add a Wi-Fi data path as a second carrier of the same hop. The Wi-Fi carrier is untrusted transport. It carries the same BMS1 hop records, sealed under keys derived from the authenticated hop, and inside them the unchanged BME1 end-to-end records, so no new trust path, identity or key-agreement protocol is added. If the Wi-Fi carrier fails while Bluetooth is alive, the hop continues on Bluetooth. The mesh is one automatic network: the user chooses how visible the phone is, never a radio.

Visibility levels (the user's choice).

Level Switches What goes on air before a hop is authenticated
Private (default) wifi_carrier Nothing over Wi-Fi. Discovery is Bluetooth only (rotating IDs). Wi-Fi is used only after a hop is authenticated
Wider reach wifi_carrier and wifi_wider_reach Also Wi-Fi Aware discovery of the same rotating announcement (below); a peer seen only over Wi-Fi can be called over Wi-Fi
Wi-Fi Direct wifi_carrier and wifi_direct (independent of the level above) Wi-Fi Direct group owner or client for phones without Wi-Fi Aware and for Windows PCs; shows the device's Wi-Fi Direct name and possibly a stable Wi-Fi Direct address

API levels. Android 13 and newer (API 33): Wi-Fi Aware at level Private. Android 11 and 12 (API 30 to 32): Wi-Fi Aware only at level Wider reach (the user must turn Location on; the data path's band cannot be read there, see "Band check"). Android 10 (API 29): no Wi-Fi Aware (no public setPmk). Wi-Fi Direct: API 29 and newer.

Scope. Direct private calls with video (audio-only calls stay on Bluetooth), stream parent-to-child hops, relayed-call hops, and a Wi-Fi-native hop (a hop whose first carrier is Wi-Fi, Wider reach). Windows PCs take part in direct calls with Android phones over Wi-Fi Direct, always as the group owner. Excluded: emergency calls and SOS (Design EM kinds, BEC1, BER1 resumed sessions), verification sessions, iPhone and Mac. Not in this revision: one shared Wi-Fi resource for several children (a later revision needs its own credential design), multicast.

Rules that hold everywhere.

  • The upgrade never weakens anything. BMS1 and BME1 run unchanged over the new carrier; the Wi-Fi layer's own security (Wi-Fi Aware NCS-SK with a derived PMK, WPA2-PSK for Wi-Fi Direct) is defence in depth only. A phone never sends plaintext media over Wi-Fi, never requests an open data path and never falls back to an unauthenticated path.
  • Bluetooth stays the standby. Wherever a hop has a Bluetooth carrier it stays open while Wi-Fi carries media. Jamming is handled by redundancy, not detection: a jammed carrier dies by its liveness rule and the other carries on.
  • Never fatal while a carrier lives. The invariant of "Draft: Classic address exchange and media upgrade" ("Cutover and fallback: lossy by design, never fatal") applies: no carrier race may end a hop while a carrier is alive; a race may only lose records.
  • Failures stay local. A peer-caused failure (a peer that times out, declines or fails binding) costs only that hop's attempts; only failures this phone observes in its own platform (attach refused, data path refused locally, Wi-Fi turned off) count toward the phone-wide limit. No failure on one hop changes another hop's eligibility, and no recovery resets Wi-Fi, Wi-Fi Aware or another app's resources.
  • One carrier change at a time per hop. An in-call link renewal (Design SR), a Classic upgrade (Design CX) and a Wi-Fi attempt never run together on one hop; precedence below.
  • Explicit choice. An attempt needs the user's switch on at both ends; a phone with it off sends CAPS with every bit clear and never says why. A side that cannot derive the hop key also sends CAPS with every bit clear on that hop.
  • Hop-local. Every BWF1 record, every attempt and every carrier belongs to one hop; a relay or forwarder terminates them and never copies one or any address learned from one.

Reserved elements

Entered in the "Draft number registry" (the single authority); no collision with the values there.

Namespace Value Why this one
BMH3 extension type (below 0x80 non-critical) 8 WIFI First free value. Inside the encrypted, signed identity block
BMS1 hop record type 16 BWF1 First free value. Rejected as unknown by every reader that has not opted in, like types 11 to 15
BWF1 kind 1 CAPS, 2 HOST, 3 DECLINE, 4 PICK, 5 ARMED, 6 BIND, 7 READY, 8 SWITCH, 9 RESUME, 10 CLOSE, 11 CREDS New namespace. Kinds 7 to 10 have the numbers, bodies and rules of BCX1 kinds 7 to 10; kind 6 has the role of BCX1 kind 6 with its own body
First frame on a dedicated upgrade socket BWU1, only on the TCP socket of a Wi-Fi carrier that upgrades a hop Never on a Bluetooth listener; the BMH2/BMH3/BCU1/BOB1 dispatch is untouched
First bytes on a Wi-Fi-native socket the normal BMH3 link handshake Dispatch by magic, as on a Bluetooth listener
Wi-Fi Aware discovery messages service-specific info MWD1; follow-up messages MWK1 and MWA1 Only in Wider reach; formats below
Hello bits, advertisement bytes, GATT UUIDs, rotating-ID roles none Nothing on air before a connection changes in Private; Wider reach adds only the Aware publication below

No fixed Wi-Fi Aware service name, Wi-Fi Direct network name or TCP port is reserved; names are derived per attempt or per rotating-ID epoch, and ports are derived or drawn per attempt.

Eligibility (each phone decides for itself, before every attempt)

A phone sets the Wi-Fi Aware bits of CAPS and takes part in an Aware attempt only while all of rules 1 to 6 hold; otherwise it sends CAPS with those bits clear or DECLINE, without blaming the peer. Rules are evaluated in this order and the first failing rule gives the DECLINE reason in brackets.

  1. Switch, permission and API [1]. wifi_carrier is on. API 33 and newer: NEARBY_WIFI_DEVICES is granted (requested when the user turns the switch on, never during a call). API 30 to 32: wifi_wider_reach is on, fine location is granted and Location is turned on. API below 30: never. The manifest also needs CHANGE_NETWORK_STATE, besides ACCESS_WIFI_STATE, CHANGE_WIFI_STATE and INTERNET. A revoked grant clears the bits at the next check.
  2. Hardware and radio [1 if the platform feature is missing, 3 if Aware is unavailable now or Wi-Fi is off]. FEATURE_WIFI_AWARE, WifiAwareManager.isAvailable() and Wi-Fi enabled.
  3. No other Aware client [2]. At level Private on API 31 and newer, no other app holds Wi-Fi Aware (isDeviceAttached() is false before this phone attaches), so this phone's attach enables Aware and the platform draws fresh random interface addresses; an unreadable value counts as "another client". At level Wider reach this rule is not applied: this app holds the attach for discovery, and addresses re-randomise at the platform interval (30 minutes by default); the Wider reach switch text states it.
  4. Band [7]. This phone's own Wi-Fi client connection, if any, is on 5 GHz or higher (frequency of at least 4900 MHz), because a data path on 2.4 GHz shares that channel and the Bluetooth standby. A phone that is not connected passes.
  5. Family policy [1]. The device-family registry has not turned Aware off for this phone family (a DeviceFamilyPolicy entry with the census evidence, its source and an off-switch).
  6. Resources, power and pause [5 battery saver, battery below 15 percent while not charging, thermal status SEVERE or worse; 2 hotspot or a Wi-Fi Direct group of this app active, or no free data path; 4 phone-wide pause]. Not in battery saver; at least 15 percent battery or charging; thermal status below SEVERE; no hotspot and no Wi-Fi Direct group of this app active; the phone-wide pause (below) has expired; at least one free data path.

Wi-Fi Direct (group owner and client bits) is allowed while: wifi_direct is on, the platform has Wi-Fi Direct, Wi-Fi is on, API 29 or newer, permission (API 33 and newer: NEARBY_WIFI_DEVICES; API 29 to 32: fine location and Location on), the family registry has not turned Wi-Fi Direct off, no hotspot, no Wi-Fi Aware attempt or carrier of this app holds the attach (the platform answers BUSY otherwise; the Wider reach discovery hold does not count, a Direct attempt pauses it first and resumes it when the attempt fails or the carrier closes), and the battery, thermal and pause terms of rule 6 hold.

Capability: the WIFI marker, the build-tag gate and CAPS

BMH3 extension type 8 WIFI (non-critical). Value 1 byte: version = 1; another version is treated as absent. A build marker: every build that implements BWF1 sends it, whatever the user's switch and the hardware, so it says nothing about the user's choice or the phone's Wi-Fi. It is allowed only in the initiator's block (msg3) of a link handshake; in a responder block, in an end-to-end block or with another length it fails the handshake. Relay edges carry it too: it is a build marker inside the encrypted block, as revealing as the build tag. Why initiator-only: a responder must not tell an unadmitted initiator (anyone who connects) anything; the area would have room (the fullest responder link block is 107 of 112 bytes, so 4 more would fit), so this is a privacy rule, not a size one. The responder that implements BWF1 opts its reader in when the verified block carried WIFI; the initiator opts in when it sent WIFI.

BMH2 hops (stream hops). A BMH2 link has no extension area. A hop may carry BWF1 only when both hellos carry the signed build tag (link-hello bit 7, "Build tag in the link hello") with a release number of at least WIFI_FIRST_RELEASE, the release number of the first build that ships BWF1 (debug builds use that release number as their own build tag on BMH2 links while any Wi-Fi switch is on; release builds send their real build tag while wifi_carrier is on, even when "Tell me about newer versions nearby" is off, so stream peers can see this app's release number; with wifi_carrier off nothing changes). No tag or a lower number: no BWF1 on that hop, ever.

CAPS after admission. On a hop that may carry BWF1, each side sends CAPS once, and a phone sends any other BWF1 record only after both CAPS were exchanged on this hop. CAPS is sent whatever the switches say (a phone with every switch off sends one with every bit clear), so the peer cannot tell "switch off" from "no Wi-Fi" from "not eligible now". A stranger who connects but is never admitted learns nothing. A Wi-Fi-native hop (its first carrier is Wi-Fi) sends no CAPS and attempts no upgrade: it has no other carrier to move to. Timing:

Hop When CAPS is sent
Direct call (BMH3) The responder sends first once the call is ACTIVE and, on a link with any pseudonym or throwaway identity, only after both reveals have verified (Design F phase 2: BDX1 REVEAL in both directions); the initiator answers
Stream parent-to-child hop The child sends CAPS after its first complete stream header was delivered; the parent answers
Relayed-call hop (BMH3) The responder first, once it is ready (a call endpoint: as for a direct call; a relay: after it forwarded the first end-to-end call record toward that neighbour); the initiator answers. A side sends BWF1 only to a peer proven to read it, and on a BMH3 hop only the responder has that proof (it saw the WIFI marker)
Relay-to-relay hop (BMH2, both build tags visible) The initiator first, after it forwarded the first end-to-end call record toward that neighbour

BWF1 records (hop type 16)

Plaintext of the BMS1 record: "BWF1" (4) | kind (1) | body. Integers are big-endian. A record longer than 96 bytes plaintext (the header's ciphertext length is checked against 96 plus the 16-byte tag, and at least 5 plus the tag, before allocating), is a framing error that closes that link. Forward compatibility: CAPS bits 4 to 7 and every field named reserved are sent 0 and ignored on receipt; a record of an unknown kind is ignored and logged (bwf1_unknown_kind). A wrong body length, an out-of-range field, a record out of order or a repeated CAPS is a Wi-Fi protocol violation: received on the Bluetooth carrier it ends Wi-Fi for that hop (release its attempts and Wi-Fi carriers, send no more BWF1 on it, log bwf1_violation) and never closes the Bluetooth carrier; received on a Wi-Fi carrier it closes that Wi-Fi carrier (the hop falls back to its standby). Records follow the exact-next BMS1 ordering of the carrier they travel on. The link layer consumes every type-16 record itself. A decoder checks, in this order: total length, magic, kind, body length, fields.

Kind Name Body (bytes) Rules
1 CAPS bearers (1) | freeHost (1) | freeGuest (1) | reserved (1) = 4 Each direction once per hop (a repeat is a protocol error). bearers: bit 0 Wi-Fi Aware eligible now (all of "Eligibility"), bit 1 Aware instant communication mode will be requested (supported by the hardware and, until it is measured, switched on only by a DEBUG test arm), bit 2 Wi-Fi Direct group owner possible now, bit 3 Wi-Fi Direct client possible now, bits 4 to 7 sent 0 and ignored on receipt. freeHost and freeGuest: data paths this phone could still serve as responder and as initiator (0 to 7, from getAvailableAwareResources()); not used for Wi-Fi Direct. reserved is sent 0 and ignored. Bits 0 and 1 are set only while the Wi-Fi switch is on and eligibility holds; bits 2 and 3 only while the Wi-Fi Direct switch is on and its conditions hold
2 HOST status (1) | bearer (1) | ticket (8) | validMs (2) | freqMhz (2) | reserved (2) = 16 Host to guest. Status 1: the host attached (Aware) or started its group (Direct), published or listens under the names derived from ticket; validMs 1 to 20,000. Status 0: nothing offered (stale CAPS, local reservation failed); validMs and freqMhz must be 0; no penalty to either side. bearer: 1 Wi-Fi Aware, 2 Wi-Fi Direct. freqMhz: Wi-Fi Aware 0; Wi-Fi Direct the group's frequency (at least 4900) or 0 when the host does not know it (a Windows host) or its group is below 4900 (possible only under the DEBUG arm test_wifi_allow_24ghz); the guest checks the band after joining (see Wi-Fi Direct). reserved is sent 0 and ignored. At most one outstanding HOST per hop; a fresh ticket for every attempt
3 DECLINE ticket (8) | reason (1) = 9 Either side. 1 switch off or not eligible now, 2 busy (radio held by another use), 3 Wi-Fi off or unavailable, 4 limit or cooldown, 5 battery or thermal, 6 join, data path or connect failed or timed out, 7 data path not on 5 GHz. Both sides release everything of that ticket
4 PICK ticket (8) | challenge (16) = 24 Guest to host, on the hop's other carrier (Bluetooth): the challenge the host sent to the guest's discovery handle. Pins the one discovery handle the host may serve. Wi-Fi Aware only
5 ARMED ticket (8) = 8 Host to guest, on Bluetooth: the host's data-path request for the picked handle is registered; the guest may request its side now. Wi-Fi Aware only
6 BIND role (1) | ticket (8) = 9 First sealed record on the new carrier, each direction (see "Binding"). Role 1 guest, 2 host
7 READY recvCeilingKbps (2) | familyCapKbps (2) | transportBudgetKbps (2) | reserved (2) = 8 On the new carrier after both BINDs, each direction, independent of any media: this phone's receive ceiling for the new carrier, its device-family cap for this peer (0 = none) and the transport budget it measured or the family default. reserved is sent 0 and ignored
8 SWITCH gen (4) | ticket (8) | e2eBoundary (8) | meshBoundary (8) = 28 Sender to receiver, in-band on the old carrier after the last record before the boundary (the normal move), or on the new carrier when the sender sees the old carrier dead: from the BME1 content sequence e2eBoundary (it covers every content type) and the mesh envelope sequence meshBoundary (hop type 7; 0 if none) on, this direction travels on the new carrier. The boundary is the next sealed sequence at a frame boundary. Idempotent: a repeat of the same gen and boundary is ignored. ticket names the attempt that made the new carrier (eight zero bytes name the hop's first carrier, which has no attempt); a receiver acts on a marker only for its current new carrier or for the attempt whose BIND and READY are still running here (it keeps the boundary and applies it when READY completes); a marker for any other ticket changes nothing and burns its generation. gen is at least 1; both boundaries are below 2^63
9 RESUME gen (4) | ticket (8) | e2eNext (8) | meshNext (8) = 28 Sender to receiver on the old or standby carrier: from these sequences on, this direction uses the old carrier again; the new carrier is finished for this hop. Ignored (and the generation burned) if the receiver is already reading this direction from the old carrier, or is reading it from a new carrier other than the one ticket names. Same field limits as SWITCH
10 CLOSE reason (1) = 1 Optional and informational: the hop is ending (a hang-up). A receiver accepts it on any carrier at any time after both CAPS, logs it (close_ignored) and takes no action; the hang-up itself arrives as call content. Android sends none; Windows sends it on the Bluetooth carrier only. Reason 1 hang-up; other values are accepted and logged
11 CREDS ticket (8) | port (2) | ipv4 (4) = 14 Wi-Fi Direct only, host to guest, right after a HOST with bearer 2 and status 1: where the guest connects once it has joined the group (port 1 to 65535, ipv4 not 0.0.0.0). The guest derives the group's network name and password itself from the hop key; they are never sent. See "Wi-Fi Direct"

For calls the boundary fields have the meanings given above (BME1 content sequence and mesh envelope sequence). For stream and relay hops, which have no end-to-end sequence, e2eBoundary and e2eNext carry the sequence of the one record type that hop's machine governs in that direction (a mesh envelope or an end-to-end content record; see "Stream and relay hops"), and meshBoundary and meshNext are written 0 and ignored. No new wire field.

One cutover machine serves Classic, Wi-Fi Aware and Wi-Fi Direct: the pure state machine of "Implementation stage 0" (CarrierCutover, model-tested; named ClassicCutover until 2026-11-04), whose new carrier is named by (bearer, ticket), extended with the events of "Cutover, fallback and the switch". Its model test covers those events, in-call link renewal, stale callbacks and two concurrent links (one failing on purpose) before any wiring.

Bearer and roles

The bearer is fixed for the hop session from the two CAPS: Wi-Fi Aware when both set bit 0 and a role tuple exists; otherwise Wi-Fi Direct when a Direct tuple exists; otherwise none. A failed Aware attempt never moves to Wi-Fi Direct; the retry (if any) is Aware again.

Role tuples are chosen among those both CAPS support, deterministically, with the link initiator and responder (for a stream hop the child dials, so the child is the initiator):

  • Wi-Fi Aware: host = link responder, guest = link initiator when the responder's freeHost and the initiator's freeGuest are nonzero; otherwise the reverse when those are nonzero; otherwise none.
  • Wi-Fi Direct: host = link responder when the responder sets bit 2 and the initiator sets bit 3; otherwise the reverse; otherwise none. A Windows PC sets bit 2 only, so it is always the host, and an Android phone toward a Windows PC is always the guest.

Just before starting, each side reserves its resources locally; a phone whose CAPS has become stale answers HOST status 0 or DECLINE 1 or 2, which costs nobody a penalty. The host owns the listening socket (the Wi-Fi Aware publisher is the server, as in Apple's accessory rules).

Keys, names and passwords

Calls:    UW = HKDF-Expand(EXP_e2e, "BluetoothVideoMesh/WifiUpgrade/1/key", 32)
          EXP_e2e = the call's BMH3 end-to-end exporter; derived before the handshake keys are erased; held with the call session
Hops:     UW = HKDF-Expand(HKDF-Extract(salt = "BluetoothVideoMesh/WifiUpgrade/1/hop",
                                         IKM  = min(kA, kB) | max(kA, kB)),
                           "BluetoothVideoMesh/WifiUpgrade/1/key", 32)
          kA, kB = the hop's two directional BMS1 keys of the hop's first carrier, ordered bytewise (unsigned, min first) so both ends agree
          Stream hops and relayed-call hops; a hop has no end-to-end peer
ticket   = 8 random bytes, chosen by the host for each attempt
svc      = "m" | b32(HMAC-SHA-256(UW, "svc" | ticket))[0:15]            Wi-Fi Aware service name, 16 characters
pmk      = HMAC-SHA-256(UW, "pmk" | ticket)                              32 bytes: the data-path PMK
port     = 49152 + (u32be(HMAC-SHA-256(UW, "port" | ticket)[0:4]) mod 16384)   the Wi-Fi Aware host's TCP port, 49152 to 65535
knock    = gN | HMAC-SHA-256(UW, "knock" | ticket | gN)[0:16]           the guest's first Aware message to one handle (32 bytes; gN = 16 fresh random bytes per handle)
challenge = c | HMAC-SHA-256(UW, "chal" | ticket | gN | c)[0:16]        the host's answer to that knock (32 bytes; c = 16 fresh random bytes)
ssid     = "DIRECT-" | a2 | "-" | a6                                     Wi-Fi Direct network name
psk      = base64url(HMAC-SHA-256(UW, "psk" | ticket))[0:43]             no padding, 43 characters
a2, a6   = characters of the alphabet "abcdefghjkmnpqrstuvwxyz23456789" (31 characters, Share app nearby's alphabet) chosen by successive big-endian 16-bit values of
           HMAC-SHA-256(UW, "ssid" | ticket), each taken mod 31: values from bytes (0,1) and (2,3) for a2, bytes 4 to 15 for a6

port is not a secret: the data path is PMK-secured, the host accepts only the data path's peer address and BWU1 then authenticates the binding; its own label keeps it independent of every other value derived from UW, and 2^32 is a multiple of 16384, so every port is equally likely. b32 is the lowercase RFC 4648 base32 alphabet without padding. HKDF-Extract is HMAC-SHA-256(salt, IKM) and one block of HKDF-Expand is HMAC-SHA-256(PRK, info | 0x01). The service ID on air (48 bits of SHA-256 of svc) changes with every attempt and names neither the app nor the phone. UW is derived where the keys exist (before any wipe) and wiped when the hop (or call) ends and by the emergency wipe; it is retained from the moment the keys exist, whatever the switches say, so a switch turned on mid-call works for that call. A call's UW belongs to the call session, so an in-call link renewal does not invalidate it.

Setup (Wi-Fi Aware)

Every attempt has one owner keyed by (hop session, bearer, ticket, local generation). It reserves its resources atomically, stamps every platform callback and timer with that key, ignores and releases anything that arrives for an ended or other attempt, and on every terminal path (success handed to the carrier, DECLINE, timeout, switch off, hang-up, emergency wipe, process shutdown) closes the discovery sessions, unregisters every network callback it registered (ConnectivityManager.unregisterNetworkCallback, which is what releases a data path), closes its sockets and closes its attach. The attach is reference-counted between the attempt, the carrier that grows out of it and (Wider reach) the discovery plane, and closed when all are gone. Platform callbacks run on one dedicated thread and never block on a hop's locks.

  1. Host. Check "Eligibility", attach, publish svc with PUBLISH_TYPE_SOLICITED (no unsolicited publication; a matching subscription is answered in unicast), no service-specific info, no match filter, a TTL of validMs, ranging off, instant communication mode on 5 GHz when both CAPS set bit 1. Send HOST.
  2. Guest. Check "Eligibility", attach, subscribe to svc with SUBSCRIBE_TYPE_ACTIVE, the same instant-mode choice and a TTL of validMs. On each onServiceDiscovered for svc, send knock with a fresh gN to that handle (at most 4 handles knocked at the same time; a handle that returns no verified challenge after 3 knock sends is dropped and frees its place; at most 16 handles per attempt).
  3. Host, challenge. For a handle whose knock verifies (constant time), send challenge bound to that knock's gN to that handle, at most 8 challenges per attempt; the same knock resent on the same handle gets the same challenge. The guest accepts a challenge only if its MAC verifies against the gN it sent to that same handle, so a stranger can neither invent a challenge nor move a genuine one to another handle. The knock is replayable by anyone who heard it; it only lets a copier obtain a challenge it cannot use, never a data path.
  4. Guest, pick. Send PICK(ticket, c) on Bluetooth with the c of the verified challenge received on the handle it discovered. The authenticated Bluetooth link thus tells the host which discovery handle belongs to its real peer.
  5. Host, arm. For the picked handle only, request the network with WifiAwareNetworkSpecifier.Builder(publishSession, peerHandle).setPmk(pmk).setPort(port).setChannelFrequencyMhz(5745, false) with the derived port (the channel hint only if isSetChannelOnDataPathSupported(); the hint is not enforcement), using the timeout overload of requestNetwork (12 s). Then send ARMED. The host binds its server socket (below) to port when its own data path is up, because it needs that interface's link-local address. One data-path reservation per attempt.
  6. Guest, request. On ARMED, request the network with Builder(subscribeSession, peerHandle).setPmk(pmk) and the same channel hint, timeout overload (12 s). On onCapabilitiesChanged, read WifiAwareNetworkInfo (peer IPv6 link-local address with scope, port). A nonzero platform port is used as given; a port of 0 (some platforms deliver none) means the derived port. The guest retries any connect failure every 250 ms until the 5 s connect window ends, because the host may still be binding.
  7. Band check, both sides. API 33 and newer: before any media moves, each side reads the data path's channels (WifiAwareNetworkInfo.getChannelInfoList()). Any channel below 5 GHz, or no channel information, ends the attempt with DECLINE 7 (a local fact, no penalty). API 30 to 32 (Wider reach, the band cannot be read): the attempt proceeds only if this phone's own Wi-Fi client is off or on 5 GHz, and the data path is closed as a quality fallback if the measured goodput stays below twice the hop's learned Bluetooth capacity for 5 s. The channels of both phones (and their Wi-Fi client channels) are logged.
  8. Socket. The host's ServerSocket is bound to the data interface's own IPv6 link-local address and scope from the Aware network's LinkProperties (never the wildcard address), and accepts only a connection whose remote address is the peerIpv6Addr of that network; anything else is closed without counting toward the attempt. The guest connects with network.getSocketFactory() (bound to that network) to the peer address and port. No process-wide network binding, no saved network, no default route, no tethering or bridging; the socket carries only BWU1 and BMS1. TCP_NODELAY; a send buffer of at most 256 KiB; TCP keep-alive off; connect timeout 5 s.

Port rule compatibility. No build before 0.5.0-beta implements BWF1 (0.4.4 has no Wi-Fi code). BMH3 hops opt in by the signed WIFI marker and BMH2 hops by the build tag; the rule ships with 0.5.0-beta (release 11), the first release that sends the marker. A release must never be cut from a commit before this rule.

Neither side requests a data path for a handle that was not picked, so an observer who replays knocks costs the host at most 8 challenge messages per attempt. An attempt that fails only because of handles that never returned a verified challenge does not use the hop's normal retry; a hop allows at most 2 such attempts. A phone never holds a Wi-Fi Aware attach and a Wi-Fi Direct group at the same time (the platform answers BUSY).

Wi-Fi Direct

Between two Android phones the host creates a temporary group and the guest joins it by credentials, without peer discovery and without a prompt on either phone.

  • Host. WifiP2pConfig.Builder with setNetworkName(ssid), setPassphrase(psk), setGroupOperatingBand(GROUP_OWNER_BAND_5GHZ) and enablePersistentMode(false), then createGroup(channel, config, listener). If the phone's Wi-Fi client is connected on 5 GHz the host uses setGroupOperatingFrequency(staFrequency) instead of setGroupOperatingBand; otherwise setGroupOperatingBand(GROUP_OWNER_BAND_5GHZ). A 2.4 GHz client does not make the host decline. HOST.bearer = 2 and HOST.freqMhz = the group's frequency, or 0 when the group is below 4900 (the record refuses a lower nonzero value; the guest then judges the band itself after joining). createGroup without a configuration is never called (it creates a persistent group). The host refuses to start while any Wi-Fi Direct group exists; it removes a stranded group only when its name matches the sealed record of groups this app created.
  • Guest. connect(channel, WifiP2pConfig.Builder().setNetworkName(ssid).setPassphrase(psk).setGroupOperatingFrequency(freqMhz).enablePersistentMode(false).build(), listener): the platform's promptless fast-connection path. When HOST.freqMhz is 0 the guest sets no frequency. After joining it reads WifiP2pGroup.getFrequency() (public since API 29); at or above 4900 MHz it proceeds; below 4900 it sends DECLINE 7 unless the DEBUG arm test_wifi_allow_24ghz is on. If the read gives 0 (unreadable) and the host is an Android phone whose authenticated HOST.freqMhz is at least 4900, that value is the band evidence and it proceeds; otherwise (a Windows host sends 0) it sends DECLINE 7. This does not depend on Wider reach.
  • Sockets. The host's server socket is bound to the group interface address (192.168.49.1 on Android hosts) and accepts only peers in the group's subnet (192.168.49.0/24 for an Android host); the guest binds its socket to its group-interface address. The host sends CREDS right after HOST to say where to connect.
  • Windows host, Android guest. Roles are fixed: Windows is always the host (CAPS bit 2 only) and Android always the guest (bit 3). Windows starts WiFiDirectAdvertisementPublisher with Advertisement.IsAutonomousGroupOwnerEnabled = true and LegacySettings { IsEnabled = true, Ssid = ssid, Passphrase = psk }, opens a TCP listener bound to the address of the group interface, and sends HOST(status 1, bearer 2, ticket, validMs, freqMhz 0) followed by CREDS. The Android guest joins as above without a frequency.
  • Exposure. A Wi-Fi Direct group shows its network name on air (derived per attempt, in the pattern of Share app nearby) and each phone's Wi-Fi Direct device name and address: the host's in its own frames, and the joining phone's in its association, which an Android host also lists in the group information it sends to anyone who probes. Where the maker enabled address randomisation the device address is re-drawn only at interface creation and never while a persistent group is saved; apps cannot delete persistent groups. This is why Wi-Fi Direct is a separate opt-in whose text names these facts.
  • Android guards. The frequency in HOST is the group's real frequency when it is 4900 MHz or higher; an Android host whose group is below that (possible only under the DEBUG arm test_wifi_allow_24ghz) sends 0, because the record refuses a nonzero value below 4900, and the guest judges the band itself after joining. A guest that reads no group frequency uses the host's authenticated HOST frequency when the host sent one (an Android host). The guest counts as joined when its group interface has an address, pins that interface, binds its socket to its address, refuses a CREDS address outside that interface's subnet and refuses a group whose subnet overlaps another interface of the phone. While an attempt is prepared the Wider reach discovery plane is paused and resumes after the group is removed. A phone offers to join a host that offers only the group-owner bit (a Windows PC) only after that join has been proven on a phone.
  • Cleanup. The group lives exactly as long as its carrier. A group whose removal is not confirmed is quarantined: no new Wi-Fi Direct or Wi-Fi Aware work starts until its absence is confirmed; the phone retries the removal and, after six failures, asks the user to turn Wi-Fi off and on. A stranded group is removed at start-up by the same sealed-record rule, and a group the app did not create is never removed.

Binding, cutover and limits are as for Wi-Fi Aware (one group per phone, one carrier or attempt per hop).

Binding

The first bytes on the new socket bind it to the hop before the host consumes the ticket or computes a shared secret. No signature and no identity is sent on the Wi-Fi socket.

host  -> guest on accept:  "BWU1" (4) | 0x01 | challenge (16)                                     21 bytes, fresh per socket
guest -> host:             "BWU1" (4) | 0x02 | ticket (8) | e_G (65) | n_G (32) | pre (16)          126 bytes
host  -> guest:            "BWU1" (4) | 0x03 | e_H (65) | n_H (32)                                 102 bytes
pre     = HMAC-SHA-256(UW, "pre" | ticket | challenge | e_G | n_G)[0:16]
th      = SHA-256("BluetoothVideoMesh/WifiUpgrade/1/bind" | 0x00 | frame1 | frame2 | frame3)       whole frames, magic included
dh      = ECDH(e_G, e_H)                                                                           32-byte x coordinate
K       = HKDF-Extract(salt = HMAC-SHA-256(UW, "bind" | ticket | th), IKM = dh)
k_g2h   = HKDF-Expand(K, "BluetoothVideoMesh/WifiUpgrade/1/g2h", 32)
k_h2g   = HKDF-Expand(K, "BluetoothVideoMesh/WifiUpgrade/1/h2g", 32)

e_G and e_H are 65-byte uncompressed P-256 points (0x04 | x | y), n_G and n_H 32 random bytes. The host reads frame 2 under an absolute 2 s deadline from accept, verifies pre in constant time before it consumes the ticket, validates the point or computes anything else, and closes silently on any mismatch without touching the ticket. A valid pre consumes the ticket atomically (never restored). Points are validated as in BMH3 (both coordinates below p, on the curve). k_g2h and k_h2g are the BMS1 directional keys of the new carrier, each direction from sequence 0, with the 17-byte header as associated data. Each side's first sealed record is BIND(role, ticket); a peer that cannot open it, or sees the wrong role or ticket, closes the socket. Opening the peer's BIND is the key confirmation: only the two ends of the hop hold UW. The ephemeral exchange keeps the carrier's keys secret even if UW were later disclosed from memory, unless the attacker actively held the socket at setup.

What this binds: the new carrier to exactly this hop (UW), this attempt (ticket) and this socket's transcript (th). A valid BIND authenticates the app peer only, not the network around it; that is why the socket rules exist. Wi-Fi addresses and discovery handles are routing hints, never identities.

Wider reach: Wi-Fi Aware discovery and Wi-Fi-native hops

While wifi_wider_reach is on and the app is reachable (the same condition under which it advertises on Bluetooth LE), the phone holds one Wi-Fi Aware attach and runs one publish (PUBLISH_TYPE_UNSOLICITED) and one subscribe (SUBSCRIBE_TYPE_PASSIVE) under the service name

wsvc(epoch) = "m" | b32(SHA-256("BluetoothVideoMesh/WifiDiscovery/1" | epoch_be8))[0:15]       epoch_be8 = the rotating-ID epoch as 8 bytes big-endian
MWD1        = "MWD1" (4) | version 1 (1) | announcement (22)                                     27 bytes, service-specific info

announcement is exactly the bytes of the current Bluetooth discovery announcement (DiscoveryAnnouncementCodec, version 5), so Wi-Fi discovery shows nothing that Bluetooth does not already show, and rotates with it: at every rotating-ID epoch boundary the phone stops and restarts both sessions with the new epoch's names. A sighting is merged with Bluetooth sightings by the same key the call list uses; Wi-Fi sightings never feed the copied-ID detector. Screen-off slows Aware discovery to seconds. API 30 to 32: publish and subscribe run only while the app is in the foreground or the active-session service runs (location app-op); a build without the location service type runs Wider reach discovery in the foreground only.

Connecting to a peer seen only over Wi-Fi (a Wi-Fi-native hop). The initiator sends the follow-up message MWK1 = "MWK1" (4) | nonce (8) = 12 bytes to the peer's handle (at most once per peer per 10 s). The publisher registers a per-peer responder request with

pmk_native = HMAC-SHA-256(SHA-256("BluetoothVideoMesh/WifiNative/1" | epoch_be8), "pmk" | nonce)
MWA1       = "MWA1" (4) | nonce (8) | port (2)                                                    14 bytes, the answer

and answers MWA1 with the port it listens on (drawn at random per data path: there is no UW before the BMH3 handshake). The initiator requests the data path with the same pmk_native, connects to the platform's port when the platform reports one and otherwise to the MWA1 port, and runs the normal BMH3 link handshake as the first bytes on the socket; the BMH3 handshake provides the security exactly as on a Bluetooth LE CoC (the data path's own encryption keeps out passive non-MeshLive listeners only, because pmk_native is derivable from public values). Follow-up messages are retried at most 3 times, 1 s apart; failure is native_fail. The resulting hop's first carrier is Wi-Fi: Bluetooth may not join it in this revision, the hop ends when Wi-Fi dies, and the call screen offers a redial.

Stream and relay hops

A stream parent-to-child hop and each of a relayed call's two hops carry the same BWF1 exchange and the same CarrierCutover as a call's link. The differences are these, and nothing else:

  • Governed records. After a hop's machine starts, each direction carries governed records of exactly one type: MESH_ENVELOPE (parent to a child that relays, and every forwarder-to-child hop) or END_TO_END_CONTENT (parent to a leaf child, child-to-parent control, relayed-call content both ways). Setup records that are not machine-governed (GROUP_KEY, labels, the end-to-end handshake) are written before the first CAPS, hence before the machine starts. SWITCH.boundary and RESUME.next are the governed record's existing sequence (the mesh envelope sequence or the BME1 content sequence), carried in the e2eBoundary/e2eNext field with the meshBoundary/meshNext field written 0 and ignored. No wire field is added for stream or relay hops.
  • CAPS timing. The CAPS timing table of "Cutover, fallback and the switch" ("When an upgrade is attempted", relays included) is the only statement of it; the child sends first on a stream hop and a relay sends only to a neighbour proven to read BWF1.
  • UW. A hop's UW is derived from the hop's two directional BMS1 keys as "Keys, names and passwords" states; every attempt on that hop uses it.
  • Heartbeats. A stream child does not deliver LINK_HEARTBEAT records to its stream reader; they still count for the carrier's liveness. The parent and a relay handle them.
  • The gate. On a BMH2 stream hop, both link hellos must carry a signed build tag at or above WIFI_FIRST_RELEASE (a debug build signs WIFI_FIRST_RELEASE while any Wi-Fi switch is on). On a BMH3 hop, the initiator's link block carries the WIFI marker, relay edges included; the responder opts in when it saw the marker. No marker or a lower tag: no BWF1 on that hop, ever.

Cutover, fallback and the switch

The Classic draft's "Two carriers, one state per direction", sender rows, receiver rows, single inbound consumer, fragment suppression and state-only re-assertion apply, with O the hop's first carrier (Bluetooth, or Wi-Fi on a Wi-Fi-native hop, which has no second carrier) and N the Wi-Fi carrier. Added rules:

  • Liveness of N. While a direction of N has nothing else to send, it sends a BMS1 heartbeat record every 500 ms. N is dead after 2.5 s without a complete, authenticated record (a partial frame does not count), after a write stalled 2.5 s, on a socket error or end, or at once on NetworkCallback.onLost for its network, ACTION_WIFI_AWARE_STATE_CHANGED with Aware unavailable, or Wi-Fi turned off. O keeps its own heartbeat and silence rules, now only as that carrier's death rule.
  • Hop liveness is carrier-aware. The hop is alive while any carrier is alive. A liveness or supervisor rule that ends a hop because its Bluetooth link died (including the in-call renewal's 12 s bound and its per-call supervisor) does not fire while N lives.
  • In-call link renewal (Design SR) and Wi-Fi. Precedence: a running renewal wins over a Wi-Fi attempt (the attempt ends with DECLINE 2, no penalty); no Wi-Fi attempt starts while a renewal runs; a renewal never closes N. When a renewal binds, the cutover machine receives a "standby replaced" event: O becomes the new link, any BWF1 or marker record in flight on the old link is lost, and each side writes its last marker once on the new link (a SWITCH with the same generation and boundary if it sends on N, a RESUME if it fell back to Bluetooth; both are idempotent at the receiver). UW and the CAPS exchange belong to the call session and are not repeated. A renewal while N carries the media may run at the renewal's normal priority; it restores the standby.
  • Switch turned off (either phone). A serialised, generation-stamped transition: no new attempt from that moment, and an attempt in progress is cancelled. If O is alive: a planned fallback (below, not counted as a fallback and with no cooldown), then N closes and every Wi-Fi resource is released; UW is kept until the hop or call ends. If N is the only live carrier: the hop continues on N (closing it would end the call), the phone shows "Wi-Fi is the only connection to this call now; it turns off when the call ends", and the transition completes as soon as a Bluetooth standby is back (by renewal) or the call ends. Until it completes the phone keeps answering every cutover record (SWITCH, RESUME, re-assertions) as before; only new attempts stop.
  • Planned fallback order (switch off, or the quality rule): restore the Bluetooth link's connection priority and wait for it (at most 500 ms), retarget the encoder to the Bluetooth budget, write RESUME on O, and send the first video record after RESUME as a keyframe on Bluetooth. An unplanned fallback (death of N) writes RESUME at once and does the same steps.
  • Quality rule. A carrier whose delivered media age (95th percentile over 5 s) is above the Bluetooth standby's learned budget would give, or whose stall share over 5 s exceeds 10 percent, is closed as a planned fallback; it counts toward the limit of 2 fallbacks per hop. With no Bluetooth standby it is never closed by this rule.
  • Loss of O while N lives follows the existing row: the hop continues on N alone; a renewal may restore a standby; the hop ends only when no carrier is left.
  • Hang-up, emergency wipe, process shutdown. Besides the Classic rules for CLOSE, the attempt owner and the carrier withdraw the publish or subscribe, unregister every network request, close the attach, close the Wi-Fi Direct group and remove anything else they created.

Rate. The send limit on N is min(requested rate, peer READY.recvCeiling, peer READY.familyCap if nonzero, own family cap, min of both transportBudgets), as for Classic. A user's fixed bitrate, resolution or frame rate is never raised. Until the measurement plan decides otherwise, Automatic quality keeps the Bluetooth ladder on Wi-Fi: no rate is raised because the carrier is faster. Rate-keyed learned state is kept per carrier (Wi-Fi measurements never enter the Bluetooth learned capacity), and transport-keyed state (pacer seed, flush interval, playback delay profile, audio headroom) is re-derived at every switch.

When an upgrade is attempted. Both CAPS allow a tuple, the hop is ACTIVE with video on for 3 s (calls) or its first stream header was delivered (streams), no renewal running, not in a cooldown, fewer than 2 fallbacks and 2 failed attempts on this hop. Audio-only calls stay on Bluetooth.

Deadlines and limits

Item Value
HOST validity 20 s; publish and subscribe TTL equal
Discovery to PICK 8 s; at most 4 handles knocked at once and 16 per attempt; at most 8 challenges sent per attempt
Data-path request requestNetwork timeout overload 12 s, both sides; one reservation per attempt
IPv6 address after data path 5 s (our own 12 s data-path timeout plus 5 s)
Binding frame 2 within 2 s of accept; BIND and READY both ways within 5 s of accept; at most 4 accepted sockets per attempt, only from the expected peer address
Whole attempt, HOST to the first record on N 25 s
Retries (per hop) a failed attempt gets one retry after 30 s; 2 failed attempts end Wi-Fi for that hop
Phone-wide (local faults only) 3 local platform failures in 60 minutes (attach refused, local data-path refusal, Wi-Fi off during setup) pause all attempts for 60 minutes; peer timeouts, declines and bad binding never count here
Carrier liveness heartbeat 500 ms when idle; dead after 2.5 s without a complete authenticated record or a 2.5 s stalled write; platform signals at once
Cutover receiver hold 1 s and 5 MiB; no normal switch while the old carrier's oldest queued record has waited 500 ms; 30 s cooldown after a fallback; at most 2 fallbacks per hop
Phone-wide resources one Wi-Fi Aware attach (reference-counted); data paths at most the smallest of the census value, the platform characteristic and 4; one Wi-Fi Direct group; never a Wi-Fi Aware attach and a Wi-Fi Direct group at the same time
Wider reach discovery publish and subscribe while the app is reachable; restart both at every rotating-ID epoch boundary (an open Wi-Fi link may defer one restart by at most one epoch, so names rotate at least every 30 minutes; while a Wi-Fi link is open the phone's Wi-Fi address may not change); at most 1 knock (MWK1) per peer per 10 s; at most 3 sends per MWK1, 1 s apart
Socket TCP_NODELAY; send buffer 256 KiB; keep-alive off; connect timeout 5 s
Pre-key work per socket at most 21 bytes sent and 126 read before pre verifies; one HMAC; then one point check, one ECDH and three HMACs

What is on air while it is used

Who Wi-Fi Aware Wi-Fi Direct Wider reach discovery
Passive radio observer Subscribe frames naming a per-attempt service ID; the host's unicast answers; NAN synchronisation beacons if either phone becomes a cluster master; the Aware interface addresses (random; re-drawn when Aware is enabled, kept for the life of a data path); NAN capability attributes, which can indicate the chipset family; an encrypted data path; traffic volume and timing The group's network name (random pattern DIRECT-xx-xxxxxx), each phone's Wi-Fi Direct device name and address (the group owner's, and the joining phone's in the owner's group information), an encrypted data path, traffic volume and timing Aware publish and subscribe frames naming the epoch's service ID (changes every rotating-ID epoch) and the announcement bytes that Bluetooth already shows; that this phone uses Wi-Fi Aware; its Aware address (re-drawn every 30 minutes by default)
The authenticated peer the above, plus the IPv6 link-local addresses the above, plus the group addresses the same
Not exposed identity keys, node IDs, rotating IDs beyond what Bluetooth shows, names, call content, any fixed app marker identity keys, node IDs, call content, any fixed app marker (the Wi-Fi Direct device name belongs to the system) identity keys, node IDs

Stated residuals: a sniffer can correlate a Bluetooth link and a Wi-Fi data path that start together; during one hop the Aware addresses stay the same across rotating-ID epochs (as the hop's Bluetooth connection address does); capability attributes narrow the chipset family; another app that attaches to Aware shares the cluster for that time (at level Private rule 3 keeps an address drawn for one hop from being reused for a later one as far as the platform allows, at Wider reach it is not applied); a Wi-Fi Direct device address may be stable on makers that did not enable randomisation; Wider reach tells nearby Wi-Fi devices that this phone uses Wi-Fi Aware.

Threats and residuals

  • Impersonation and MITM on Wi-Fi. Without UW an attacker cannot join an authenticated data path (PMK), pass pre, make a valid BIND, or read or forge a record. A relay of Wi-Fi frames can only drop or delay them. A Wi-Fi-native hop is protected by BMH3 exactly as a Bluetooth LE CoC is; its PMK is public-derived.
  • Downgrade. Jamming Wi-Fi or refusing to join forces Bluetooth, with the same security and lower quality. WIFI is inside the signed block and CAPS inside the sealed link, so neither can be stripped or forged. A BMH2 hop's build tag is inside the signed hello.
  • Denial of service. A stranger learns nothing about Wi-Fi without admission at level Private; names change every attempt. A replayed knock earns at most a challenge it cannot use (8 per attempt); a replayed BWU1 frame fails pre against a fresh challenge and never consumes the ticket. An admitted malicious peer can make this phone attach and publish about twice per hop and cannot affect other hops (failures stay per hop). At Wider reach any nearby phone can send MWK1 (at most one answer per peer per 10 s). Jamming remains possible; Bluetooth then continues.
  • Hostile network. An admitted peer holds UW and can run a hostile Aware peer. The binding of sockets to the data interface and the expected peer address, and the absence of any route, saved network or process binding, keep that peer from reaching anything but this one socket.
  • Not solved. Traffic analysis; the residuals in "What is on air"; a peer learning after admission whether this phone can use Wi-Fi now. Nothing in BMH2, BMH3, BMS1, BME1, BFG1 or BGK1 changes in structure.

Logs and vectors (to be written with the code)

BENCHMARK wifi_upgrade: event=caps|host|decline|attach|publish|subscribe|knock|challenge|pick|armed|ndp_request|ndp_up|port|band|socket|bind|ready|switch|resume|standby_replaced|switch_off|close|fallback|release|abort|sweep|quarantine | hop=call|stream|relay|native | level=private|wider | bearer=aware|direct | carrier=aware|direct | role=host|guest | chMhz= | staMhz= | peerChMhz= | gen= | msFromActive= | setupMs= | pauseMs= | requested= | effective= | fault=local|peer | group=ready|failed | freq_band=5|24|unknown | freq_source=group|host|none | same_channel=true|false|unknown | address_ok=true|false | removed= | source=derived|random|platform|none | match=true|false | reason= (port: the host's source is derived or random; the guest's is platform, derived or none, and match says whether a nonzero platform port equals the derived one), BENCHMARK wifi_caps: with the census fields of DECISIONS "Design WF", BENCHMARK wifi_discovery: event=publish|subscribe|seen|lost|knock|native_up|native_fail|native_port | epoch= | source=platform|mwa1|none | match=true|false | reason= (native_port: the initiator's port source, and whether a nonzero platform port equals the MWA1 one) and, whenever a hop's effective carrier differs from the requested one, BENCHMARK carrier: hop= | requested=aware|direct|bluetooth | effective= | reason=. Never a MAC address, IPv6 address, device name, SSID, service name, ticket, port or key; an address appears only as SHA-256(processSalt | value)[0:8] with a random per-process salt that is never written to disk.

Vectors to pin in a JVM test and a Rust test (shared JSON): UW for a call and for a hop, svc, pmk, port (call UW with two tickets, hop UW), knock and challenge for fixed gN and c, ssid, psk, wsvc, the native PMK for fixed inputs; the three BWU1 frames, pre, th, K, k_g2h, k_h2g for fixed ephemeral keys; each BWF1 record and each malformed record; the reader's length check before allocating; the MWD1, MWK1 and MWA1 messages; the WIFI placement rules (initiator link block only); pre replay against a second challenge; the role-tuple choice for every combination of CAPS; and the extended cutover model.