Most messengers have a server in the middle. It is where your messages live, and it is also the single place where they can be read, subpoenaed, or quietly deleted when the company loses interest.
I wanted to see how far you can get without one. ShutChat is the result: an Android messenger where messages travel directly between devices. No server, no account, no sign-up screen.
This is a write-up of the parts that were interesting to build, and the parts that were genuinely hard.
The whole access model is one code
There is no address book, no user ID, no registration. A conversation starts with a 24-character invite code, and that code is the entire access model.
It is 15 bytes, rendered in Base32 with the Crockford alphabet, so 1/l/I and 0/O cannot be confused when you read it off someone else's screen:
| Bytes | Contents |
|---|---|
| 0 | version and chat kind |
| 1..2 | expiry day, big endian, 0xFFFF = never |
| 3..4 | CRC-16 |
| 5..14 | 80-bit rendezvous secret |
Two details I would keep in any rewrite.
Expiry is stored as a day, not a timestamp. Every device in the room enforces the same UTC deadline, whatever its timezone. A timestamp would drift.
The CRC is computed with its own bytes zeroed. Obvious once it works, maddening before: a checksum cannot cover itself. This was a real bug in an early version, and it shipped.
Three transports, picked at runtime
The interesting constraint: two phones on different mobile networks have no addressable path to each other. Something has to be reachable. So there are three paths, chosen at runtime.
- LAN — UDP multicast to find peers on the same network, TCP for the data.
- Nearby Connections — Bluetooth and Wi-Fi Direct, for devices that are close but not on a shared network.
- MQTT over TLS — for devices on different networks.
The room id is derived from the secret with HKDF and travels in the clear in every frame header. That looks wrong at first. It is deliberate: it lets a device forward a frame it cannot decrypt, which is what makes relaying possible. The message key and the file-transfer key are separate derivations, so a file chunk cannot be replayed as a message.
The relay is the only third party, and it sees neither the message nor who is talking. Frames are sealed, and the topic is derived from the invite secret, never from the room id. That last part matters: the room id travels in clear on every frame, so a topic derived from it would let a broker watcher correlate rooms.
The hard part: a message for someone who is offline
This is where a serverless design bites. With no server, a message can only arrive if something is awake to receive it.
The first version had a real bug. A message written for an offline peer went into the queue and was never retried. It sat in the chat with a clock icon, forever. Worse, "delivered" was inferred from "the bytes reached a socket", and the delivery receipt envelope was defined but never actually sent.
The fix was a retry queue with per-message backoff. It uses the message table as the queue, so a crash loses nothing the database had already saved. A message is retried until the recipient acknowledges it, and after 30 days, or when the room code expires, it is marked failed instead of pretending forever.
Two subtleties cost me real time.
The mesh deduplicates before the handler runs, so a re-sent message arrived and was silently dropped. The sender was retrying precisely because it never got the first receipt, so staying quiet kept it retrying for the whole window. A re-send now re-acknowledges.
Backoff must be per message, not a global counter: a message stays in the undelivered set after being handed to a transport, so a single counter would re-send every queued message on the same schedule.
A foreground service keeps the mesh listening when the app is closed, because with no push server, something must be awake to receive.
What it does
Direct and group chats from a single code; photos, audio and files transferred in chunks; replies, edits, deletes, reactions; read receipts and typing indicators; presence; search, pin, mute, archive, drafts; QR code sharing; automatic expiry and rotation of invite codes.
The honest limitations
- The relay is on by default, because a messenger that only works on one network is not much of one. It can be switched off from settings.
- Nearby Connections is Android-only. There is no iOS version, and a desktop version would be LAN plus relay, without the Bluetooth mesh.
- I could not test the network paths end to end in my build environment: no KVM, so no emulator. The retry logic is covered by unit tests, but real two-phone behaviour is the thing to try first.
It is Apache-2.0, and the APK is on the releases page: https://github.com/ciao22king/ShutChat
Top comments (0)