← Blog

April 20, 2026 · protocol · 5 min read

WiFi Direct and peer-to-peer video calls land (in scaffold)

This is an engineering milestone post, not a feature announcement. The v2.2 sprint landed two things in the codebase: a high-bandwidth side channel that kicks in when BLE mesh is the wrong tool for the job, and the first working draft of a peer-to-peer calls stack. Neither is user-ready yet. The wiring is laid, the walls are not painted, and we want to be straightforward about which is which.

Why a 200 KB threshold

Hoppr's primary transport is Bluetooth Low Energy. BLE mesh is the right choice for short, frequent messages. It is the wrong choice for a 4 MB photo or a live audio stream. BLE caps its practical throughput somewhere in the range of a few tens of kilobytes per second under real-world interference, and the gossip-flood design amplifies every byte across every relay within range. Pushing a large payload through that pipe is slow for the sender and wasteful for every nearby device that has to carry a copy.

We picked 200 KB as the line. Below it, payloads ride the existing BLE mesh path; the flood is cheap at that scale and the mesh preserves its reach advantage. Above it, the router hands off to a higher-bandwidth local transport and delivers directly between the two endpoints rather than broadcasting. 200 KB is not load-bearing magic; it is where the curves cross on our measurements, and it is a single constant that we can adjust as the radio environment teaches us more.

Why MultipeerConnectivity on iOS

On Android, the high-bandwidth transport is Wi-Fi Direct. It is a documented API, apps can drive discovery and group formation directly, and the bandwidth is in the tens of megabits per second range. On iOS, Apple does not expose Wi-Fi Direct. There is no public API surface for it; any attempt to drive it from a sandboxed app goes nowhere.

What Apple does expose is MultipeerConnectivity. It uses a mix of Wi-Fi and peer-to-peer Wi-Fi under the hood, picks the best available option, and presents a session abstraction to the app. We do not control which radio it picks; Apple does, and that is the trade. The practical effect for Hoppr is that the high-bandwidth side channel works within the Apple ecosystem and within the Android ecosystem, but not yet between them. Cross-platform large-payload transfer stays on BLE mesh until we land a shared fallback. That is honest and also the only path Apple leaves open.

How signaling rides the Noise session

WebRTC needs signaling: an offer, an answer, a stream of ICE candidates. Most apps stand up a signaling server for this. We did not want to. A signaling server is a new trust boundary, a new uptime dependency, and a new place where metadata can pool.

Instead, signaling is a payload type inside the Noise session the two peers already have. When you start a call with someone, the offer is serialized, wrapped in a new NoisePayloadType variant (0x70), and delivered over the same encrypted tunnel that carries your DMs. The answer comes back the same way. ICE candidates trickle across as they are discovered. None of this traffic is distinguishable from regular chat to anyone on the outside; it is Noise ciphertext from end to end.

The consequence is that the call inherits the chat's trust story exactly. If you have a verified Noise session with a peer, you have a trusted signaling channel with that peer. There is no second authentication, no second identity to manage, and nothing new for the user to configure. The media itself (DTLS-SRTP, once the xcframework is in place) keys off the same Noise session so the video stream is bound to the same identity the DMs are.

Limitations of a first-sprint WebRTC integration

This is a scaffold. Here is what it is not.

The call UI is glue. It is Jetpack Compose on Android and SwiftUI on iOS, it renders, it wires to the signaling layer, and it has mute and end-call. It does not have a ring tone, a proper incoming-call screen, participant tiles, reconnection handling, or audio-only fallback when bandwidth collapses. We built enough UI to prove the plumbing works end-to-end and stopped there for this sprint.

The WebRTC engines are accessed reflectively. On Android the Stream WebRTC fork is loaded via reflection; on iOS the WebRTC xcframework has not yet been bundled at all. If the library is not present at runtime, the call stack returns a polite "not available" state rather than crashing. This is a deliberate holding pattern while we finalise how the dependency ships in the build systems. The replacement is mechanical: direct imports, no reflection, once the AAR and xcframework are wired in.

OEM Wi-Fi Direct quirks are not yet handled. A handful of Android vendors serialise the underlying P2P manager in ways that can deadlock under concurrent discover-and-advertise. We know the shape of the problem; we have not yet written the per-OEM workaround table.

Cross-platform interop is not there. Android to Android works on Wi-Fi Direct. iOS to iOS works on MultipeerConnectivity. Android to iOS over the high-bandwidth channel does not work yet; those calls fall back to BLE-only for now, which means audio-only and low quality.

What ships next

Finish the dependency work so WebRTC is a first-class linked library rather than a reflective lookup. Run the first real two-device interop tests on both platforms. Design the per-OEM Wi-Fi Direct workaround table. Build the in-call UI that an actual human can use: ringing, accepting, declining, a participant view, and a graceful degradation path when the radio gets worse.

The v2.4 roadmap post talked about voice and video calls as a feature. This sprint is the part of that feature the user never sees: the transport decision, the signaling channel, the trust inheritance from Noise. When the part the user does see is ready, we will write that post too.

Published by the Hoppr team. Mirrored as a NIP-23 long-form event (kind 30023) on the Hoppr publication key. Questions: hello@hoppr.chat.