cometchat-ios-calls
$
npx mdskill add cometchat/cometchat-skills/cometchat-ios-callsIntegrates CometChat Calls SDK v5 for native iOS voice and video calling.
- Adds voice/video calling to iOS apps using CometChat signaling and WebRTC.
- Depends on CometChatSDK, CometChatCallsSDK, CallKit, PushKit, and AVAudioSession.
- Selects standalone or additive mode based on existing CometChatUIKitSwift integration.
- Provides SwiftUI/UIKit hosting, permissions setup, and VoIP push configuration.
SKILL.md
.github/skills/cometchat-ios-callsView on GitHub ↗
---
name: cometchat-ios-calls
description: CometChat Calls SDK v5 integration for native iOS (Swift; SwiftUI + UIKit hosting). Covers SDK install (SPM + CocoaPods), CometChatCalls init, dual-SDK ringing (Chat SDK initiateCall + Calls SDK generateToken/startSession), CallKit + PushKit VoIP push, AVAudioSession routing, mixed-stack hosting (UIViewControllerRepresentable for SwiftUI), Info.plist permissions + Background Modes, and additive-vs-standalone modes.
license: "MIT"
compatibility: "Xcode 15+, Swift 5.9+, iOS 13+ deployment target; CometChatCallsSDK 5.x; CometChatSDK 4.x; CometChatUIKitSwift 5.x (additive mode)"
metadata:
author: "CometChat"
version: "4.0.0"
tags: "cometchat ios calls voice video webrtc swift swiftui uikit callkit pushkit avaudiosession spm cocoapods voip background-modes"
---
## Purpose
Production-grade voice + video calling for native iOS. Loaded by `cometchat-calls` when `framework === "ios"`. Operates in two modes:
- **Standalone** — calls is the product. `CometChatSDK` (signaling) + `CometChatCallsSDK` (WebRTC) + your own SwiftUI views or UIKit view controllers. **CallKit + PushKit are mandatory.**
- **Additive** — calls layered onto an existing CometChat iOS UI Kit integration. The kit's `CometChatMessageHeader` already exposes call buttons; this skill wires them and mounts the global call listener.
**Read these other skills first:**
- `cometchat-calls` — dispatcher (modes, hard rules, anti-patterns)
- `cometchat-ios-core` — Chat SDK init, login, `Secrets.swift` / `.xcconfig` credential conventions, SwiftUI vs UIKit entry-point detection
**Ground truth:**
- SDK source — `calls-sdk-ios-5/sdk/`
- Sample app — `calls-sdk-ios-5/sample-apps/cometchat-calls-sample-app-ios/`
- `Package.swift` — `https://github.com/cometchat/calls-sdk-ios.git` (SPM)
- Public docs — https://www.cometchat.com/docs/calls/ios/overview
---
## iOS Calls SDK v5 resolution — verified working (re-checked 2026-06-04)
> An earlier draft of this skill warned that "the iOS Calls SDK v5 binary is broken / missing from Cloudsmith — stay on v4.2.x." **That warning was wrong and has been removed.** It was based on a hand-constructed URL (`…/raw/versions/124.0.4/CometChatWebRTC-124.0.4.xcframework.zip`) that 404s — but that is NOT the path the podspec/`Package.swift` actually fetches, so its 404 never proved anything. Verified 2026-06-04:
> - **Additive (UI Kit) path:** `CometChatCallsSDK` v5 ships **vendored inside `CometChatUIKitSwift`** — a clean `pod install` resolves `CometChatUIKitSwift (5.1.13)` (which bundles the v5 CallsSDK framework) with **no 404**. The audited `CometChatCallsSDK.swiftinterface` (v5 shape: `generateToken`/`startSession`, no `login`) came straight out of that resolved framework.
> - **Standalone path:** `CometChatCallsSDK` v5.0.0 is consumable via CocoaPods (e.g. the internal VoIP+APNs sample under Linear ENG-35167 builds against it).
>
> If you ever DO hit a missing-WebRTC binary on a direct standalone `pod 'CometChatCallsSDK'` install, treat it as an environment/registry hiccup and check the current podspec URL — it is not a known blanket failure, and there is no open vendor ticket for it.
---
## 1. The seven hard rules — iOS specialization
> ⚠️ **TOP PRIORITY (ENG-35710): Auth-token passing is the most-broken step on iOS Calls integrations.** Unlike Android / Flutter / JS, the **iOS Calls SDK 5.x has NO separate login step** — there is no `CometChatCalls.login(...)` and no `CometChatCalls.getLoggedInUser()` (those are phantom; verified absent from `CometChatCallsSDK`). Auth is supplied **per session** as the `authToken:` argument to `CometChatCalls.generateToken(authToken:sessionID:)`. Every tester who hit "calls don't work" on iOS traced back to that token: (a) the token was empty/nil → `generateToken` fails with **"auth token cannot be null"**; (b) a server-minted token expired between mint and `generateToken` (default ~30-minute TTL); (c) the client-side `apiKey` was passed instead of the user's auth token. Before scaffolding ANY iOS calls code, confirm the auth path (dev: read `CometChat.getUserAuthToken()`; production: fetch a fresh server-minted token) and feed it to `generateToken`. Mixing them is the canonical "ringing but never joins" iOS failure mode.
### 1.0 The iOS Calls SDK has NO separate login (v5)
Unlike the Android / Flutter / JS Calls SDKs, **`CometChatCalls` on iOS has no `login()` and no `getLoggedInUser()`** — those methods do not exist in `CometChatCallsSDK` 5.x. There is nothing to "log in" to. Once the Chat SDK session exists, you authorize each call session by passing an **auth token** to `generateToken(authToken:sessionID:)`, then `startSession(callToken:callSetting:view:)` with the returned call token (full flow in §1.1).
```swift
import CometChatSDK
import CometChatCallsSDK
// Chat SDK login is the ONLY login. (UI Kit users: CometChatUIKit.login already did this.)
CometChat.login(UID: uid, authKey: AUTH_KEY) { user in
// Chat SDK ready. No CometChatCalls.login() exists — do NOT call it.
// The incoming-call listener can be registered now; per-call auth happens via generateToken.
} onError: { error in
// chat login failed
}
// Dev: the auth token for generateToken comes from the Chat SDK:
let authToken = CometChat.getUserAuthToken() // String? — pass to generateToken(authToken:)
// Production: fetch a fresh server-minted token from your backend instead.
```
**Surprises:**
- There is **no Calls-SDK session to persist** — the Chat SDK's Keychain session is the only persisted login. On app start just confirm `CometChat.getLoggedInUser() != nil`.
- `generateToken` / `startSession` errors hit the `onError` closure (not `do/try/catch`). The error type is `CometChatCallException` — has `errorDescription` and `errorCode`.
### 1.1 Dual-SDK contract
Chat SDK (`CometChatSDK`) initiates ringing; Calls SDK (`CometChatCallsSDK`) runs the WebRTC session. There is no two-`Call`-classes problem on iOS — Swift's module separation prevents it. But the API split still exists:
```swift
// ✓ RIGHT — initiate ringing (Chat SDK)
import CometChatSDK
let outgoing = Call(receiverId: receiverUid, callType: .video, receiverType: .user)
CometChat.initiateCall(call: outgoing, onSuccess: { initiated in
// initiated.sessionID is what the Calls SDK will join
}, onError: { error in
// surface to UI
})
```
```swift
// ✓ RIGHT — start the WebRTC session (Calls SDK 5.x) after acceptance.
// iOS Calls SDK 5.x is a TWO-STEP flow: generateToken → startSession.
// There is NO joinSession, NO CallSession singleton, and NO SessionSettingsBuilder
// in the 5.x SDK — those are phantom. The real builder is CallSettingsBuilder.
// - CallSettingsBuilder (NOT SessionSettingsBuilder) builds the CallSettings
// - generateToken(authToken:sessionID:) FIRST, then startSession(callToken:callSetting:view:)
// - Attach the event listener via CallSettingsBuilder.setDelegate(_:) (CallsEventsDelegate)
import CometChatCallsSDK
let settings = CallSettingsBuilder()
.setStartVideoMuted(false)
.setStartAudioMuted(false)
.setDelegate(callListener) // any CallsEventsDelegate — onCallEnded, onUserJoined, …
.build()
// loggedInAuthToken: the current user's auth token — CometChat.getUserAuthToken()
// (User has NO .authToken property; getUserAuthToken() is the real accessor)
CometChatCalls.generateToken(authToken: loggedInAuthToken, sessionID: sessionID) { callToken in
guard let callToken = callToken else { return }
CometChatCalls.startSession(
callToken: callToken,
callSetting: settings,
view: callContainerView // UIView with measurable bounds
) { _ in
// session started — the call UI is rendered into callContainerView
} onError: { error in
print("startSession failed: \(error?.errorDescription ?? "unknown")")
}
} onError: { error in
print("generateToken failed: \(error?.errorDescription ?? "unknown")")
}
```
### 1.1a End-to-end initiate→join sequence (ENG-35710)
After `CometChat.initiateCall` the caller's session is NOT auto-joined — the gap between *initiation* and *joining* is the second canonical iOS failure (after auth-token). Three events must fire in sequence; the caller side handles each in a different callback.
> **Corrected 2026-06-02 against `calls-sdk-ios/sample-apps/cometchat-calls-sample-app-ringing-ios/CometChatCallsRinging/AppState.swift:213`** — the listener protocol is **`CometChatCallDelegate`** (NOT `CometChatCallsListener` as earlier drafts said), and each callback takes TWO parameters (`Call?` + `CometChatException?`), NOT one. Earlier example would not compile.
```swift
// Step A: register the listener on app start (before any call fires)
// Listener ID is a String; listener instance is "self" or any class conforming
// to CometChatCallDelegate.
CometChat.addCallListener(callListenerID, self)
// Step B: conform to CometChatCallDelegate (NOT CometChatCallsListener)
extension YourClass: CometChatCallDelegate {
// Caller side: the OTHER party accepted — NOW join the session
func onOutgoingCallAccepted(acceptedCall: Call?, error: CometChatException?) {
if let error = error { /* surface */ return }
guard let call = acceptedCall, let sessionID = call.sessionID else { return }
startCallSession(sessionID: sessionID)
}
// Receiver side: incoming call rang — present accept UI; on accept, start the session
func onIncomingCallReceived(incomingCall: Call?, error: CometChatException?) {
if let error = error { /* surface */ return }
guard let call = incomingCall else { return }
presentIncomingCallUI(for: call) { accepted in
if accepted {
CometChat.acceptCall(sessionID: call.sessionID ?? "") { acceptedCall in
self.startCallSession(sessionID: acceptedCall?.sessionID ?? "")
} onError: { _ in /* surface */ }
}
}
}
func onOutgoingCallRejected(rejectedCall: Call?, error: CometChatException?) { /* end UI, cleanup */ }
func onIncomingCallCancelled(canceledCall: Call?, error: CometChatException?) { /* dismiss incoming UI — label is canceledCall: (single L) */ }
}
// Don't forget to remove the listener on teardown
// CometChat.removeCallListener(callListenerID)
// Step B: kick the call off (caller — your "Call" button taps this)
let outgoing = Call(receiverId: targetUid, callType: .video, receiverType: .user)
CometChat.initiateCall(call: outgoing, onSuccess: { initiated in
// initiated.sessionID is what the session will use — but DO NOT start the
// session here. Wait for onOutgoingCallAccepted on the listener. Starting before
// the remote accepts means you're alone in an empty session.
}, onError: { error in /* surface */ })
// Step C: start the session (caller in onOutgoingCallAccepted, receiver after acceptCall)
// 5.x flow: generateToken(authToken:sessionID:) → startSession(callToken:callSetting:view:)
func startCallSession(sessionID: String) {
let settings = CallSettingsBuilder()
.setStartVideoMuted(false)
.setStartAudioMuted(false)
.setDelegate(self) // self conforms to CallsEventsDelegate
.build()
CometChatCalls.generateToken(authToken: loggedInAuthToken, sessionID: sessionID) { callToken in
guard let callToken = callToken else { return }
CometChatCalls.startSession(
callToken: callToken,
callSetting: settings,
view: callContainerView // UIView WITH non-zero bounds (lay out first)
) { _ in
// session started
} onError: { error in
print("startSession failed: \(error?.errorDescription ?? "unknown")")
}
} onError: { error in
print("generateToken failed: \(error?.errorDescription ?? "unknown")")
}
}
```
The shape: `initiateCall` → wait for `onOutgoingCallAccepted` on your `CometChatCallDelegate` → `generateToken` → `startSession`. Skipping the listener and starting inside `initiateCall`'s `onSuccess` is the "ringing but never joins" trap — you'll see the call connect on the wire but the receiver's accept never reaches your code.
### 1.2 CallKit + PushKit are mandatory
iOS will not deliver standard push notifications to a backgrounded/terminated app for an incoming call — the app would have to be foregrounded for it to ring. CallKit + PushKit is the only correct path:
- **PushKit** delivers VoIP push tokens (separate from APNs tokens) and high-priority VoIP payloads even when the app is terminated
- **CallKit** presents the system incoming-call UI (lock-screen, even when device is locked) and reports call state to the OS
- Your `PKPushRegistry` listener calls `CXProvider.reportNewIncomingCall(...)` immediately on receiving the VoIP payload — Apple terminates apps that delay this
This pattern is non-negotiable for App Store review on apps that ring users. Standalone mode wires it; additive mode prompts but doesn't force it.
### 1.3 Background modes — three required entries
In the target's "Signing & Capabilities" → Background Modes:
- **`Audio, AirPlay, and Picture in Picture`** — required for ongoing-call audio when backgrounded
- **`Voice over IP`** — required for PushKit VoIP payload delivery
- **`Remote notifications`** — required for chat push (keeps existing entitlements working)
In `Info.plist`:
```xml
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
<string>voip</string>
<string>remote-notification</string>
</array>
```
Apple will reject the app if `voip` is declared but PushKit is not actually used.
### 1.4 Server-minted auth tokens
Same rule as the chat dispatcher / `cometchat-ios-production`. Production calls path uses `CometChat.login(authToken:)`, not `CometChat.login(uid:authKey:)`.
### 1.5 Hangup cleanup — `AVAudioSession` routing
Releasing the camera + mic on iOS is straightforward; the gotcha is `AVAudioSession`:
```swift
import CometChatCallsSDK
func endCall() {
// v5: end the session via the static CometChatCalls.endSession().
// There is NO CallSession singleton and NO leaveSession() in the 5.x SDK —
// all in-call control is via static funcs on CometChatCalls.
CometChatCalls.endSession()
callContainer.subviews.forEach { $0.removeFromSuperview() }
// CRITICAL: deactivate the audio session, otherwise the speaker stays
// routed to "earpiece + voice processing" mode and other audio (music,
// phone calls outside the app) sounds wrong until the user resets it.
do {
try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
} catch {
// log but don't block — best-effort cleanup
}
// CallKit must be told the call ended, even if WE ended it
cxProvider.reportCall(with: callUUID, endedAt: Date(), reason: .remoteEnded)
}
```
In-call controls are **static funcs on `CometChatCalls`** (there is no `CallSession` type in the 5.x Calls SDK). Audio/video toggles take a `Bool`:
```swift
CometChatCalls.audioMuted(true) // mute mic; false = unmute
CometChatCalls.videoPaused(true) // pause video; false = resume
CometChatCalls.switchCamera()
CometChatCalls.enterPIPMode() // picture-in-picture; exitPIPMode() to leave
CometChatCalls.startRecording() // stopRecording() to end
// NOTE: the iOS Calls SDK 5.x has NO local screen-share initiation API
// (no startScreenShare/stopScreenShare) — iOS can only RECEIVE a remote
// participant's screen share, not start one.
```
Skipping the `setActive(false, options: .notifyOthersOnDeactivation)` is the canonical "music doesn't resume after the call" bug.
### 1.6 Permissions with rationale — Info.plist usage strings
Two required entries; iOS rejects builds that record without them:
```xml
<key>NSMicrophoneUsageDescription</key>
<string>So you can talk during voice and video calls.</string>
<key>NSCameraUsageDescription</key>
<string>So you can be seen during video calls.</string>
```
Plus, on iOS 15+, request permission via `AVCaptureDevice.requestAccess(for:)` and `AVAudioApplication.requestRecordPermission` (deprecating `AVAudioSession.requestRecordPermission` in iOS 17+). The skill writes the modern path.
### 1.7 IncomingCall UI — CallKit owns it
In standalone mode, you do NOT render an in-app incoming-call screen. CallKit's system UI handles it (rule 1.2). Your job is:
1. Keep `PKPushRegistry` alive — register in `application(_:didFinishLaunchingWithOptions:)` (UIKit) or `App.init()` (SwiftUI's `@main`)
2. On VoIP payload → call `CXProvider.reportNewIncomingCall`
3. Implement `CXProviderDelegate` `provider(_:perform:)` for `CXAnswerCallAction` — this is where you finally call `CometChatCalls.generateToken` then `startSession`
In additive mode, the UI Kit's incoming-call view can be used for in-foreground rings; CallKit handles backgrounded/terminated rings.
### 1.7a Default incoming/outgoing UI fallback (ENG-35710)
Two iOS testers shipped without a foreground incoming/outgoing screen because the skill jumped straight to CallKit and never showed what to render when the app is already in-foreground. Result: a Call rings on the wire, but the user-facing app shows nothing — the call connects, the call drops, no feedback.
**If the developer hasn't asked for a custom incoming-call UI, wire the kit's defaults:**
```swift
// SwiftUI host
import SwiftUI
import UIKit
import CometChatUIKitSwift
// The kit ships call UI as UIViewControllers (CometChatIncomingCall / CometChatOutgoingCall /
// CometChatOngoingCall) — there are NO `…SwiftUI`-suffixed wrappers. Bridge with
// UIViewControllerRepresentable. Present the controller when your delegate fires (§1.1a);
// configure it via the kit's IncomingCallConfiguration rather than a constructor arg.
struct CometChatIncomingCallView: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> CometChatIncomingCall {
CometChatIncomingCall()
}
func updateUIViewController(_ vc: CometChatIncomingCall, context: Context) {}
}
struct AppRootView: View {
@State private var hasIncomingCall = false
var body: some View {
ZStack {
ContentView() // your app's tabs / nav
if hasIncomingCall {
CometChatIncomingCallView()
.ignoresSafeArea()
}
}
// flip `hasIncomingCall` from your CometChatCallDelegate.onIncomingCallReceived (see §1.1a)
}
}
```
```swift
// UIKit host
class RootViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// Install the kit's incoming-call VC above all other view controllers
let incoming = CometChatIncomingCall()
addChild(incoming)
view.addSubview(incoming.view)
incoming.view.frame = view.bounds
incoming.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
incoming.didMove(toParent: self)
}
}
```
Mounting at the root (above your nav / tab structure) means foreground rings work app-wide — same idea as the web skill's "mount above the route boundary" rule. CallKit still handles backgrounded/terminated rings; the kit's incoming-call VC handles foreground rings. Together they're the complete foreground-and-background coverage.
If the developer DOES want a custom UI: tell them to (a) implement `CometChatCallDelegate.onIncomingCallReceived` to drive their own SwiftUI/UIKit screen, (b) call `CometChat.acceptCall` / `rejectCall` from that screen, and (c) on accept, follow the `generateToken` → `startSession` recipe in §1.1a. The kit's default UI is the recommended starting point because it already wires accept/reject/timer/profile-pic; replicating it from scratch is a 200-line side quest.
---
## 2. Setup
### Swift Package Manager (preferred)
Add via Xcode → File → Add Package Dependencies:
```
https://github.com/cometchat/calls-sdk-ios.git
```
Pin to `5.0.0..<6.0.0`. SPM resolves `CometChatSDK` automatically.
### CocoaPods
```ruby
platform :ios, '13.0'
target 'YourApp' do
use_frameworks!
pod 'CometChatSDK', '~> 4.0' # signaling
pod 'CometChatCallsSDK', '~> 5.0' # WebRTC session
# additive mode also has CometChatUIKitSwift '~> 5.1' already
end
post_install do |installer|
installer.pods_project.build_configurations.each do |config|
config.build_settings['ENABLE_USER_SCRIPT_SANDBOXING'] = 'NO' # Xcode 15+
end
end
```
### Init
In SwiftUI app entry (`@main struct App.init()`) or UIKit `application(_:didFinishLaunchingWithOptions:)`:
```swift
import CometChatSDK
import CometChatCallsSDK
let appSettings = AppSettings.AppSettingsBuilder()
.subscribePresenceForAllUsers()
.setRegion(region: Secrets.cometchatRegion)
.build()
CometChat.init(appId: Secrets.cometchatAppID, appSettings: appSettings) { isInitialized, error in
guard error == nil else { return }
let callAppSettings = CallAppSettingsBuilder()
.setAppId(Secrets.cometchatAppID)
.setRegion(Secrets.cometchatRegion)
.build()
CometChatCalls.init(callsAppSettings: callAppSettings, onSuccess: { _ in
// ready — register PushKit (rule 1.2) and CXProvider (rule 1.7) here
}, onError: { error in
print("CometChatCalls.init failed: \(error?.errorDescription ?? "")")
})
}
```
Credentials via `Secrets.swift` (gitignored) or `.xcconfig` Build Settings — see `cometchat-ios-core`.
---
## 3. Components catalog
### Calls SDK primitives (used in standalone or wherever you build custom UI)
| Type | Purpose |
|---|---|
| `CometChatCalls` | Top-level facade — `init`, `generateToken`, `startSession`, `endSession`, `audioMuted(_:)`, `videoPaused(_:)`, `switchCamera`, `enterPIPMode`/`exitPIPMode`, `startRecording`/`stopRecording`, `addCallEventListener` |
| `CallSettingsBuilder` | Per-session config (audio-only, presenter, hide buttons, start muted, `setDelegate`) → `.build()` returns `CallSettings` |
| `CallSettings` | The built per-session config passed to `startSession(callToken:callSetting:view:)` (there is no `CallSession` object in 5.x — `startSession` returns a session-id `String`) |
| `CallType` | `.video` / `.audio` / `.audioVideo` (swiftinterface:294-297) — there is NO `.voice` |
| `DisplayModes` (enum) | `.default` / `.single` / `.spotlight` (swiftinterface:175) — passed to `CallSettingsBuilder.setMode(_:)`. The current setter is `setMode(_ value: DisplayModes)` (swiftinterface:281); the `setMode(_ value: NSString)` string overload (swiftinterface:280) is DEPRECATED. There is no `LayoutMode`/`.tile`/`.sidebar` |
| `CallLogsRequest` + `CallLogsBuilder` | Paginated call history (note the plural — there is no `CallLogRequest`/`CallLogRequestBuilder`) |
| `CallsEventsDelegate` (protocol) | Lifecycle + media + participant + button events — set via `CallSettingsBuilder.setDelegate(_:)` or `CometChatCalls.addCallEventListener(observerId:delegate:)`. (NOT `CometChatCallsEventsListener` — that name doesn't exist.) |
### UI Kit views (additive mode — `CometChatUIKitSwift`)
| View / VC | Purpose |
|---|---|
| `CometChatCallButtons` | Voice + video button row (standalone or in `CometChatMessageHeader`) |
| `CometChatIncomingCall` (UIViewController) | Foreground in-app ring UI |
| `CometChatOutgoingCall` (UIViewController) | Dialing UI |
| `CometChatOngoingCall` (UIViewController) | Active call UI hosting WebRTC |
| `CometChatCallLogs` (UIViewController) | Paginated history |
SwiftUI hosting via `UIViewControllerRepresentable`:
```swift
struct CallLogsView: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> CometChatCallLogs { CometChatCallLogs() }
func updateUIViewController(_ vc: CometChatCallLogs, context: Context) {}
}
```
---
## 4. Standalone integration
When `product === "voice-video"` and there is no existing UI Kit.
**Split by calling mode:**
### 4a. Standalone — Session mode (meeting-room UX, no ringing)
Calls SDK ONLY. NO Chat SDK, NO CallKit, NO PushKit. Matches `calls-sdk-ios-5/sample-apps/cometchat-calls-sample-app-ios/`. Scaffold:
1. **App entry** — `CometChatCalls.init(callsAppSettings:)` with a `CallAppSettingsBuilder().setAppId(...).setRegion(...).build()` ONLY. No `CometChat.init`, no `CometChat.login`, no PushKit, no CallKit.
2. **`JoinSessionView.swift`** — UID picker + "Start meeting" / "Join meeting" + state.
3. **`CallView.swift` + `CallContainerView: UIViewRepresentable`** — `CometChatCalls.generateToken(authToken:sessionID:)` then `CometChatCalls.startSession(callToken:callSetting:view:)`. Coordinator conforms to `CallsEventsDelegate` (set via `CallSettingsBuilder.setDelegate`). See `references/call-session.md`.
4. **`Info.plist` patch** — Camera + microphone permissions ONLY. No `audio` or `voip` Background Modes needed.
5. **Universal Link routing** — meeting URL → CallView.
**Why no CallKit / no PushKit:** session mode is link-driven, not push-driven. No incoming ring to surface.
### 4b. Standalone — Ringing mode (CallKit + PushKit + Chat SDK signaling)
Dual-SDK + CallKit + PushKit. Scaffold:
1. **App entry** — Chat SDK + Calls SDK init, PushKit + CallKit registration (rule 1.2, 1.7).
2. **`CallKitProviderDelegate.swift`** — `CXProviderDelegate` implementation. Routes `CXAnswerCallAction` → `CometChatCalls.generateToken` then `startSession(callToken:callSetting:view:)`. Routes `CXEndCallAction` → `CometChatCalls.endSession()` + `AVAudioSession` cleanup (rule 1.5).
3. **`PushRegistryDelegate.swift`** — `PKPushRegistryDelegate` listening on `.voIP`. On payload → `CXProvider.reportNewIncomingCall` immediately.
4. **`ProfileView.swift` (SwiftUI) or `ProfileViewController.swift` (UIKit)** — Hosts call buttons next to user info. Tap → `CometChat.initiateCall`.
5. **`OngoingCallView.swift`** — Custom view hosting the SDK's call surface via `UIViewRepresentable` bridging. Implements rule 1.5 teardown.
6. **`CallLogsView.swift`** — `/calls` equivalent — `CallLogsRequest.CallLogsBuilder`.
7. **`Info.plist` patch** — Background Modes (rule 1.3), permissions (rule 1.6).
8. **VoIP Services certificate setup** — manual step (Apple Developer portal + App Store Connect). The skill documents the steps; cannot automate.
## 5. Additive integration
When `cometchat-ios` is already integrated.
> ✅ **Kit-default path FIRST (what the canonical `SampleApp` actually does).** `CometChatCallsSDK` is **vendored inside `CometChatUIKitSwift`** — no separate SPM/Pod add, and **no separate `CometChatCalls.init`**. The sample turns calling on with **`UIKitSettings…enable(inAppIncomingCall: true)`** at init (`SampleApp/SceneDelegate.swift`), then just drops in `CometChatCallButtons(...).set(user:)` (e.g. in the user-details screen) and a `CometChatCallLogs` tab — the kit handles `generateToken`/`startSession` and the incoming-call UI internally. You do NOT hand-write `CometChatCalls.init` + a manual `generateToken → startSession` flow on this path. (The manual two-step is the **standalone / custom-UI** path only — §1.1 / §4.)
The kit-default additive recipe:
1. Confirm `CometChatUIKitSwift` is present (CallsSDK is bundled — nothing extra to install).
2. Set **`.enable(inAppIncomingCall: true)`** on the `UIKitSettings` builder at init (the one calling switch).
3. Add Background Modes (`voip`, `remote-notification`, `audio`) + camera/mic permissions to `Info.plist`.
4. Drop in `CometChatCallButtons` (e.g. user-details / contact screen) — the kit also auto-renders them in `CometChatMessageHeader` when CallsSDK is present; add `CometChatCallLogs` as a tab/route.
5. (Optional) CallKit + PushKit for background VoIP (asks user — same opt-in as Android).
Only reach for `CometChatCalls.init` + `generateToken`/`startSession` (§1.1) when building a **custom call surface** without the kit's call components.
## 6. Anti-patterns
1. **Skipping CallKit + PushKit** in standalone mode. The app cannot ring backgrounded/terminated devices any other way. App Store reviewers test this.
2. **Calling `CXProvider.reportNewIncomingCall` after async work.** Apple terminates the app process if the report happens more than ~5 seconds after PushKit delivery. Report immediately, then do the actual `generateToken` + `startSession` from inside `CXAnswerCallAction`.
3. **Forgetting `setActive(false, options: .notifyOthersOnDeactivation)`** on hangup. Music/phone-call audio sounds wrong until the next foreground. The "looks fine in dev, fails review" bug.
4. **Declaring `voip` in `UIBackgroundModes` without using PushKit.** Apple rejects.
5. **Mixing CallKit and an in-app incoming-call screen** in standalone mode. Causes double-rings and confused state. CallKit owns standalone rings; in-app UI is additive-mode only.
6. **Using SwiftUI `.alert` for incoming calls.** It only fires when the app is foregrounded. Use CallKit (rule 1.7).
7. **Pinning `CometChatCallsSDK` to a `4.x`** because that's what the chat SDK is on. They version independently — Calls SDK 5.x is the current calls major.
## 7. Verification checklist
**Static:**
- [ ] `CometChatCallsSDK` in SPM Package.resolved or Podfile.lock
- [ ] `CometChat.init` followed by `CometChatCalls.init` in app entry
- [ ] PushKit registry listening on `.voIP`
- [ ] `CXProvider` configured with `CXProviderConfiguration` (app name + ringtone if any)
- [ ] `CXProviderDelegate` implements answer + end actions
- [ ] `Info.plist` has `NSMicrophoneUsageDescription` + `NSCameraUsageDescription`
- [ ] `Info.plist` `UIBackgroundModes` has `audio` + `voip` + `remote-notification`
- [ ] Hangup path includes `endSession` + `setActive(false, options: .notifyOthersOnDeactivation)` + `cxProvider.reportCall(...endedAt:reason:)`
- [ ] **Standalone only:** No in-app incoming-call screen — CallKit owns it
**Runtime (real device):**
- [ ] Outgoing call connects, two-way audio + video
- [ ] Incoming call rings on lock screen (device locked + app terminated)
- [ ] Tap "Answer" on lock screen → app opens directly into ongoing call
- [ ] Hangup ends call, camera light off, music resumes if it was playing
- [ ] Background the app during a call → audio continues
- [ ] Lock device during a call → ongoing-call UI on lock screen, audio continues
## 8. Pointers
- `references/audio-modes-and-controls.md` — audio-route switching (speaker/earpiece/Bluetooth) + custom control panel (button-disable matrix)
- `cometchat-ios-core` — Chat SDK init, login, `Secrets.swift` conventions
- `cometchat-ios-components` — full UI Kit catalog (additive mode)
- `cometchat-ios-push` — APNs setup; some overlap with PushKit but distinct (PushKit is VoIP-only)
- `cometchat-ios-production` — server-minted tokens, security checklist
- `cometchat-ios-troubleshooting` — common failure modes (PushKit delivery, CallKit reporting timing, ENABLE_USER_SCRIPT_SANDBOXING)