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>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,167 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user