Back to blog
Transport19 min read

Wi-Fi Aware Transport Design — Neighbor Discovery & Data Paths

This article explains how PlainApp uses Wi-Fi Aware (NAN — Neighbor Awareness Networking) as the middle tier of its peer transport fallback chain, between LAN (same-subnet HTTPS) and BLE (last-resort GATT RPC). Wi-Fi Aware is what makes two PlainApp devices talk when they are on different SSIDs, guest vs IoT VLANs, or no Wi-Fi infrastructure at all — without ever needing an IP address from a DHCP server.

The article covers the Android-only Aware session lifecycle, the publish / subscribe discovery model, the two-phase role-split handshake that synchronizes requestNetwork on both sides within the framework's ~500 ms window, the per-peer link pool with idle sweeping, the IPv6 + custom-DNS trick that lets a single OkHttp client serve both LAN and Aware, and the prewarmer that triggers peer Aware startup via BLE.

For the broader fallback chain, see Chat Architecture. For the BLE transport that takes over when Aware is unavailable, see BLE Transport. For how the shared ChaCha20 key reused as the Aware PMK is established, see Pairing Flow.

Table of Contents

Why Wi-Fi Aware?

Wi-Fi Aware (IEEE 802.11bc, formerly NAN — Neighbor Awareness Networking) is a Wi-Fi Alliance certification that lets two devices discover each other and exchange data without any Wi-Fi infrastructure — no AP, no router, no DHCP. PlainApp uses it for two scenarios that LAN cannot cover:

  • Different SSIDs / VLANs. A phone on the guest network and a laptop on the IoT VLAN are both "online" via Wi-Fi but cannot reach each other's IP. Aware creates a direct device-to-device data path that bypasses the infrastructure entirely.
  • No infrastructure at all. Two devices in the wilderness with Wi-Fi on but no AP can still chat. (BLE also covers this, but Aware is much faster — ~10 ms round trips vs seconds, and MB/s vs tens of KB/s.)

Diagram 1
1

Platform constraints

Wi-Fi Aware is Android-only in PlainApp:

  • Android 13 (API 33) is the minimum — the WifiAwareNetworkSpecifier.Builder with setPort() and setPmk() overloads that PlainApp depends on require isTPlus().
  • iOS does not expose Wi-Fi Aware to third-party apps. iOS PlainApp falls directly from LAN to BLE; the WifiAwareTransport object is not even compiled into the iOS target (@RequiresApi(Build.VERSION_CODES.S) + androidMain source set).

This is why the PeerTransportRouter.buildList calls createWifiAwareTransport() — a factory that returns null on iOS.

Where Aware Sits in the Fallback Chain

PlainApp's PeerTransportRouter is an ordered list. For each send or downloadFile call, it walks the list and tries each transport until one succeeds; failures cascade down.

Diagram 2
2

Why is Aware "the middle" and not "the first"?

Because LAN is almost always faster when available. A same-subnet Wi-Fi hop through an AP is a single 802.11 frame exchange; an Aware data path adds an NDP setup (~5 s on first use) plus a second Wi-Fi radio context for the device-to-device link. If both are reachable, LAN wins on latency and throughput.

Conversely, BLE is always slower — but it works whenever both devices are paired. Aware is in the middle: faster than BLE, slower than LAN, and only available on Android 13+ devices with Wi-Fi on.

Session Lifecycle: Attach → Publish + Subscribe

A Wi-Fi Aware session is process-wide. There is exactly one WifiAwareSession per device; within it, PlainApp runs one publish session (so peers can discover us) and one subscribe session (so we can discover peers). Both are started the moment AwareSession.start() completes the attach callback.

Diagram 3
3

Why publish AND subscribe on the same device?

The Wi-Fi Aware discovery model is asymmetric: a publisher advertises a service, a subscriber scans for it. To make discovery symmetric (both devices discover each other), PlainApp does both at once. Without this, device A would have to know in advance whether it's the publisher or the subscriber for a given peer — but peer roles are determined later by clientId comparison (see Discovery & Role Assignment).

Publishing and subscribing simultaneously means each device sees the other's onServiceDiscovered (as subscriber) AND receives the other's hello messages (as publisher) — both directions of the handshake are always available.

Auto-restart on termination

Some Android variants (MIUI in particular) kill long-running Aware sessions to save battery. PlainApp handles this in the onSessionTerminated callbacks: it nulls out the terminated session and immediately calls publishOwnService / subscribeOwnService again on the still-attached WifiAwareSession. The attach session itself is not lost — only the publish/subscribe discovery session. Peer handles from before the termination become stale, which is why awaitPeerHandle checks the discoveredAt timestamp and discards handles older than 30 s.

Discovery & Role Assignment

The Wi-Fi Aware data-path protocol requires one side to act as the publisher (server) and the other as the subscriber (client). Both sides cannot simultaneously be the initiator — the framework rejects requests without a matching counterpart.

PlainApp assigns roles deterministically per peer using a simple lexicographic comparison of clientIds:

Diagram 4
4

Why deterministic and not negotiated?

A negotiated approach (e.g. "lower MAC is the server") would require an extra message exchange. The lexicographic comparison is idempotent, symmetric, and stateless: both devices compute the same role for the same pair without any communication. The clientId is a 13-character short UUID, so ties (clientId == peer.id) only happen when comparing a peer to itself — which never reaches the transport.

The role determines two things downstream:

  1. Who drives the retry loop. Only the client retries requestNetwork; the server makes exactly one attempt per hello received. This is critical for the 500 ms window (next section).
  2. Who sets the port. The publisher calls setPort(httpsPort) because it's the one accepting incoming connections on its HTTPS server port. The subscriber doesn't set a port — it learns the peer's port from the WifiAwareNetworkInfo after the data path is established.

The Two-Phase Handshake (hello + ready)

The hardest part of Wi-Fi Aware data-path setup is timing. The framework requires both sides to call connectivityManager.requestNetwork within roughly 500 ms of each other — if one side calls it before the other has registered its matching request, the framework rejects it immediately with onUnavailable ("releaseRequestAsUnfulfillableByAnyFactory").

PlainApp solves this with a two-message application-layer handshake that runs on top of the Aware L2 message channel (the same sendMessage API used by onServiceDiscovered):

Diagram 5
5

Why two messages (hello + ready) instead of just one?

The hello alone is not enough because of direction asymmetry. The subscriber can send hello the instant it discovers the publisher (in onServiceDiscovered), but the publisher cannot start requestNetwork until it has the subscriber's PeerHandle, which it only learns by receiving the hello. So the hello serves two purposes:

  1. Deliver the subscriber's PeerHandle to the publisher. The publisher needs it to build the WifiAwareNetworkSpecifier.
  2. Signal intent to connect. Receiving the hello tells the publisher "the subscriber is about to requestNetwork, so I should too."

The ready receipt exists for the opposite direction — to tell the subscriber "the publisher has registered its requestNetwork." Without it, the subscriber's requestNetwork might race ahead of the publisher's and get rejected by the framework. The ready receipt is a non-blocking signal: the subscriber doesn't wait for it before calling requestNetwork (that would add a round trip), but if it arrives while the subscriber is in IDLE state (between retry attempts), the subscriber can immediately retry without waiting for the RETRY_DELAY_MS gap.

The retry-loop asymmetry

This is the most subtle part of the design. Only the subscriber retries. The publisher makes exactly one requestNetwork attempt per hello received. This is because:

  • If both sides retried independently, their retry cycles would drift out of phase (different delay() durations, different GC pauses), and the two requestNetwork calls would rarely overlap inside the 500 ms window.
  • The subscriber's retry loop sends a fresh hello on each attempt, which re-triggers the publisher's buildLink via publishHelloListeners. This guarantees the publisher's requestNetwork always follows the hello by ~50 ms, well inside the 500 ms window.

This is documented in detail in AwarePeerLink.build.

NDP requestNetwork — The 500 ms Window

The requestNetwork call is the most timing-sensitive operation in the Aware transport. Here's what happens on each side:

Diagram 6
6

What the onUnavailable callback means

onUnavailable fires when the framework rejects the requestNetwork before finding a matching peer request. The PeerHandle itself is still valid — only the NDP (Neighbor Discovery Protocol) pairing failed because the other side hadn't registered yet. PlainApp deliberately does not call session.invalidatePeerHandle in this case, because invalidating the handle would discard the only signal that onServiceDiscovered was ever called (it fires once per peer per subscribe session lifetime). With the handle preserved, the retry can reuse it instead of waiting for a fresh discovery.

The same applies to the publisher-side handle from onMessageReceived — the publisher keeps the publishPeerHandles[fromCid] entry across failed attempts, so the subscriber's next hello re-uses the cached handle instead of being dropped.

Each paired peer gets its own AwarePeerLink object, owned by the process-wide AwareLinkPool. The pool handles discovery events, link reuse, and idle eviction.

Diagram 7
7

Why no auto-build on discovery?

The pool explicitly does not build a link when onServiceDiscovered fires. This is a critical decision: a busy coffee shop might have 100 PlainApp devices all publishing the "plain-peer" service. If each discovery triggered a requestNetwork, the framework would be flooded with NDP setup attempts and the Wi-Fi radio would be saturated.

Instead, the pool only records the PeerHandle and waits for one of:

  1. The local user sends a messageWifiAwareTransport.sendpool.buildLink(peer) (sender-side trigger).
  2. The remote peer sends a helloonPublishHelloReceivedbuildLink(peer) (receiver-side trigger).
  3. The remote peer sends a readyonSubscribeReadyReceivedbuildLink(peer) (receiver-side trigger).

This way, links are only built for peers that the user is actually exchanging messages with — not every PlainApp device in radio range.

Idle sweep

Every 10 seconds, the pool walks all links and closes any whose lastActiveAt is older than 60 seconds. Each send and downloadFile calls link.touch() to refresh the timestamp. This reclaims the Wi-Fi radio context and OkHttp connection pool for peers the user has stopped chatting with — important because Android limits the number of simultaneous Aware data paths to roughly 4–10 (device-dependent).

IPv6 Addressing & the plain-aware-peer DNS Trick

Wi-Fi Aware data paths use link-local IPv6 only. There is no IPv4, no DNS server, no DHCP. The peer's IPv6 address is delivered via the WifiAwareNetworkInfo.peerIpv6Addr field in onCapabilitiesChanged — a fe80::... address that's only meaningful on the Aware network interface.

PlainApp needs to send HTTPS requests to this address, but OkHttp's https:// URL parsing refuses raw IPv6 literals in a hostname (https://[fe80::abcd]:8443/ works, but routing it through a custom Dns resolver is cleaner). The trick:

Diagram 8
8

Why a sentinel hostname?

The alternative — passing the IPv6 literal directly in the URL — would require every call site to know about the link-local address. By using a sentinel hostname, the URL construction is identical for LAN and Aware: both produce a valid https://<host>:<port>/peer_graphql URL that OkHttp can parse. The only difference is the Dns implementation bound to the client — LAN uses the system DNS, Aware uses awareDns(peerIpv6) which returns the cached link-local address for the sentinel hostname and falls through to Dns.SYSTEM for anything else.

Why network.socketFactory?

Android's Network object represents a specific network interface (in this case, the Aware data path). By calling network.socketFactory and passing it to OkHttp's socketFactory config, we force all TCP sockets to be created on the Aware interface — not the default Wi-Fi or cellular interface. Without this, the OS would route the request via the default network, where the link-local IPv6 is unreachable, and the request would fail with ENETUNREACH.

Cryptography: PMK Derivation & ChaCha20 Reuse

Wi-Fi Aware supports an optional PMK (Pairwise Master Key) for the data path. When set, the L2 link itself is encrypted with that PMK — the Wi-Fi radio handles encryption, no application-layer crypto needed.

PlainApp derives the PMK from the same ChaCha20 shared key that LanTransport and BleTransport use for application-layer encryption:

Diagram 9
9

Why truncate to 32 bytes?

The Wi-Fi Aware PMK must be exactly 32 bytes (256 bits). The ChaCha20 shared key from pairing is also 32 bytes in the normal case, so the raw.size == 32 branch is the common path. The truncation/padding fallback handles the (theoretical) case where the key was stored shorter — padding with zeros to 32 bytes is a defensive measure, not something that happens in practice with properly paired peers.

The signed envelope is identical to LAN

Because createCryptoHttpClient is the same factory used by LanTransport, the L7 crypto on Aware is byte-for-byte identical to LAN. The server-side PeerGraphQLService doesn't know (or care) which transport delivered the request — it just sees a signed, encrypted GraphQL payload and decrypts it with the peer's shared key. This is the "one codebase, many transports" principle documented in Chat Architecture.

Message Send Path (End-to-End)

Putting it all together — what happens when a chat message is sent over Wi-Fi Aware:

Diagram 10
10

Notable design choices

  • Connection reuse. Unlike BleTransport, which tears down the GATT connection after every request, WifiAwareTransport reuses the Aware data path for as many requests as the user makes within the 60 s idle window. The first request pays the ~400 ms handshake; subsequent requests are ~10 ms round trips.
  • Same crypto as LAN. The ChaCha20 interceptor and signed envelope are byte-identical to LAN. The peer's PeerGraphQLService doesn't know which transport delivered the request.
  • No preemption on link failure. If buildLink fails, the transport throws TransportUnavailable and the router falls through to BLE. There is no retry within sendAwarePeerLink.build already does its own internal retry loop (with MAX_BUILD_ATTEMPTS = 1 on the client, more if the prewarmer has primed both sides).

File Download Path (End-to-End)

File downloads over Aware reuse the same data path as chat messages, but use a separate OkHttp client configured for streaming large files:

Diagram 11
11

Why a separate client for downloads?

The chat client (AwareHttpClientFactory.build) has a 30 s requestTimeoutMillis — appropriate for GraphQL mutations but catastrophic for a 100 MB file download. The download client (buildFileDownload) sets:

  • connectTimeoutMillis = 10_000 (longer than chat's 5 s, more tolerant of slow first-packet on a fresh data path)
  • readTimeout = 120 s per read (vs the implicit default of 10 s)
  • requestTimeoutMillis = 120_000 (2 minutes — enough for most files)
  • retryOnConnectionFailure(true) — a dropped mid-download read is retried instead of failing the whole transfer

It also omits the ChaCha20 interceptor. The /fs endpoint serves raw file bytes (not a signed GraphQL envelope), and the L2 PMK (when present) already encrypts the radio link. Double-encrypting a 50 MB video with ChaCha20 in software would waste CPU and slow the transfer.

Streaming, not buffering

Like the BLE path, Aware downloads stream the file through a ByteReadChannel — the file is written to a temp file as bytes arrive, not buffered in memory. PeerFileDownloader reads 8 KB chunks and emits progress events every second. The same DownloadedResponse / PeerFileDownloader / DownloadQueue pipeline is reused across all transports — transport-specific only the channel source.

Prewarming: BLE-Triggered Aware Startup

The biggest user-visible latency in the Aware path is the first handshake — if both sides haven't started Aware yet, the user's first message has to wait for:

  1. Local Aware session attach (~1 s)
  2. Local publish + subscribe start (~1 s)
  3. Remote peer's Aware startup (~2 s over BLE)
  4. Mutual discovery (~1 s)
  5. NDP handshake (~400 ms)

That's ~5 seconds before the first byte is sent. To hide this latency, PeerTransportPrewarmer runs on ChatPage entry and triggers the remote peer's Aware startup via BLE:

Diagram 12
12

The dual role of BLE

BLE serves two purposes here:

  1. Read the peer's current Aware state (cheap, no GATT connect — the scan response's serviceData byte0 carries the Aware flags).
  2. Trigger the peer to start Aware if it supports but isn't currently running it. This goes through the regular BleTransport.send path — a startAware GraphQL mutation encrypted with the shared ChaCha20 key, delivered via GATT RPC to the peer's /peer_graphql endpoint.

This is one of the few places where the transports cooperate rather than just fall back: BLE is used to pre-emptively upgrade the session to the faster Aware transport, before the user even notices.

Why optimistic setAwareRunning(true)?

The startAware mutation returns success as soon as the remote peer's resolver invokes WifiAwareTransport.start() — but the Aware session isn't actually attached yet (onAttached fires asynchronously). PlainApp marks the peer as awareRunning = true optimistically, because:

  • If it actually started, the next send will use Aware (fast).
  • If it didn't (e.g. peer's Wi-Fi is off), the next send's buildLink will fail with TransportUnavailable and fall back to BLE naturally.
  • The cost of a false positive is one ~5 s timeout, not a permanent block — PeerCircuitBreaker records the failure but doesn't open the BLE leg (BLE only opens on its own failures).

Why throttle to 30 s?

PeerTransportPrewarmer.prewarm(peerId) records a timestamp per peer and refuses to re-run within 30 s. This is because the user navigates back and forth between chat list and chat page frequently — without throttling, every navigation would trigger a BLE scan + startAware mutation, draining battery and spamming the BLE radio. The 30 s window is short enough to catch a peer that just came online (e.g. user opened the app on the remote device) but long enough to avoid spurious re-runs.

Failure Modes & the Fast-Skip Flag

Aware has more failure modes than any other transport. The isAwareRunning fast-skip flag is the single most important optimization in the whole module — without it, every send would waste 10 s on buildLink timing out before falling back to BLE.

Diagram 13
13

The isAwareRunning flag is the linchpin

Without this single boolean, every Aware send would either:

  • Always attempt buildLink → 10 s timeout on every send to a peer whose Aware isn't running.
  • Always skip Aware → never use it even when both sides have it running.

The flag is refreshed from two sources, in order of authority:

  1. BLE scan response (cheap, no GATT connect) — set by PeerTransportPrewarmer.refreshAwareFlagFromScan. The peer advertises its Aware state in the 9-byte serviceData payload (byte0 bitfield).
  2. GATT DISCOVER reply (authoritative) — set by PairingTransport.scanAndDiscover when a full discovery happens. This overwrites the scan hint.

When false, WifiAwareTransport.send and downloadFile throw TransportUnavailable immediately — no scan, no handshake, no timeout. The router falls through to BLE in microseconds.

Key Constants Reference

ConstantValueWherePurpose
AwareSession.SERVICE_NAME"plain-peer"DiscoveryService name published & subscribed by every PlainApp device
AwareSession.PEER_HANDLE_MAX_AGE_MS30 000PeerHandle cacheDiscard stale handles (peer's publish session may have been restarted)
AwareSession.READY_TIMEOUT_MS15 000HandshakeSubscriber wait for publisher's ready receipt
AwareSession.MSG_HELLO0HandshakeSubscriber → Publisher message ID
AwareSession.MSG_READY1HandshakePublisher → Subscriber message ID
AwarePeerLink.MAX_BUILD_ATTEMPTS1Handshake (client only)Single attempt — was 3, now 1 because prewarmer primes both sides
AwarePeerLink.ATTEMPT_TIMEOUT_MS5 000HandshakePer-attempt timeout — was 10 s, halved to speed fallback
AwarePeerLink.RETRY_DELAY_MS500HandshakeDelay between retry attempts (client only)
AwarePeerLink.REQUEST_TIMEOUT_MS30 000NDPconnectivityManager.requestNetwork timeout
AwareLinkPool.IDLE_TIMEOUT_MS60 000Pool sweepClose idle links after 60 s of inactivity
AwareLinkPool.IDLE_SWEEP_INTERVAL_MS10 000Pool sweepSweep interval
AwareHttpClientFactory.AWARE_HOST"plain-aware-peer"DNSSentinel hostname resolved to peer IPv6 by custom Dns
build chat clientconnectTimeout 5 s, requestTimeout 30 s, ChaCha20 interceptor
buildFileDownloadconnectTimeout 10 s, readTimeout 120 s, requestTimeout 120 s, no crypto
PeerTransportPrewarmer.PREWARM_TTL_MS30 000PrewarmThrottle per peer
PeerTransportPrewarmer.BLE_SCAN_TIMEOUT_MS15 000PrewarmBLE scan timeout for refreshAwareFlagFromScan
PeerCircuitBreaker.WINDOW_MS30 000Circuit breakerOpen duration after threshold
PeerCircuitBreaker.MAX_FAILURES2Circuit breakerFailures within window to open
TempData.httpsPort8443 (default)ServerPublisher's port advertised via WifiAwareNetworkSpecifier.setPort
BleServiceData.AWARE_SUPPORTED0x01BLE scan responseBit indicating peer supports Wi-Fi Aware
BleServiceData.AWARE_RUNNING0x02BLE scan responseBit indicating peer's Aware service is currently running

Design Trade-offs Recap

Diagram 14
14

Further Reading

  • Chat Architecture — how WifiAwareTransport fits into the LAN → Aware → BLE fallback chain and the broader chat send/receive pipeline.
  • BLE Transport — the last-resort transport that takes over when Aware is unavailable; also the channel used by the prewarmer to trigger Aware startup on the remote peer.
  • Pairing Flow — how the shared ChaCha20 key reused as the Aware PMK is established, and how the BLE scan response flags (AWARE_SUPPORTED / AWARE_RUNNING) are populated.