cometchat-ios-calls — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited cometchat-ios-calls (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Production-grade voice + video calling for native iOS. Loaded by cometchat-calls when framework === "ios". Operates in two modes:
CometChatSDK (signaling) + CometChatCallsSDK (WebRTC) + your own SwiftUI views or UIKit view controllers. CallKit + PushKit are mandatory.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 detectionGround truth:
calls-sdk-ios-5/sdk/calls-sdk-ios-5/sample-apps/cometchat-calls-sample-app-ios/Package.swift — https://github.com/cometchat/calls-sdk-ios.git (SPM)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.swiftactually fetches, so its 404 never proved anything. Verified 2026-06-04: - Additive (UI Kit) path:CometChatCallsSDKv5 ships vendored inside `CometChatUIKitSwift` — a cleanpod installresolvesCometChatUIKitSwift (5.1.13)(which bundles the v5 CallsSDK framework) with no 404. The auditedCometChatCallsSDK.swiftinterface(v5 shape:generateToken/startSession, nologin) came straight out of that resolved framework. - Standalone path:CometChatCallsSDKv5.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.⚠️ 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 noCometChatCalls.login(...)and noCometChatCalls.getLoggedInUser()(those are phantom; verified absent fromCometChatCallsSDK). Auth is supplied per session as theauthToken:argument toCometChatCalls.generateToken(authToken:sessionID:). Every tester who hit "calls don't work" on iOS traced back to that token: (a) the token was empty/nil →generateTokenfails with "auth token cannot be null"; (b) a server-minted token expired between mint andgenerateToken(default ~30-minute TTL); (c) the client-sideapiKeywas passed instead of the user's auth token. Before scaffolding ANY iOS calls code, confirm the auth path (dev: readCometChat.getUserAuthToken(); production: fetch a fresh server-minted token) and feed it togenerateToken. Mixing them is the canonical "ringing but never joins" iOS failure mode.
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).
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:
CometChat.getLoggedInUser() != nil.generateToken / startSession errors hit the onError closure (not do/try/catch). The error type is CometChatCallException — has errorDescription and errorCode.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:
// ✓ 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
})// ✓ 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")")
}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` (NOTCometChatCallsListeneras earlier drafts said), and each callback takes TWO parameters (Call?+CometChatException?), NOT one. Earlier example would not compile.
// 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.
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:
PKPushRegistry listener calls CXProvider.reportNewIncomingCall(...) immediately on receiving the VoIP payload — Apple terminates apps that delay thisThis 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.
In the target's "Signing & Capabilities" → Background Modes:
In Info.plist:
<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.
Same rule as the chat dispatcher / cometchat-ios-production. Production calls path uses CometChat.login(authToken:), not CometChat.login(uid:authKey:).
AVAudioSession routingReleasing the camera + mic on iOS is straightforward; the gotcha is AVAudioSession:
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:
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.
Two required entries; iOS rejects builds that record without them:
<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.
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:
PKPushRegistry alive — register in application(_:didFinishLaunchingWithOptions:) (UIKit) or App.init() (SwiftUI's @main)CXProvider.reportNewIncomingCallCXProviderDelegate provider(_:perform:) for CXAnswerCallAction — this is where you finally call CometChatCalls.generateToken then startSessionIn additive mode, the UI Kit's incoming-call view can be used for in-foreground rings; CallKit handles backgrounded/terminated rings.
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:
// 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)
}
}// 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.
Add via Xcode → File → Add Package Dependencies:
https://github.com/cometchat/calls-sdk-ios.gitPin to 5.0.0..<6.0.0. SPM resolves CometChatSDK automatically.
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
endIn SwiftUI app entry (@main struct App.init()) or UIKit application(_:didFinishLaunchingWithOptions:):
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.
| 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.) |
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:
struct CallLogsView: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> CometChatCallLogs { CometChatCallLogs() }
func updateUIViewController(_ vc: CometChatCallLogs, context: Context) {}
}When product === "voice-video" and there is no existing UI Kit.
Split by calling mode:
Calls SDK ONLY. NO Chat SDK, NO CallKit, NO PushKit. Matches calls-sdk-ios-5/sample-apps/cometchat-calls-sample-app-ios/. Scaffold:
CometChatCalls.init(callsAppSettings:) with a CallAppSettingsBuilder().setAppId(...).setRegion(...).build() ONLY. No CometChat.init, no CometChat.login, no PushKit, no CallKit.CometChatCalls.generateToken(authToken:sessionID:) then CometChatCalls.startSession(callToken:callSetting:view:). Coordinator conforms to CallsEventsDelegate (set via CallSettingsBuilder.setDelegate). See references/call-session.md.audio or voip Background Modes needed.Why no CallKit / no PushKit: session mode is link-driven, not push-driven. No incoming ring to surface.
Dual-SDK + CallKit + PushKit. Scaffold:
CXProviderDelegate implementation. Routes CXAnswerCallAction → CometChatCalls.generateToken then startSession(callToken:callSetting:view:). Routes CXEndCallAction → CometChatCalls.endSession() + AVAudioSession cleanup (rule 1.5).PKPushRegistryDelegate listening on .voIP. On payload → CXProvider.reportNewIncomingCall immediately.CometChat.initiateCall.UIViewRepresentable bridging. Implements rule 1.5 teardown./calls equivalent — CallLogsRequest.CallLogsBuilder.When cometchat-ios is already integrated.
✅ Kit-default path FIRST (what the canonical `SampleApp` actually does).CometChatCallsSDKis 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 inCometChatCallButtons(...).set(user:)(e.g. in the user-details screen) and aCometChatCallLogstab — the kit handlesgenerateToken/startSessionand the incoming-call UI internally. You do NOT hand-writeCometChatCalls.init+ a manualgenerateToken → startSessionflow on this path. (The manual two-step is the standalone / custom-UI path only — §1.1 / §4.)
The kit-default additive recipe:
CometChatUIKitSwift is present (CallsSDK is bundled — nothing extra to install).UIKitSettings builder at init (the one calling switch).voip, remote-notification, audio) + camera/mic permissions to Info.plist.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.Only reach for CometChatCalls.init + generateToken/startSession (§1.1) when building a custom call surface without the kit's call components.
generateToken + startSession from inside CXAnswerCallAction.Static:
CometChatCallsSDK in SPM Package.resolved or Podfile.lockCometChat.init followed by CometChatCalls.init in app entry.voIPCXProvider configured with CXProviderConfiguration (app name + ringtone if any)CXProviderDelegate implements answer + end actionsInfo.plist has NSMicrophoneUsageDescription + NSCameraUsageDescriptionInfo.plist UIBackgroundModes has audio + voip + remote-notificationendSession + setActive(false, options: .notifyOthersOnDeactivation) + cxProvider.reportCall(...endedAt:reason:)Runtime (real device):
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 conventionscometchat-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 checklistcometchat-ios-troubleshooting — common failure modes (PushKit delivery, CallKit reporting timing, ENABLE_USER_SCRIPT_SANDBOXING)~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.