# Mobile Phase 3 — Voice Calls **Date:** 2026-05-14 **Scope:** `apps/mobile` **Roadmap context:** `2026-05-13-mobile-deployment-roadmap.md` --- ## Problem Phases 1+2 cover messaging. The desktop ships voice/video calls via LiveKit; without parity on mobile the app feels incomplete. Phase 3 brings audio calls (1:1 DM + groups) into the mobile client over the same LiveKit + Supabase-realtime signaling stack the desktop already uses. ## Goal After Phase 3, a Netralax mobile user can: 1. Initiate a voice call from a conversation header — "Anrufen" button next to the title. 2. Receive an incoming-call modal when the peer / a group member starts a call, with **Annehmen** / **Ablehnen** buttons. 3. Join the LiveKit room on accept, hear other participants, and be heard. 4. Toggle mute + lautsprecher (speaker/earpiece), and end the call with the red hangup button. 5. See a participant list in the in-call screen so they know who's on the line. ## Non-goals - **Video calls** — voice-only MVP. Camera toggle UI is reserved for Phase 3.5 because it requires extra permission strings, camera previews, and view tracks that double the surface. - **CallKit / ConnectionService native UI** — the OS-level "incoming call" screen requires `react-native-callkeep` + native config + APNS VoIP / FCM data-only payloads. Phase 3 ships an in-app modal; native CallKit is Phase 3.5. - **Push-based wakeup** — if the app is killed, the user doesn't get notified of an incoming call. Realtime subscription only works while the app is open. - **Screen sharing** — explicitly out of scope on mobile. - **Call recording, captions, soundboard** — desktop-only conveniences, post-Phase-4. - **Call-stream end-to-end encryption beyond what the LiveKit token mint already enforces** — server-side RLS + short-lived JWT handle authorisation. ## Design ### 1. New dependencies - `@livekit/react-native` — the LiveKit RN SDK; provides `Room`, `LocalParticipant`, `RemoteParticipant`, `AudioSession`. - `@livekit/react-native-webrtc` — peer dep that ships the actual WebRTC stack as native modules. Both LiveKit packages require native code, so the workspace already runs through EAS Dev Client (Phase 0 set up the dev profile). The `@livekit/react-native` Expo plugin needs registration in `app.json`. ### 2. Permissions `app.json` gains `NSMicrophoneUsageDescription` (via plugin config) plus the Android `RECORD_AUDIO` permission. No camera string in Phase 3 since we don't capture video. Also add iOS `audio` background mode so the LiveKit room stays alive when the user backgrounds the app mid-call. ### 3. Call signaling `apps/mobile/lib/callSignal.ts` — Supabase realtime channel subscription: - `subscribeCallSignals(client, userId, onSignal)` subscribes to `signalTopic(userId)`, parses `CallSignal` payloads, returns an unsubscribe function. - `sendCallSignal(client, toUserId, payload)` broadcasts to the peer's channel. Mirrors the desktop pattern but in a much smaller surface — the desktop's CallContext is ~3000 lines because of features we're not shipping (active speaker, captions, screen share, soundboard, stats overlay, etc.). The mobile Phase 3 equivalent is ~400 lines. ### 4. Call state machine `apps/mobile/lib/callContext.tsx` — React context holding: ```ts type CallState = | { kind: 'idle' } | { kind: 'outgoing'; callId: string; conversationId: string; peers: string[] } | { kind: 'incoming'; callId: string; conversationId: string; fromUserId: string } | { kind: 'connecting'; callId: string; conversationId: string } | { kind: 'connected'; callId: string; conversationId: string; room: Room; muted: boolean; speakerOn: boolean } | { kind: 'ended'; reason: 'normal' | 'rejected' | 'cancelled' | 'error' }; ``` Exposed actions: - `startCall(conversationId)` — sends `invite` signals to every other member, transitions to `outgoing`, then joins the LiveKit room immediately so the user is "in" once the first peer accepts. - `acceptIncoming()` — fetches LiveKit token, connects, sends `accept`, transitions to `connected`. - `rejectIncoming()` — sends `reject`, transitions to `idle`. - `cancelOutgoing()` — sends `cancel` to each invited peer, transitions to `idle`. - `endCall()` — disconnects from LiveKit, sends `end` to participants, transitions to `idle`. - `toggleMute()` — un/publishes the mic track. - `toggleSpeaker()` — flips between speaker and earpiece via LiveKit's `AudioSession`. The provider subscribes to call signals via §3 and surfaces incoming invites to whichever screen is currently mounted. ### 5. Audio routing LiveKit RN's `AudioSession.startAudioSession()` + `selectAudioOutput()` handle the platform plumbing: - iOS: AVAudioSession category `playAndRecord`, with the speaker on/off based on the toggle. - Android: AudioManager speakerphone flag. On `acceptIncoming()` and `startCall()` we call `AudioSession.startAudioSession()`; on `endCall()` we call `AudioSession.stopAudioSession()`. ### 6. Call screen UI `apps/mobile/app/(app)/call.tsx` — full-screen route shown when `state.kind === 'connected'`. Layout: - Top: conversation name + call duration ("00:42"). - Middle: vertical list of participants — own + remotes — with avatars + names. "verbindet…" until they join, "spricht" emerald dot when active speaker. - Bottom: 3-button toolbar — Mute, Speaker, Hangup. Hangup is a red circle, the others pill-style. Routes are gated: - If `state.kind === 'connected'` and we're not on `/call` → router pushes `/call`. - If `state.kind === 'idle'` and we are on `/call` → router pops. - If `state.kind === 'incoming'` → `IncomingCallModal` (§7) renders over the current screen. ### 7. Incoming-call modal `apps/mobile/components/IncomingCallModal.tsx` — full-screen modal mounted at the root layout so it surfaces over any screen. - Visible when `state.kind === 'incoming'`. - Shows caller name (resolved via the conversation members), big avatar, ringing animation. - Two buttons: **Annehmen** (green) and **Ablehnen** (red). - Tap Annehmen → `acceptIncoming()` → routes to `/call` on success. ### 8. Conversation entry points Add a phone-icon button in the conversation-detail header (`[id].tsx` Stack.Screen `headerRight`). On tap → `startCall(id)`. The icon is a simple PNG-character `📞` for the MVP — a vector icon set is a polish-pass. ### 9. Edge cases handled - **App backgrounding mid-call:** LiveKit stays connected; audio continues. iOS `audio` background mode is required in `app.json` — added. - **Network loss:** LiveKit auto-reconnects. We surface "Verbindung verloren — Wiederverbinden…" in the call screen. - **Caller hangs up before answer:** the modal listens for `cancel` and auto-dismisses. - **Both sides hang up simultaneously:** dual `end` signals are idempotent. - **Joining a call that's already started in a group:** every member who got an invite can join the same LiveKit room. ## File structure (deltas) | File | Status | Responsibility | |---|---|---| | `apps/mobile/package.json` | MODIFIED | Add `@livekit/react-native`, `@livekit/react-native-webrtc` | | `apps/mobile/app.json` | MODIFIED | Microphone permission, iOS `audio` background mode, LiveKit plugin | | `apps/mobile/lib/callSignal.ts` | NEW | Realtime subscribe/publish helpers | | `apps/mobile/lib/callContext.tsx` | NEW | Call state machine, signal dispatch, LiveKit room lifecycle | | `apps/mobile/components/IncomingCallModal.tsx` | NEW | Annehmen / Ablehnen modal | | `apps/mobile/app/_layout.tsx` | MODIFIED | Mount `CallProvider` under `AuthProvider`; render `IncomingCallModal` at root | | `apps/mobile/app/(app)/call.tsx` | NEW | In-call full-screen UI | | `apps/mobile/app/(app)/_layout.tsx` | MODIFIED | Register the `call` route | | `apps/mobile/app/(app)/conversations/[id].tsx` | MODIFIED | Phone-icon header button → `startCall(id)` | ## Risks - **`@livekit/react-native-webrtc` + New Architecture.** Supported but a moving target. If a runtime crash surfaces under `newArchEnabled: true`, drop to `false` for the next dev build. - **Audio session conflicts.** Other apps holding the audio session may interrupt. LiveKit's `AudioSession` API requests focus; we accept brief interruptions. - **Realtime channel dies on app sleep.** Backgrounded app → iOS budgets out the JS bridge after ~30s. Phase 3 accepts this; Phase 3.5 with CallKit + VoIP push fixes it. - **Token endpoint dependency.** The `mint-livekit-token` edge function must accept the mobile session JWT — same as desktop, server-side RLS already in place. ## Verification 1. `pnpm --filter @chat-app/mobile typecheck` exits 0. 2. With the mobile dev client built + desktop signed into the same account, on real hardware: - Mobile starts a call → desktop sees incoming-call panel. - Desktop accepts → audio flows both ways. - Mute / speaker toggles work. - Hangup ends the call cleanly on both sides. 3. Reverse direction. 4. Reject + cancel flows. 5. Group: any one member starts → all others get the modal; multiple can join. ## Out of scope (Phase 3.5 / 4) - Video calls (camera + view tracks). - Native CallKit / ConnectionService. - VoIP push to wake the app from killed/background. - Call history / missed-call UI on the chat list. - Screen sharing. - Active-speaker reorder, captions, in-call soundboard. - Bluetooth headset routing menu beyond the simple toggle.