Call Module Architecture — Clean Architecture iOS 17+¶
Philosophy: Pragmatic 3-layer architecture for iOS native + separated pure Swift
Domain/for testability/portability + clean protocol boundaries for key subsystems.
1. Scope & Constraints¶
| Item | Decision |
|---|---|
| Min iOS target | 17+ |
| State container | @Observable macro |
| Async events | Delegate closures from LiveKit/CallKit/STOMP bridged back to @MainActor CallEngine methods |
| Concurrency | async/await, actor, @MainActor |
| DI | Manual init injection (Composition Root at App Entry) |
| ViewModel | 1 CallViewModel for the active call view |
2. Pain Points → Resolution Mapping¶
| Pain point | Clean Architecture Resolution | File Responsible |
|---|---|---|
| Duplicate E2EE/KT decryption | KeyExchangeService actor: unified cryptographic pipeline | ecall/Features/Call/Domain/Services/KeyExchangeService.swift |
| Callback hell & thread races | Modern async/await flows coupled with proper actor/MainActor isolation | ecall/Features/Call/Domain/State/CallSessionStore.swift + CallEngine |
| Direct View coupling to low-level services | Active call UI: CallView → CallViewModel → CallEngine. Contact/history call entrypoints currently call the environment-injected CallEngine directly. | ecall/Features/Call/Presentation/ViewModels/CallViewModel.swift, ecall/Core/Utilities/CallEnvironmentKeys.swift |
| Signaling chaos / bloated files | SignalingRouter maps incoming SignalType → call engine methods | ecall/Features/Call/Domain/Services/SignalingRouter.swift |
| LiveKit reconnect drift | LiveKitRoomManager.onReconnectCompleted calls CallEngine.validateAndRecoverAfterReconnect() | ecall/Features/Call/Domain/Protocols/CallMediaSession.swift, ecall/Features/Call/Engine/CallEngine+Helpers.swift |
| Monolithic integrations | Clean interfaces for core networking, media, and security | Domain/Protocols/*.swift |
3. 3-Layer Architecture Overview¶
┌────────────────────────────────────────────────────────────────┐
│ Presentation SwiftUI Views ─▶ ViewModels (@MainActor) │
│ Uses @Observable to bind UI state │
└─────────────────────────────────┬──────────────────────────────┘
▼
┌────────────────────────────────────────────────────────────────┐
│ Module root CallEngine (facade) + CallKitAdapter │
│ Orchestrates active business logic flows │
└─────────────────────────────────┬──────────────────────────────┘
▼
┌────────────────────────────────────────────────────────────────┐
│ Domain / Services Models · State · Services · Protocols │
│ Foundation-only, NO UIKit/CallKit/LiveKit │
└─────────────────────────────────▲──────────────────────────────┘
│ implements protocols
┌────────────────────────────────────────────────────────────────┐
│ Infrastructure CallAPI · LiveKit · STOMP · Crypto · KT │
│ Concrete adapters behind boundaries │
└────────────────────────────────────────────────────────────────┘
Golden Rule: The Domain/ directory contains zero imports of UIKit, SwiftUI, CallKit, LiveKit, or third-party network libraries.
Current boundary note: SignalingRouter is stored under Domain/Services, but it is an orchestration router that depends on CallEngine through a closure. Treat it as an application-service boundary, not a pure domain rule object.
4. Subsystem Breakdown¶
| Layer | Component | Responsibility |
|---|---|---|
| Presentation | CallViewModel | @Observable @MainActor. Binds CallSessionStore + proxies UI actions to CallEngine. |
| Module Root | CallEngine | Facade – Coordinates main call flows: outgoing call, incoming answer, invite, rejoin, and teardown. |
CallKitAdapter | thin CXProviderDelegate adapter – Bridges system CallKit actions directly to CallEngine. | |
| Domain — Models | CallModel, Participant, CallError, SignalMessage | Pure Swift data models (Sendable). |
| Domain — State | CallSessionStore | @Observable @MainActor store tracking participants, status, call ID, and local media states. |
| Domain — Services | KeyExchangeService | actor – Manages Key Transparency proof fetching, trust verification, and AES session key decryption. |
SignalingRouter | Decodes incoming signaling messages and routes them to CallEngine. | |
| Domain — Protocols | CallAPI, CallMediaSession, SignalingTransport | Dependency Injection boundaries implemented in Infrastructure. |
| Infrastructure | RemoteCallAPI | async throws implementation of CallAPI network client. |
LiveKitRoomManager | @MainActor class implementing CallMediaSession to bridge the LiveKit media session and E2EE key provider. | |
StompSignalingTransport | Implements SignalingTransport via underlying WebSocket. | |
CallEncryptionAdapter | Implements E2EE encryption/decryption primitives (AES-256-GCM, P-256 ECDH, RSA). | |
PublicKeyVerifierAdapter | Implements Key Transparency verification logic. |
5. Actual Directory Structure¶
ecall/Features/Call/
├── Presentation/ # Native iOS UI Layer
│ ├── Views/
│ │ ├── CallView.swift # Root call screen (switches 1-1 vs group layout)
│ │ ├── CallHistory/
│ │ │ ├── CallHistoryView.swift
│ │ │ ├── CallHistoryContentView.swift
│ │ │ ├── CallHistoryDetailView.swift
│ │ │ └── CallHistoryRow.swift
│ │ └── SubViews/
│ │ ├── TwoPersonCallView.swift
│ │ ├── MultiPersonVideoView.swift
│ │ ├── MultiPersonAudioView.swift
│ │ ├── VideoParticipantTileView.swift
│ │ ├── AudioParticipantTileView.swift
│ │ ├── VideoTrackRendererView.swift
│ │ ├── ParticipantAvatarView.swift
│ │ ├── CallControlsView.swift
│ │ ├── CallBusyModeView.swift
│ │ └── KeyManagerSheetView.swift
│ ├── ViewModels/
│ │ ├── CallViewModel.swift # @Observable @MainActor final class
│ │ └── CallHistoryViewModel.swift # VM for recent calls list
│ ├── Extensions/
│ │ └── CallModel+UI.swift # UI display helpers for CallModel
│ └── Models/
│ └── ParticipantMediaProtocols.swift # Media display protocols
│
├── CallKit/
│ └── CallKitAdapter.swift # CXProviderDelegate boundary
│
├── Domain/ # ★ PURE SWIFT (Core business logic)
│ ├── Models/
│ │ ├── CallModel.swift
│ │ ├── Participant.swift
│ │ ├── CallError.swift
│ │ └── SignalMessage.swift
│ ├── State/
│ │ └── CallSessionStore.swift # Tracks current active session state
│ ├── Services/
│ │ ├── KeyExchangeService.swift # actor — unified E2EE cryptographic actions
│ │ └── SignalingRouter.swift # Decodes incoming signal events
│ └── Protocols/ # Subsystem Boundaries (Contracts)
│ ├── CallAPI.swift
│ ├── CallMediaSession.swift
│ └── SignalingTransport.swift
│
├── Data/ # Infrastructure Implementations
│ ├── API/
│ │ ├── RemoteCallAPI.swift # REST requests for active call lifecycle
│ │ └── CallHistoryAPI.swift # REST requests for call history
│ ├── Media/
│ │ ├── LiveKitRoomManager.swift # LiveKit media session connector
│ │ ├── AudioSessionManager.swift # Audio routing (speaker/receiver)
│ │ └── SFXManager.swift # Playback of ringtones / sound effects
│ ├── Security/
│ │ ├── CallEncryptionAdapter.swift # AES/RSA/ECDH implementation
│ │ ├── CallKeyStorage.swift # Keychain storage for encrypted call AES keys
│ │ └── PublicKeyVerifierAdapter.swift # Key Transparency proofs verifier
│ └── Signaling/
│ ├── StompSignalingTransport.swift # STOMP client coordinator
│ ├── STOMPClient.swift # Raw Web Socket wrapper
│ └── PushRegistryManager.swift # PushKit VoIP token registration
│
└── Engine/
├── CallDependencies.swift # DI Container wrapping active protocols
├── CallEngine.swift # Main orchestrator (facade)
├── CallEngine+Outgoing.swift # Outgoing call flow
├── CallEngine+Incoming.swift # Incoming call handling
├── CallEngine+Invite.swift # Invite additional participants
├── CallEngine+Rejoin.swift # Rejoin/reconnect flows
├── CallEngine+End.swift # Call termination & cleanup
└── CallEngine+Helpers.swift # Shared helpers (refresh, reconnect validation)
6. Dependency Flow & Architecture Graph¶
graph TD
subgraph Presentation
V[CallView]
VM["CallViewModel<br/>@Observable @MainActor"]
end
subgraph ModuleRoot
ENG["CallEngine<br/>facade"]
CKA["CallKitAdapter<br/>CXProviderDelegate"]
end
subgraph Domain
STORE["CallSessionStore<br/>@Observable"]
KES["KeyExchangeService<br/>actor"]
ROUTER[SignalingRouter]
PROTO["Protocols<br/>CallAPI/CallMediaSession/SignalingTransport"]
end
subgraph Data
API[RemoteCallAPI]
MEDIA[LiveKitRoomManager]
SIG[StompSignalingTransport]
ENC[CallEncryptionAdapter]
KT[PublicKeyVerifierAdapter]
end
V --> VM
VM --> ENG
VM -. observes .-> STORE
ENG --> STORE
ENG --> KES
ENG --> PROTO
CKA --> ENG
SIG --> ROUTER
ROUTER --> ENG
PROTO -. implemented by .-> API
PROTO -. implemented by .-> MEDIA
PROTO -. implemented by .-> SIG
KES --> ENC
KES --> KT 7. Sequence Flows (Flat Async)¶
7.1 Outgoing Call Flow¶
ContactRow / CallHistory / CallViewModel.recall(...)
│
▼
CallEngine.startOutgoingCall(callees, isVideo)
├── store.prepareOutgoing(...)
├── callKit.requestStart(...)
├── keys.fetchVerifiedKeys(...) (Unified actor: KT proof + PeerTrustStore)
├── keys.generateSessionKey()
├── keys.prepareInvitations(...) (Encrypt session key for each participant)
├── api.startGroupCall(...) (POST /app/api/call/start-v2, returns LiveKit token)
├── store.attach(callId, token)
├── media.connect(token, isVideo, isVideoEnabled, isMuted, e2eeKey)
└── store.setParticipants(...)
7.2 Incoming Call Flow¶
PushKit VoIP (`call_created`) → PushRegistryManager
│
▼
CallKitAdapter.reportIncomingSync(...)
│
▼
CallEngine.handleIncomingInvitation(payload, callKitUUID, livekitToken)
├── store.prepareIncoming(...)
├── keyStorage.storeEncryptedAESKey(...)
└── keyExchange.decryptIncomingKey(...) when possible
7.3 LiveKit Reconnect Validation Flow¶
LiveKitRoomManager.didCompleteReconnectWithMode(...)
│
▼
onReconnectCompleted
│
▼
CallEngine.validateAndRecoverAfterReconnect()
├── api.fetchCallParticipants(callId)
├── if local participant is still .joined:
│ └── store.setParticipants(...)
└── else:
├── reuse store.livekitToken + KeyExchangeService.currentSessionKey()
├── media.disconnect()
├── media.activateAudioEngine(isVideo:)
├── media.connect(...)
└── refreshParticipants()