docs(call): add ARCHITECTURE.md summarizing the WebRTC call pipeline

Maps the full call flow — NIP-AC event kinds, module layout across
quartz/commons/amethyst, the CallManager state machine, incoming and
outgoing signaling pipelines, group-call invited-vs-watchdog timer
semantics, multi-device "answered elsewhere" handling, and the
load-bearing invariants (stateMutex discipline, self-pubkey guards,
publish-outside-the-lock pattern).

Lives next to CallManager.kt so the next contributor finds it without
an hour of grepping.

https://claude.ai/code/session_01QVUnhr79hYqQuXFiEb8puk
This commit is contained in:
Claude
2026-04-15 19:49:50 +00:00
parent 822718e687
commit 421b31f84f
@@ -0,0 +1,273 @@
# WebRTC Call Architecture
A map of how P2P and group voice/video calls work in Amethyst, aimed at future
contributors (human or AI) who need to change call code without spending an
hour grepping.
## 1. Protocol (NIP-AC)
Calls are signaled over Nostr using the NIP-AC event family. Every signaling
event is sealed inside a NIP-59 gift wrap of kind `21059`
(`EphemeralGiftWrapEvent`) addressed to a single recipient per wrap.
| Kind | Event | Purpose |
|-------|--------------------------|------------------------------------------------------------|
| 25050 | `CallOfferEvent` | Offer + SDP, starts ringing on the callee |
| 25051 | `CallAnswerEvent` | Answer + SDP, callee accepts |
| 25052 | `CallIceCandidateEvent` | Trickle ICE candidate for a specific peer |
| 25053 | `CallHangupEvent` | End the call / drop a peer |
| 25054 | `CallRejectEvent` | Decline ringing (only valid while ringing) |
| 25055 | `CallRenegotiateEvent` | Mid-call SDP renegotiation (e.g. voice → video) |
Specification: `quartz/.../nipACWebRtcCalls/NIP-AC.md`.
Gift-wrap recipient is set via `EphemeralGiftWrapEvent.create(event, recipientPubKey)`.
The wrap's outer `p` tag is the recipient's pubkey; relay subscriptions
filter gift wraps by `p == myPubKey`, so **a device only receives signaling
addressed explicitly to its account**.
## 2. Module layout
```
quartz/commonMain/.../nipACWebRtcCalls/
├── events/ # Event classes (KIND constants, builders, accessors)
├── tags/ # CallType, CallIdTag, etc.
└── WebRtcCallFactory.kt # Sign + gift-wrap helpers (P2P + group variants)
commons/commonMain/.../call/ # ← this package
├── CallState.kt # Sealed state + EndReason enum
├── CallManager.kt # State machine, signaling dispatch, timeouts
├── PeerSession.kt # Platform-neutral per-peer signaling interface
└── PeerSessionManager.kt # Map<pubKey, PeerSession> + ICE buffering
amethyst/.../service/call/ # Android platform layer
├── CallController.kt # Wires CallManager ↔ WebRTC, audio routing
├── WebRtcCallSession.kt # org.webrtc.PeerConnection wrapper
├── WebRtcPeerSessionAdapter.kt # Implements PeerSession over org.webrtc
├── CallAudioManager.kt # Speakerphone / Bluetooth SCO routing
├── CallMediaManager.kt # Camera / microphone tracks
├── CallForegroundService.kt # Android foreground service while in a call
└── CallNotificationReceiver.kt # Accept / reject from notification actions
amethyst/.../ui/call/ # Android call UI
└── CallScreen.kt # IncomingCallUI, InCallUI, etc.
```
`quartz` is pure Kotlin/Multiplatform protocol code with **zero** WebRTC or
Android dependencies. `commons/.../call/` is also platform-neutral — it holds
the state machine and a `PeerSession` abstraction. All `org.webrtc` code lives
in `amethyst/service/call/`.
## 3. State machine
```
┌──────────────────────────────────────────────┐
│ │
▼ │
Idle ─────initiateCall()─────▶ Offering │
│ │ │
│ onCallAnswered │
│ │ │
│ ▼ │
onIncomingCallEvent Connecting │
│ │ │
▼ onPeerConnected │
IncomingCall ──acceptCall()─────▶ │ │
│ ▼ │
rejectCall() Connected │
│ │ │
│ hangup() / │
│ PEER_HANGUP │
│ │ │
└───────────────▶ Ended ◀─────┘ │
│ │
└─────after 2s────────────┘
```
States (`CallState.kt`):
- **`Idle`** — no call.
- **`Offering`** — we sent the offer, waiting for answers. Carries
`peerPubKeys` (pending callees).
- **`IncomingCall`** — we received an offer and are ringing. Carries the
caller, `groupMembers`, and the SDP offer.
- **`Connecting`** — the handshake is in progress. Carries `peerPubKeys`
(peers we're handshaking with) and `pendingPeerPubKeys` (known group
members we haven't heard from yet).
- **`Connected`** — at least one peer session is live. Same fields as
`Connecting` plus `startedAtEpoch`.
- **`Ended(reason)`** — terminal transition. After `ENDED_DISPLAY_MS` (2 s)
the state automatically resets to `Idle`.
`EndReason`: `HANGUP`, `REJECTED`, `TIMEOUT`, `ERROR`, `PEER_HANGUP`,
`PEER_REJECTED`, `ANSWERED_ELSEWHERE`.
## 4. `CallManager` responsibilities
`CallManager` (in this package) is the single owner of call state. It:
1. Runs the state machine. All mutations go through `stateMutex` — signaling
events arrive on multiple relay coroutines concurrently.
2. Dispatches signaling events (`onSignalingEvent`) to per-kind handlers
(`onIncomingCallEvent`, `onCallAnswered`, `onCallRejected`, `onPeerHangup`,
`onIceCandidate`, `onRenegotiate`).
3. Publishes outbound signaling via `factory: WebRtcCallFactory` and the
injected `publishEvent: (EphemeralGiftWrapEvent) -> Unit` lambda.
4. Forwards SDP/ICE payloads to the platform WebRTC layer via callback
properties (`onAnswerReceived`, `onIceCandidateReceived`,
`onRenegotiationOfferReceived`, `onNewPeerInGroupCall`,
`onMidCallOfferReceived`, `onPeerLeft`). **These callbacks are the only
`CallManager` → `CallController` contact surface.**
5. Manages two kinds of timers:
- **Global timeout** (`CALL_TIMEOUT_MS = 60 s`) — fires while in `Offering`
or `IncomingCall`. Transitions to `Ended(TIMEOUT)` and, if `Offering`,
publishes a group hangup so callees stop ringing immediately.
- **Per-peer invite timeout** (`PEER_INVITE_TIMEOUT_MS = 30 s`) — one timer
per pending peer. See §7 for the invited-vs-watchdog distinction.
6. Deduplicates incoming events (`processedEventIds`) and drops events older
than 20 s or from before `initTimestamp` (`isEventTooOld`). Remembers
already-terminated call-ids (`completedCallIds`) so relay replays after an
app restart don't re-ring for a dead call.
`CallManager` is created by `CallController`, which injects the current
account's `NostrSigner`, a coroutine scope tied to the account's lifetime,
`isFollowing` to gate incoming offers, the `publishEvent` lambda that goes
through the relay client, and `isCallsEnabled` (settings toggle).
## 5. Incoming call pipeline
```
relay → GiftWrap subscription on p=myPubKey
→ DecryptAndIndexProcessor decrypts & unwraps
→ routes kind-25050..25055 events to CallManager.onSignalingEvent
→ stateMutex.withLock { … dedup, age-gate, dispatch … }
→ per-kind handler mutates _state and/or forwards to CallController
→ CallController drives org.webrtc.PeerConnection via WebRtcCallSession
```
Relay subscription: `amethyst/.../service/relayClient/.../FilterGiftWrapsToPubkey.kt`
(filter: `kind ∈ {1059, 21059}, #p = [myPubKey]`).
Decryption + dispatch: `DecryptAndIndexProcessor.kt` routes every
`CallOfferEvent | CallAnswerEvent | CallIceCandidateEvent | CallHangupEvent |
CallRejectEvent | CallRenegotiateEvent` to `callManager?.onSignalingEvent(event)`.
## 6. Outgoing call pipeline
- **P2P**: `CallController.initiateCall(peer, type)` → generates SDP →
`CallManager.initiateCall(peer, type, callId, sdpOffer)` → transitions to
`Offering`, publishes one wrap to the callee.
- **Group**: `CallController.beginGroupCall(peers, type)` →
`CallManager.beginOffering(callId, peers, type)` transitions to `Offering`
and schedules per-peer timers, then CallController creates a separate SDP
per peer and calls `CallManager.publishOfferToPeer` once for each.
`WebRtcCallFactory.createGroupCallOffer` / `createGroupCallAnswer` /
`createGroupHangup` / `createGroupReject` each sign a **single** inner event
(with `p` tags listing all members) and produce one `EphemeralGiftWrapEvent`
per recipient — the inner event is shared, only the outer wrap envelope
differs.
## 7. Group calls: invited vs. watchdog timers
Two sources drop a pending peer from the state after 30 s, but they publish
different signals:
- **Invited by us** (we sent the offer): added to `peersInvitedByUs`.
On timeout we publish a `CallHangup` addressed to them so their device
stops ringing.
- **Watched by us** (we accepted a group call and are waiting on siblings
we never invited): we only run a local watchdog. Terminating their ringing
is the *original caller's* responsibility; we must not publish anything on
their behalf.
Distinguishing these two is the job of `peersInvitedByUs` and the
`wasInvitedByUs` flag in `handlePeerTimeout`. Do not collapse them.
Callee-to-callee mesh (group calls): when `acceptCall` runs, any answers we
already observed during `IncomingCall` (`discoveredCalleePeers`) trigger
`onNewPeerInGroupCall` callbacks so the CallController opens additional peer
connections. Mid-call offers from other callees arrive via `onMidCallOfferReceived`.
## 8. Multi-device ("answered elsewhere") handling
A user can be logged in on multiple devices. All of them are subscribed to
gift wraps for the same pubkey, so all of them receive the caller's offer and
ring simultaneously. The rules:
1. **`acceptCall` and `rejectCall`** publish two sets of wraps:
- The "real" wraps for every other group member (carrying SDP where
relevant).
- **An extra wrap of the same signed event addressed to `signer.pubKey`**
so sibling devices on the same account observe it.
2. **Sibling device receives a self-answer in `IncomingCall`** →
`onCallAnswered` transitions to `Ended(ANSWERED_ELSEWHERE)`. Self-reject
in `IncomingCall` → `Ended(REJECTED)`. Both are purely local state
changes; neither publishes any signaling. **This is why the device that
picked up is never disturbed.**
3. **The device that published the self-wrap also receives its own echo**.
It is ignored: the self-pubkey guard at the top of both `onCallAnswered`
and `onCallRejected` returns early in any state other than `IncomingCall`.
Without the self-reject guard, an echo arriving while in
`Connecting`/`Connected` would fire `onPeerLeft(signer.pubKey)` and the
CallController would dispose its *own* PeerSession, killing the live
audio — do not remove that guard.
4. **Self-ICE and self-hangup** are unconditionally filtered at the top of
`onSignalingEvent` (they are never useful). Self-answers and self-rejects
are **not** filtered there — they are the "answered/rejected elsewhere"
signal and must reach the handler.
5. **Stale offer replays**: `completedCallIds` is populated by hangup, reject,
**and self-answer** events. If a relay replays events out of order so a
self-answer arrives before the original offer, the later offer is dropped
by the `callId in completedCallIds` check in `onIncomingCallEvent`.
See `CallManagerTest.kt`'s "Multi-Device" section for the full scenario
coverage.
## 9. Invariants and pitfalls
- **All state mutations must happen under `stateMutex`.** The lock also
guards `processedEventIds`, `completedCallIds`, `peersInvitedByUs`, and
`perPeerTimeoutJobs`. Methods that are called from outside
`onSignalingEvent` (e.g. `acceptCall`, `hangup`, `onPeerConnected`) take
the lock themselves.
- **Publish outside the lock.** The lock protects state; the signing + gift
wrap is slow and must not block other signaling. Pattern: read/mutate state
inside the lock, collect the bytes to publish, then `publishEvent` after
releasing.
- **`transitionToEnded` is local-only.** It never publishes anything.
Publishers (`hangup`, `rejectCall`, timeout handlers) publish *around* it.
- **`onPeerLeft(pubKey)` must never be called with `signer.pubKey`.** It
tells CallController to dispose a peer session; passing self tears down
the local call. The self-pubkey early-returns in `onCallAnswered` and
`onCallRejected` enforce this.
- **`ENDED_DISPLAY_MS` auto-reset.** After ending, state lingers in `Ended`
for 2 s so the UI can show "Call ended" before resetting to `Idle`.
`resetJob` is cancelled on any new transition.
- **`isEventTooOld` rejects events older than 20 s** (`MAX_EVENT_AGE_SECONDS`)
or created before `CallManager` was constructed. This prevents relays from
ringing the phone with a day-old offer on app startup.
- **`isFollowing` gates incoming offers only.** Once a call is in flight,
all mid-call signaling is accepted regardless of the follow relationship.
- **Do NOT add event kinds to `onSignalingEvent`'s self-filter without
thinking.** Self-answers and self-rejects are load-bearing for
multi-device.
## 10. Testing
`commons/commonTest/.../call/CallManagerTest.kt` is the canonical reference
for expected state transitions. It uses real `NostrSignerInternal` keys so
the factory-sign-wrap pipeline is exercised end-to-end, with `publishEvent`
captured into a list for assertions. Construct events directly with the
`makeOffer` / `makeAnswer` / `makeHangup` / `makeReject` / `makeIceCandidate`
helpers — they mirror real event shapes but skip signing.
The "Multi-Device: Second Logged-In Device Must Stop Ringing" section at the
bottom of the test file shows how to simulate two phones with the same
signer. Prefer extending it over creating new test files.
Run just the CallManager tests:
```bash
./gradlew :commons:commonTest --tests "com.vitorpamplona.amethyst.commons.call.CallManagerTest"
```