Skip to content

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: CallViewCallViewModelCallEngine. 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()