Offline, encrypted group chat + walkie-talkie for one WiFi / hotspot / LAN. No accounts. No servers. No internet. If you are on the network, you are in the room; if you are off it, you receive literally nothing — there is no server to subpoena, no cloud to leak, no packets to intercept.
License: MIT — see LICENSE. Copyright © 2026 OpenTuwa.com (https://opentuwa.com).
This document is the complete technical blueprint: what the system is, why it exists, every feature, how to use it, the full architecture, every runtime flow, the wire protocol byte-for-byte, and how it is built and tested.
LanMesh is a single-room group messenger with push-to-talk voice, scoped to a local IP network:
- Transport: WiFi infrastructure mode, phone hotspot, or any L2/L3 LAN. All sockets bind to the LAN only (UDP broadcast, LAN TCP). The app contains zero cloud URLs, zero accounts, zero analytics, zero push-notification services.
- Chat: everyone connected sees every message (group chat only, no DMs).
- Voice: half-duplex walkie-talkie — hold TALK to speak, everyone hears.
- Identity: per-device Ed25519/X25519 keypair generated on first launch + a user-chosen cover name (display only, changeable anytime).
- Crypto: AES-256-GCM for chat and voice. Room keys are derived on-device; ciphertext is all that ever traverses the network.
- Reliability: a foreground service keeps the listener alive with the screen off; every phone stores every message and re-serves missed ones to peers that were dozing (store-and-forward anti-entropy sync). Nothing is lost to idle gaps.
Non-goals (deliberate): no internet messaging, no multi-room, no DMs, no message editing/deletion sync, no always-on full-duplex calling, no iOS yet.
Protests, classrooms, dorms, offices, travel groups, game nights, emergency coordination: people in one place need to talk now, privately, without:
- exchanging phone numbers or creating accounts,
- depending on cell coverage / venue WiFi internet (captive portals, congestion, shutdowns — the radio works even when the uplink doesn't),
- routing sensitive chatter through a third-party server that logs, scans, throttles, or gets breached.
Existing apps fail at least one of these: they need sign-up, need internet, or store plaintext/metadata server-side.
A voice stream at 32 kbps ≈ 200 bytes × 50 packets/s ≈ 16 KB/s. The PCM fallback (no compression at all) is ~96 KB/s. A game download is 10–50 MB/s — three orders of magnitude more. If a router can move a game download, voice is a rounding error; fibre uplink speed is simply irrelevant because these packets never go near the ISP: phone → local radio → phone, at ~2–10 ms latency (better than any internet call). No "VoIP server", no SIP/PBX, no WebRTC (which exists for NAT traversal across the internet and is pointless on a LAN) is needed. This is the entire technical bet of the project, and it is just arithmetic.
Range (~10 m, pairing friction, piconet limits) vs WiFi (whole building, no pairing, every phone already joined). WiFi wins on every axis that matters here.
Rule: same network = same room. Anyone on the LAN with the app reads the room; anyone off the LAN gets nothing to decrypt. There is no per-user verification in v1 (no QR, no passwords) — a conscious product decision, not a missing feature. The QR/SAS verification code exists in the core as the upgrade path for closed rooms.
group_id = hex( blake3(b"lanmesh-default-room-v1")[0..16] ) # store.rs:43
chat_key = blake3("lanmesh-open-chat-v1:<group_id>") # group_crypto.rs:55
talk_key = blake3("lanmesh-open-talk-v1:<group_id>") # group_crypto.rs:56
peer_id = hex( blake3(ed25519_pubkey)[0..8] ) # identity.rs:54
- Every phone independently derives identical
chat_key/talk_key, so the room converges with zero setup and zero key-distribution packets. - Long-term identity: Ed25519 signing key (stored
identity_ed25519.key, mode 0600) + X25519 DH key derived from the Ed25519 seed via HKDF-SHA256 ("lanmesh-x25519-v1"), so only one secret is stored. LanMesh::newloads the group snapshot and injectstalk_keyinto the PTT engine (lib.rs) — without this the engine keeps its all-zero key and drops all voice by design ("not joined yet",ptt.rs:163).
{ msg_id: uuid-v4, sender_id: peer_id, sender_name: cover (display only),
lamport: u64 clock, ts_ms, nonce_ct_b64, sig_b64 }
nonce_ct_b64 = base64( epoch_be32 || nonce12 || AES-256-GCM(chat_key, plaintext) )
sig_b64 = Ed25519( msg_id | lamport | nonce_ct_b64 )
- Random 12-byte nonce per message; epoch selects current vs history key
(8-key window,
rotate_all, kept for future rotations). - Merge rule (
GossipEngine::merge): dedupe bymsg_id; verify Ed25519 if the sender's key is known, accept otherwise (open room); forged known-sender frames are dropped. Returns "was NEW" → drives notify + re-gossip.
[0] version = 0x01
[1] type: 0x10 FLOOR_REQ / 0x11 FLOOR_GRANT / 0x12 FLOOR_RELEASE / 0x20 VOICE
[2..10] sender peer_id (8 raw bytes)
[10..14] seq u32 BE (per-talk counter)
[14..18] talk_id u32 BE (bumps per button press; receiver resets on change)
[18..] AES-256-GCM(talk_key, nonce = sender[8] || seq[4], voice_bytes)
- Nonce is derived, not transmitted (saves 12 B). No-AAD encryption; decrypt accepts AAD-bound or plain (interoperability-tolerant).
- Own-echo suppression by
peer_id; all-zero key drops (unjoined). - Sender:
AudioRecord,VOICE_COMMUNICATIONsource (falls back toMIC), 48 kHz mono s16, exact 960-sample reads; RMS voice gate at 150 (room silence never hits the radio); playback muted while transmitting. - Receiver:
AudioTrack,USAGE_VOICE_COMMUNICATION+CONTENT_TYPE_SPEECH, low-latency mode, loudspeaker forced while Hear is on (prior state restored after); frames dropped (not queued) while talking or muted.
Chat
- Open-room group chat: everyone on the LAN in one room, chronological rows,
own messages right, others left with cover name +
HH:mmtime. - Cover name: onboarding (2–24 chars), ⋮ → Change cover name anytime; beacons and outgoing frames update immediately.
- Empty state: "Nothing here yet…" placeholder when history is empty.
- Panic wipe: ⋮ → Erase my messages deletes local history instantly (other phones keep theirs).
Voice (walkie-talkie)
- HOLD TO TALK press-and-hold (orange glow + TALKING… release to stop).
- Hear ON/OFF toggle (grays out when off); loudspeaker forced while on.
- Half-duplex: playback muted while transmitting — echo is impossible.
- Voice-activity gate (RMS < 150 skips silence); mic fallbacks
(
VOICE_COMMUNICATION→MIC); voice can never crash chat (guarded init). - Dual send: multicast + per-peer unicast (hotspot-proof); live talker id in the status line; tx/rx/drop counters in ⋮ → About.
Reliability & alerts
- Foreground service +
WifiLock+MulticastLock; ongoing notification (LanMesh • cover / N people on this WiFi). - High-priority message notifications (single or 5-line inbox summary,
8 rotating ids, tap opens chat, no repeats via
last_notified_ts). - Catch-up sync: anything missed while dozing arrives and notifies on wake.
- Auto-restart after reboot (only if the user joined before).
Guidance & consent UX
- First-run coach (Hear+volume / one-talker / same-WiFi rules; ⋮ → How to
use reopens; shown once via the
coach_v1flag). - Stubborn-denial notices on every attempt: mic blocked → Settings; notifications off → Turn on (message still sends — warn, never block).
- Notification permission asked at launch; mic asked on first TALK press; battery-unrestricted tip lives in ⋮ → Keep alive in background.
Install
- Uninstall any older LanMesh (old installs predate permission fixes and crash-loop on start — always reproduce with a clean install).
adb install -r android/app/build/outputs/apk/debug/app-debug.apk(≈10 MB, signed, installable). Release (app-release-unsigned.apk) needs a keystore first.
Join & chat
- All phones on the same WiFi/hotspot. 2. Open → cover name → Start chatting through the coach. 3. Type → Send. Subtitle shows the peer count; the status line shows listener + voice state.
Talk
- Hear ON + volume UP. 2. Hold TALK, speak, release. 3. One talker at a time — two pressing at once hear nothing; wait for silence.
Permissions (chat never depends on mic)
- Notifications (Android 13+): reply alerts; denied → notice on every send.
- Microphone: asked on first TALK; denied → notice on every press.
- Battery unrestricted (⋮ → Keep alive…): recommended for instant screen-off alerts; without it Doze delays delivery until wake (sync heals).
Troubleshooting
| Symptom | Cause → fix |
|---|---|
| Crash right after choosing name (old builds) | stale APK → uninstall + install current |
| Crash at service start on Android 14+ | mic FGS type without grant — fixed: service starts chat-only, upgrades on TALK |
| Nobody appears | different SSID / AP-client isolation → same network |
| Can't hear voice | Hear OFF or volume down → toggle + volume up |
| TALK silent for others | mic denied (notice shows) or genuine silence gated by VAD |
| No alerts with screen off | battery restricted / notifications off → menu + notice shortcuts |
Layers (strict — UI never touches sockets or crypto directly):
MainActivity / ChatAdapter / BootReceiver (UI + permission UX)
MeshService (foreground: node owner, beacons, TCP gossip, sync, notify, locks)
PttAudio (mic/track/UDP :7899, VAD, speaker routing) | MeshState (observable)
LanMesh.kt ── JNI (com/lanmesh/app/LanMesh, JNI_OnLoad) ──▶ Rust core
Rust core (identity, crypto, gossip store, PTT engine) ──▶ LAN ONLY
Rust core (core/src, shared by Android/JNI, the C ABI and desktop):
lib.rs—LanMeshhandle (Arc<Inner{identity, group RwLock, store, gossip, ptt}>); injectstalk_keyinto PTT at init.ffi.rs—node_*logic shared by JNI + C ABI (incl.chat_export).jni_bridge.rs— the 20 natives (table below).identity.rs— Ed25519 + HKDF-derived X25519,peer_id, b64 helpers.group_crypto.rs— deterministic room keys, AES-GCM chat, 8-key history, SAS/QR helpers for future closed rooms.gossip.rs— compose / merge / diff / hello / decrypt (the verified anti-entropy core).transport.rs— ports,Beacon, UDP broadcast loop + listener, optional mDNS feature.store.rs— file KV + per-message JSON, lamport clock, vclock, wipe.ptt.rs— voice engine: frame format, floor signals, jitter-ready decrypt, mic-frame sender with VAD.codec.rs—VoiceCodectrait; PCM passthrough default, Opus behind--features opus.portal.rs—:8080zero-install web portal (core-complete, no UI yet).file_share.rs— chunked resumable E2EE file design (UI pending).platform.rs—PlatformCallbacksOS boundary.tests/— on-device crypto tests (see §9).
Android shell (android/app/src/main/…/com/lanmesh/app/):
MeshService.kt— service + chat networking + notify + locks + voice lifecycle + FGS mic upgrade.PttAudio.kt— voice client.MainActivity.kt— onboarding, list, talk/hear, coach, snackbars, menus.MeshState.kt— process-wide observable.ChatAdapter.kt— diffed list, scrolls only on growth.BootReceiver.kt— reboot restart.LanMesh.kt— JNI facade (signatures must match the table below).- Manifest: 12 permissions; service
connectedDevice|microphone; boot rx.
| Permission | If denied / off |
|---|---|
POST_NOTIFICATIONS (13+) |
chat works; notice on every send; no alerts |
RECORD_AUDIO |
chat works; notice on every TALK; voice out dead, hear fine |
Normal (INTERNET, WIFI_*, FOREGROUND_SERVICE*, WAKE_LOCK, …) |
granted at install |
| Battery unrestricted (a setting) | delayed background delivery; sync heals on wake |
LanMesh.kt (object LanMesh, System.loadLibrary("lanmesh_core")) must
match core/src/jni_bridge.rs, which registers natives on
com/lanmesh/app/LanMesh in JNI_OnLoad (no name mangling):
Kotlin external fun |
Signature | Rust impl |
|---|---|---|
lanmesh_new(cover, dir) |
(String,String):Long |
create node → handle, -1 on error |
lanmesh_cover_name(h) |
(Long):String |
stored cover |
lanmesh_set_cover(h, name) |
(Long,String):Int |
1 = ok |
lanmesh_panic_wipe(h) |
(Long):Int |
delete msgs + snapshot |
lanmesh_peer_id(h) |
(Long):String |
peer_id hex |
lanmesh_qr_payload(h) |
(Long):String |
lanmesh://v1/… (unused by UI) |
lanmesh_chat_send(h, cover, text) |
(Long,String,String):String |
ChatMessage JSON, "" on failure |
lanmesh_chat_recv(h, json) |
(Long,String):Int |
1 = NEW |
lanmesh_chat_list(h, limit) |
(Long,Int):String |
[{id,from,name,ts,text}], text nullable |
lanmesh_chat_export(h, limit) |
(Long,Int):String |
raw frames for sync |
lanmesh_beacon_json(h, cover, port) |
(Long,String,Int):String |
discovery beacon |
lanmesh_ptt_start(h) / lanmesh_ptt_stop(h) |
(Long):Int / (Long):Unit |
begin/end talk |
lanmesh_ptt_encrypt(h, pcm1920) |
(Long,ByteArray):ByteArray? |
voice datagram or null |
lanmesh_ptt_decrypt(h, dgram) |
(Long,ByteArray):ByteArray? |
1920 B PCM or null |
lanmesh_file_wrap/unwrap, lanmesh_sas_for_qr, lanmesh_portal_start, lanmesh_free_str |
various | core capabilities, no UI yet |
Rust bool crosses as Int (0/1). blocking_read/write on the group lock
is safe because JNI calls arrive on plain (non-async) threads.
identity_ed25519.key long-term signing secret (0600 on unix)
cover_name.kv current cover name
group_id.kv room id (deterministic)
group_snapshot.kv GroupSession JSON (keys, epoch, peer cache)
msgs/<msg_id>.json one ChatMessage per file
lamport.kv clock high-water mark
Android SharedPreferences (lanmesh): cover, hear_enabled (default
true), last_notified_ts, coach_v1 (coach acknowledged).
UI thread: activity + MeshState listeners. ioPool (single): send, cover,
wipe, talk/hear toggles, FGS mic upgrade. netPool (cached): one reader per
TCP socket + dial tasks. Dedicated daemons: beacon-tx, beacon-rx,
tcp-server, sweep, ptt-rx, ptt-tx. MeshState (messages, peers,
status, pttLine, myPeerId) is the only cross-thread state, emitted to
main via Handler.
MainActivity.onCreate → cover? ──no──▶ onboarding (name, 2–24 chars)
──yes──▶ startForegroundService(MeshService)
MeshService.onCreate:
prefs cover → lanmesh_new(cover, filesDir/lanmesh) # identity+store+group
→ myPeerId, own group_id (parsed from own beacon)
→ WifiLock FULL_HIGH_PERF + MulticastLock (both non-refcounted)
→ notification channels (lanmesh_svc LOW, lanmesh_msg HIGH)
→ startForeground(CONNECTED_DEVICE only — mic type added later, §5.14)
→ PttAudio(handle, hear=prefs) guarded by runCatching (voice can never kill chat)
→ beacon-tx / beacon-rx / tcp-server / sweep threads
→ refreshChat() → MeshState → UI
BOOT_COMPLETED → BootReceiver → service restarts only if a cover was saved.
- TX every 3 s:
lanmesh_beacon_json→255.255.255.255:7879. - RX: parse; drop own
peer_id; drop foreigngroup_id; recordPeerInfo(peerId, cover, ip, now)→ensureConnected(ip, tcp_port). - Dial rule: one socket per peer (
putIfAbsent; loser closes). Sweeper prunes peers unseen for 15 s and dead sockets, re-syncs every 5 s, refreshes the ongoing notification (N people on this WiFi).
lanmesh_chat_send → ChatMessage JSON (stored by Rust)
→ fanout {"t":"msg"} to all peers → refreshChat() (own row, no notify)
chat_recv each frame → collect NEW ids
→ forward each raw frame to all OTHER peers (exactly once)
→ refreshChat() → notifyFor(newIds from others with ts > last_notified_ts)
Both sides of every socket exchange sync_req{from, ids[]} immediately;
onSyncReq replies with owned frames the peer lacks, chunked at 300
(sync_res{msgs[]}). Sweeper re-requests every 5 s, so anything missed while
Doze-frozen arrives on wake and notifies then. Verified: message sent while a
peer was disconnected was delivered on reconnect with full history.
ACTION_DOWN → (mic permission? else request + notice, abort)
→ upgrade FGS to +MICROPHONE (main thread, ≤3 s) → ptt_start → startRecording
→ mute track → loop: read 960 samples → RMS ≥ 150?
yes → ptt_encrypt → multicast 239.19.84.12:7899 + unicast to each peer ip:7899
no → skip frame
ACTION_UP → ptt_stop → stop mic → resume track → log micFrames/voicedFrames
MulticastSocket(:7899, joined 239.19.84.12) receives multicast AND unicast
→ if !hear or talking: drop → ptt_decrypt → valid 1920 B? play : drop++
→ first frame logs; talker id (header bytes 2..10) shown in status on change
lanmesh_svc(LOW, ongoing, silent):LanMesh • <cover>/N people on this WiFi. Required by the foreground service.lanmesh_msg(HIGH, sound+vibration, auto-cancel): single (name: text) or InboxStyle (N new messages, last 5 lines), 8 rotating ids, opens chat on tap.last_notified_tsprevents repeats.
START_STICKY + persisted store: process death loses nothing (history is on
disk, peers re-sync on rediscovery). panic_wipe deletes local history
instantly; others keep theirs.
| Use | Socket | Value |
|---|---|---|
| Chat gossip + sync | TCP | 7878 |
| Discovery beacons | UDP broadcast | 7879 → 255.255.255.255 |
| Voice | UDP multicast + unicast | 7899, group 239.19.84.12 |
| Zero-install portal (core only) | TCP | 8080 |
Beacon JSON: {peer_id, cover_name, sign_pk_b64, dh_pk_b64, tcp_port, group_id, epoch}. TCP frames are JSON + \n:
sync_req{from, ids[]} → sync_res{msgs[]}, plus live msg{msg}.
Chat ciphertext: epoch_be32 || nonce12 || AES-GCM (base64). Voice: §3
header + AES-GCM (nonce derived, never sent).
Toolchain pins: NDK 28.2.13676358 (API 26 = minSdk 26), AGP 8.13.2,
Gradle 8.13, Kotlin 2.0.21, JDK 17, compile/targetSdk 36.
NDK linker paths live in .cargo/config.toml (edit the 4 absolute paths if
your SDK is elsewhere); first launch needs local.properties with sdk.dir
(Android Studio generates it).
# Rust core check + Android libs (release)
cargo check -p lanmesh-core
cargo build --release --target aarch64-linux-android \
--target armv7-linux-androideabi --target x86_64-linux-android
# copy each target/<triple>/release/liblanmesh_core.so into
# android/app/src/main/jniLibs/<arm64-v8a|armeabi-v7a|x86_64>/
cd android && gradlew assembleDebug assembleReleaseCurrent artifacts: app-debug.apk ≈ 10.1 MB (signed, installable),
app-release-unsigned.apk ≈ 9.1 MB (needs a keystore).
Verification matrix (all executed, all passing):
cargo check+cargo check --tests— core + tests type-check.- Rust unit tests run on-device (
ptt_roundtrip, 4/4): voice roundtrip, wrong-key rejection, chat encrypt/rotate/history, QR SAS — executed via pushedx86_64test binary withTMPDIR=/data/local/tmp. ci/lanmesh_peer_test.py(needsblake3,pycryptodome;adb forward tcp:7878 tcp:7878; setANDROID_SERIALwith several devices): handshake → live app→peer fan-out decrypted with independently derived keys → peer→app merge/persist/notify → disconnect, send, reconnect → missed message + history re-synced. ALL PASS.- Voice RX on-device:
desktop/src/bin/voice_probe.rs(own node id, sine frames) pushed to emulator → app logsfirst voice frame played (1920B), talker id appears in status. - Voice TX mic: 3 s TALK hold →
talk start…talk stop, micFrames=137 voicedFrames=0(~49 frames/s real reads; 0 voiced = emulator digital silence correctly gated). - Crash regression: mic-revoked cold start (the Android-14 FGS/mic kill) — service starts clean; grant + TALK upgrades FGS types without error.
- Screenshots verified: onboarding-less join, coach sheet, chat with both sides, denial Snackbar, empty state path.
Emulator limits (stated, not hidden): emulator mic is silence (TX content
unprovable there — real phones prove it); adb forward cannot do UDP, hence
the on-device probe; emulator WiFi is simulated, so phone↔phone discovery is
confirmed by design + code path, to be field-confirmed on hardware.
README.md · LICENSE (MIT, © 2026 OpenTuwa.com) · .gitignore
Cargo.toml (workspace) · Cargo.lock · .cargo/config.toml (NDK)
core/src/ lib.rs ffi.rs jni_bridge.rs identity.rs group_crypto.rs
gossip.rs transport.rs store.rs ptt.rs codec.rs portal.rs
file_share.rs platform.rs (+ tests/ptt_roundtrip.rs)
android/ settings.gradle.kts build.gradle.kts gradle.properties
local.properties (local only, git-ignored) gradle/wrapper/
app/build.gradle.kts app/proguard-rules.pro
app/src/main/AndroidManifest.xml
app/src/main/java/com/lanmesh/app/
LanMesh.kt MeshState.kt MeshService.kt PttAudio.kt
MainActivity.kt ChatAdapter.kt BootReceiver.kt
app/src/main/res/ (layouts, menu, strings, themes, icons)
app/src/main/jniLibs/<abi>/liblanmesh_core.so (prebuilt, tracked)
desktop/src/ main.rs (stub) bin/voice_probe.rs (on-device test sender)
ci/ lanmesh_peer_test.py (protocol self-test, ANDROID_SERIAL-aware)
- Talk collisions. Two simultaneous talkers garble each other; the floor signals exist in the core but the app shows no "channel busy" yet — the coach sheet teaches turn-taking instead. First follow-up if it annoys.
- Voice codec. PCM fallback today (~96 KB/s, inaudible cost on LAN);
Opus (
--features opus, needs libopus in the NDK) drops it to ~8–32 kbps. - Closed rooms. QR verify + SAS + pairwise key distribution exist in the
core (
sas_for,parse_qr,VerifiedPeer) but no UI calls them; the room is open by product decision. Rotation functions exist but are intentionally not auto-triggered (rotation would fork the deterministic open-room key). - Platforms. Android only. The Rust core already exposes a C ABI
(
lanmesh_*inffi.rs) for a future iOS/desktop shell. - Release signing.
app-release-unsigned.apkneeds a keystore before sideload/Play distribution (and keystores must never be committed —*.jks/*.keystoreare git-ignored). - License. MIT — see LICENSE. Copyright © 2026 OpenTuwa.com. ...[truncated 14718 chars]