Architecture overview
Viora has two deployable pieces and one protocol between them.
The two components
1. Signaling server (server/)
A ~240-line Rust program (axum 0.8 + tokio) that does room membership and
message relay and nothing else.
- No database. State is
RwLock<HashMap<String, HashMap<String, Peer>>>- room code → peer id →{ name, tx }. APeer’stxis anmpsc::UnboundedSenderinto that peer’s dedicated writer task. - No persistence. When the last peer leaves a room, the room entry is removed.
- One binary.
cargo build --releasewithlto = true,codegen-units = 1,strip = true. - One route.
any("/")- it inspects theUpgradeheader and either performs the WebSocket upgrade or returns404.
See Signaling server.
2. Android client (android/)
A single-activity Jetpack Compose app (minSdk 24, targetSdk 35, Kotlin 2.4,
Compose BOM 2025.09, Material 3 Expressive). The WebRTC engine is
io.github.webrtc-sdk:android:144.7559.14 - LiveKit’s build of libwebrtc M144,
chosen because it ships a complete H.265 RTP stack.
Key modules:
| Package | Responsibility |
|---|---|
com.example.videocall | MainActivity - the single Activity, signaling dispatch, call state, lifecycle, PiP |
com.example.videocall.webrtc | WebRtcClient and its helpers - the entire media engine |
com.example.videocall.ui | Compose screens and components |
com.example.videocall.data | AppSettings, CallHistory, AvatarArt - SharedPreferences-backed |
com.example.videocall.service | CallForegroundService - keeps the call alive when backgrounded |
cpp/ | rnnoise_jni.c + vendored RNNoise, built to libomnicall_audio.so |
See Android client and The WebRTC pipeline.
The protocol
JSON text frames over one WebSocket. The client always connects to the server
root (wss://host/); the same URL serves the upgrade.
| Direction | Types |
|---|---|
| Client → server | join, offer, answer, candidate, chat, screen-share, video, leave |
| Server → client | room-joined, user-joined, offer, answer, candidate, chat, screen-share, video, user-left |
Relay messages carry an optional targetPeerId; if present the server unicasts,
otherwise it broadcasts to every other peer in the room. The server stamps a
senderPeerId on everything it relays. Full schema in the
signaling protocol reference.
What flows where
| Data | Path |
|---|---|
| Room join, presence | Client ↔ server WebSocket |
| SDP offer / answer | Client → server → client (unicast via targetPeerId) |
| ICE candidates | Client → server → client |
| Audio & video RTP | Client ↔ client, direct, DTLS-SRTP |
| Text chat | Client → server → client (relayed, never stored) |
| Camera / mic on-off, avatar, screen-share state | Client → server → client, as video / screen-share frames |
Design constraints that shaped the code
- Exactly two peers.
MainActivitytracks a singleremotePeerIdand a wall of scalarremote*state. The multi-party roadmap replaces this with a participant list. - The media stack is expensive to build.
WebRtcClientbuilds thePeerConnectionFactory,AudioDeviceModule, and capturers once and reuses them; a mid-call mic-mode change only swaps the software layer and defers the hardware rebuild to the next call. - Android lifecycle is hostile to a camera. Backgrounding, PiP close, screen lock, and other apps all yank the camera. The capture recovery logic is built around that.
- The debug panel must cost nothing when closed. Stats polling only runs
while the panel is visible and the Activity is
STARTED.