Flash · Library docs

Wire protocol

Session handshake, heartbeats, delivery acknowledgements and call signalling frames.

From docs/protocol.md · updated 2 September 2026

Version

Protocol version: 1

Current LAN Session

The initial LAN milestone does not yet transfer files. It implements a persistent TCP session after NSD discovery so two devices can verify that the advertised address and port are connectable and keep that connection alive.

The probe server prefers TCP port 45821. If that port is unavailable on the device, it falls back to a dynamic port and advertises the selected port through NSD. The stable preferred port reduces failures from stale mDNS/NSD cache entries pointing at an old random port.

Client hello

FLASH_HELLO version=1 deviceId=<escaped-device-id> name=<escaped-friendly-name>

Server response

FLASH_OK version=1 deviceId=<escaped-device-id> name=<escaped-friendly-name>

Disconnect notification

FLASH_DISCONNECT version=1 deviceId=<escaped-device-id> name=<escaped-friendly-name>

Heartbeat

FLASH_PING version=1 deviceId=<escaped-device-id> name=<escaped-friendly-name>
FLASH_PONG version=1 deviceId=<escaped-device-id> name=<escaped-friendly-name>

After a successful FLASH_HELLO / FLASH_OK exchange, both phones keep the TCP socket open. Each side sends periodic FLASH_PING messages and responds to received pings with FLASH_PONG. FLASH_DISCONNECT closes the live session and clears connected state on the peer.

Dead-peer detection (C4.3, 2026-08-23): ping cadence is driven by HeartbeatPolicy — default interval 10 s, missed threshold 3, so a silent peer is declared dead after ~30 s and the session is closed with reason heartbeat timeout (closing the socket from the tracker coroutine is what unblocks the peer-side blocked readLine(); see JDK Socket.close() contract). The legacy fixed 3 s cadence remains available via constructor parameter.

Delivery ACK (C4.8, additive)

Acked frames use a two-line envelope:

FLASH_DATA version=1 deviceId=<escaped-device-id> name=<escaped-friendly-name> frameId=<sender-uuid>
<single-line UTF-8 payload>

The receiver immediately echoes an acknowledgment for the envelope’s frameId (before/after processing the next-line payload) and delivers the payload line to its incoming-frame surface:

FLASH_ACK version=1 deviceId=<escaped-device-id> name=<escaped-friendly-name> frameId=<echoed-uuid>

Rules:

  • Correlation is by sender-chosen UUID (frameId), echoed verbatim.
  • Senders wait at most a bounded timeout per frame; on timeout the frame send fails (ConnectionTimeout) but the session stays open — liveness is owned solely by the heartbeat tracker.
  • Semantics are per-call at-most-once: no automatic retransmission on this transport layer; durable outbox retry (C6) supplies at-least-once with dedup by frameId.
  • Duplicate FLASH_ACK lines for one frameId are idempotent no-ops on the sender.
  • Frames sent without the envelope (plain lines) keep the pre-C4.8 wire format byte-for-byte; peers that never send FLASH_DATA need no changes.

Escaping

  • % becomes %25
  • space becomes %20
  • = becomes %3D

Experimental WebSocket Transfer Track (side track — not the main protocol)

Added 2026-08-20 at owner request (see ADR-007). Independent of the session above; uses minimal RFC 6455 WebSocket framing over TCP, cleartext ws:// on trusted LAN only.

  • Every device runs a WebSocket server (preferred port 45822, dynamic fallback) and can open any number of client connections, so 3+ devices can fully mesh.
  • Upgrade handshake: standard RFC 6455 (GET /flash-ws, Sec-WebSocket-Key / Sec-WebSocket-Accept, version 13); client frames are masked, server frames are not.
  • Pairing: both sides send a text frame immediately after upgrade:
FLASH_WS_HELLO version=1 deviceId=<escaped-device-id> name=<escaped-friendly-name>
  • Peers are keyed by deviceId; if a pair holds one connection per direction, the outbound one is primary and the inbound one is fallback.
  • File transfer (one active transfer per connection; messages are ordered):
FLASH_FILE_START version=1 transferId=<id> name=<escaped-file-name> size=<bytes-or--1>
<binary frames: raw file bytes, 64 KiB each, in order>
FLASH_FILE_END version=1 transferId=<id> bytes=<bytes-sent>
FLASH_FILE_ACK version=1 transferId=<id> received=<bytes-received> ok=<true|false>
  • Receiver saves to filesDir/ws-received/ (deduplicated names) and verifies the byte count before ok=true.
  • Escaping matches the session protocol (%25, %20, %3D).
  • Known limits: no TLS, no trust/verification UX, no resume, no hash verification, no app-level heartbeat (relies on TCP failure surfacing).

Calling (C7, 2026-09-02)

1:1 voice/video calls ride the WS mesh as text frames under the FLASH_CALL prefix, encoded with the same FlashTextFraming field rules as chat/pairing frames. Media itself travels over WebRTC (SRTP/DTLS, see ADR-025); these frames carry only signaling.

All frames share callId=<uuid> (caller-generated) and from=<escaped-device-id>. Conversation identity is implicit: the WS session’s peer device id is the conversation.

Call control frames

FLASH_CALL action=invite callId=<uuid> from=<id> video=<true|false> name=<escaped-name>
FLASH_CALL action=accept callId=<uuid> from=<id>
FLASH_CALL action=decline callId=<uuid> from=<id>
FLASH_CALL action=hangup callId=<uuid> from=<id>
  • invite: caller -> callee. video declares audio-only vs video intent. Caller enters dialing; callee enters ringing and shows the incoming-call UI/notification.
  • accept: callee -> caller after the user taps accept. Both sides proceed to SDP.
  • decline: callee -> caller (user tapped decline or auto-declined a second concurrent call). Call ends on both sides.
  • hangup: either side, any state. Call ends on both sides. Also sent on local teardown errors so the peer does not wait on a dead session.

SDP frames

FLASH_CALL action=offer callId=<uuid> from=<id> sdp=<escaped-sdp>
FLASH_CALL action=answer callId=<uuid> from=<id> sdp=<escaped-sdp>
  • The caller sends offer immediately after accept arrives (caller is the offerer; glare is impossible because only the caller offers).
  • SDP is the full session description string (type is implied by the action). As of ERROR-024/ADR-027 the sdp field is base64-encoded (RFC 4648, no whitespace, no =/%/space characters that collide with the text-framing escape rules), so the multi-line, whitespace-sensitive SDP survives the framing layer byte-for-byte. CallFrameCodec.decodeSdp tries base64 first and falls back to raw escaped text for legacy pre-hardening peers (a real SDP starts with v=0, which is not valid base64, so the fallback is unambiguous in practice). Offers are ~4-8 KB - within text-frame norms.

ICE frames (trickle)

FLASH_CALL action=ice callId=<uuid> from=<id> mid=<escaped-mid> index=<n> candidate=<escaped-candidate>
  • Trickled as local candidates appear. Receivers buffer candidates until the remote description is set (signaling-state check), then apply - the webrtc-kmp sample pattern.
  • iceServers is empty on both sides: Flash is LAN/hotspot-only, host candidates connect peer-to-peer on-link. No STUN/TURN.

Ordering and failure rules

  • Frames for one call are ordered by the single WS session (TCP); no reordering occurs.
  • If the WS session dies mid-call, the call fails immediately on both sides (media may survive briefly, but Flash treats signaling loss as call loss - deterministic and simple).
  • Unknown action values are ignored (forward compatibility).
  • A device supports at most one active call; a second incoming invite while busy is auto-declined with reason omitted (plain decline).

Call log rows: no wire frame

There is deliberately no call-log frame. When a call ends, each device already holds every field a log row needs - call id, peer, direction, video flag, end reason, duration - so each writes its own row into the chat thread locally. The row is stored as ordinary message text under a cmsg: marker, which is a storage convention inside Flash’s own database, not part of this protocol: a third-party consumer receives the same information as a FlashCallLogEntry callback and is free to persist it however it likes.

The cost is that a locally-derived row only knows what that device observed. missed is therefore defined as “an incoming call that never carried media” rather than read off the wire, because a callee that declines and a callee whose caller gave up both end the call as NORMAL - decline() reports NORMAL locally, and an inbound hangup while RINGING does too. The distinction exists on the caller’s side (an inbound decline ends as DECLINED, a dial timeout as NO_ANSWER) and is simply not recoverable on the callee’s.

Intended Full Protocol

The production transfer protocol will run over TLS and will include:

  • HELLO
  • HELLO_ACK
  • PAIR_REQUEST
  • PAIR_ACCEPT
  • TRANSFER_REQUEST
  • TRANSFER_ACCEPT
  • FILE_START
  • CHUNK
  • CHUNK_ACK
  • FILE_COMPLETE
  • TRANSFER_COMPLETE
  • ERROR
  • CANCEL
  • DISCONNECT
  • PAUSE
  • RESUME

The wire protocol must stay transport-independent so LAN and Wi-Fi Direct can use the same transfer engine.