Paul HittandClaude Opus 5 4728928d98 Android: voice notes
The engine and the client already carried audio — `Attachment.AUDIO`, a
`duration` field, and a `sendAttachment` that takes both. What was missing was
entirely the phone: something to record with, something to play with, and two
controls. So this is app-layer only, and adds no dependency: `MediaRecorder`
and `MediaPlayer` are platform APIs, and a voice note is an ordinary encrypted
attachment the moment it is bytes.

AAC in an MP4 container, mono, 32 kbit/s. Speech rather than music: clearly
intelligible, and roughly a tenth the size of the stereo default nobody asked
for — which matters when every byte is uploaded encrypted and stored by
somebody paying for it.

⚠️ **Tap to start, tap to send — not hold-to-talk.** Hold-to-talk demonstrates
well and behaves badly on a real phone: the gesture competes with scrolling,
and letting go early silently discards what was said. Two deliberate taps
cannot be lost by accident.

⚠️ **`MediaRecorder.stop()` throws when it captured no frames** — a tap instead
of a hold, or a permission granted and then denied by policy. The exception is
a bare `RuntimeException` and it reads as "recording is broken" when the truth
is "there was nothing to record". Treated as an empty recording, and anything
under half a second is dropped rather than delivered: a note that plays silence
looks like a delivery bug, and the person who fumbled the button already knows
they did.

⚠️ **The microphone permission is requested when somebody asks to record**, not
at startup. A microphone prompt on first launch, before anything has been said,
is one people deny on reflex — and a denied permission is far harder to recover
from than one that was never asked for.

⚠️ Received notes are fetched and decrypted **on tap, never on render**. A
thread of twenty would otherwise download and decrypt all twenty on scroll:
bytes nobody asked for, and twenty decrypted files in the cache for a list
nobody has listened to. Playback stops on leaving the thread, because a
MediaPlayer that outlives its composable holds audio focus and a file handle.

Every temporary file — the recording, and the decrypted copy playback needs
because `MediaPlayer` cannot take a byte array — is deleted on both the success
and the failure path. Plaintext audio left in a cache directory is the exact
thing this product is not supposed to do.

`imageBytes` became `attachmentBytes`: photos and voice notes take the same
path, and a method called `imageBytes` that audio also calls is a name that has
quietly stopped being true.

Also retires buddy commands in the parity table — no longer a feature.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 14:37:31 -04:00
2026-08-25 14:37:31 -04:00
2026-08-25 14:37:31 -04:00

Flamenet Messenger — Android

Nothing here is an app yet. This is the engine, and it is deliberately first.

An Android client needs the protocol implemented a third time. That third implementation is the one that finds the places where the specification says less than the two existing engines quietly agreed on — but only if it is written against the spec and the published vectors rather than against a reading of our own Swift and JavaScript. So the engine comes first, it is a plain Kotlin/JVM library with no Android in it at all, and it is held to engine/test/vectors/protocol-v1.json — the same file the other two engines are checked against, read from that one location rather than copied here.

./gradlew :engine:test

No Android SDK, no emulator, no device. It runs anywhere a JVM does, which is also what lets it run in CI on Linux.

What the engine covers today

Area Spec State
Safety numbers §7 Done — fingerprints and conversation ordering match the published vectors
Key transparency verification §19 Done — RFC 6962 leaf/node hashing, unbalanced split, bottom-up audit paths
Byte helpers — Unsigned comparison, constant-time equality

What it does not cover

Listed rather than left to be discovered, because a table of green ticks with the hard parts quietly missing is worse than no table:

Area Spec State
X25519 / Ed25519 §3 Not started — needs a provider that works on both JVM and Android
X3DH key agreement §4 Not started
Double Ratchet §6 Not started
Sealed sender §12 Not started
ML-KEM-768 hybrid §11 Not started
ML-DSA-65 identities §20 Not started
Relay client §8 Not started
The app itself — Not started

Until those exist, Android is honestly marked "not started" on the website, and a conversation gets the protocol's guarantees only when both ends are iOS.

Why the vectors matter more than the code

Two implementations that agree may both be wrong in the same way, especially when one was written while reading the other. The audit-path ordering in §19 is the worked example: a top-down reading of a bottom-up path produces the correct root for every left-spine index, so it passes any test that only checks index 0. The vector file pins a right-descending index for that reason, and the Kotlin tests assert both that the published path verifies and that the same path reversed does not.

If this engine ever disagrees with the file, the interesting question is which of the three is wrong — not this one by default.

S
Description
Flamenet Messenger for Android — the Kotlin engine, held to the published protocol vectors. No app yet. MIT.
Readme MIT
22 MiB
Languages
Kotlin 99.3%
Shell 0.6%
Python 0.1%