Files
ChatApp/docs/superpowers/specs/2026-05-14-mobile-phase-3-voice-calls-design.md
T
byGalax 177a4c059f docs(mobile): phase 3 spec + plan — voice calls
9 tasks adding voice calls (1:1 + group) to the mobile app over the
same LiveKit + Supabase signaling stack the desktop uses:

  1. @livekit/react-native + @livekit/react-native-webrtc deps, mic
     permission strings, audio background mode, LiveKit Expo plugin.
  2. callSignal.ts subscribe/publish helpers over Supabase realtime.
  3. callContext.tsx state machine (idle/outgoing/incoming/connecting/
     connected/ended) + LiveKit room lifecycle + audio routing.
  4. IncomingCallModal at root with Annehmen/Ablehnen.
  5. Mount CallProvider + global IncomingCallModal in _layout.tsx.
  6. Register /call full-screen modal route.
  7. In-call screen with participants list + mute/speaker/hangup.
  8. Phone-icon header button on conversation detail + push to /call
     on connect.
  9. Workspace typecheck pass.

Out of scope: video, CallKit / ConnectionService native UI, VoIP push
wake-up, screen sharing, call history. Those are Phase 3.5 / 4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 06:04:20 +02:00

9.2 KiB

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:

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.