This guide will show you how to build a native iOS video calling app using Swift, integrating CallKit for call management, PushKit for VoIP notifications, Firebase for push notifications, and VideoSDK for real-time communication.

App Overview

Imagine Ted wants to call Robin. Ted opens the app, enters Robin’s ID, and hits call. Robin receives an incoming call notification and can either accept or reject it. If she accepts, the app initiates a video call using VideoSDK.


Key Steps:
1. Call Initiation: Ted enters Robin's ID, which maps to Firebase, triggering a push notification to Robin's device.
2. Incoming Call UI: Robin’s device receives the notification, and CallKit presents the incoming call UI.
3. Call Connection: Upon acceptance, the app connects both users via VideoSDK for a video call.

Core Components

  1. CallKit
    • Purpose: Manages call actions, such as answering and rejecting calls, by providing a native UI.
    • Function: Displays the incoming call UI and handles call lifecycle events.
  2. PushKit
    • Purpose: Handles VoIP notifications, ensuring the app receives call alerts even when inactive.
    • Function: Wakes the app to handle incoming calls, integrating with CallKit for a seamless experience.
  3. Firebase & VoIP Notifications
    • Purpose: Manages push notifications via Firebase and APNs. - Function: Triggers incoming call UI and manages call events.
  4. Node.js Server
    • Purpose: Acts as the backend for call initiation and status updates.
    • Function: Sends VoIP push notifications and updates call status (accepted/rejected).

If we look at the development requirements, here is what you will need:

  1. Development Environment:

    • Xcode (latest version recommended) for iOS app development
    • macOS computer to run Xcode
  2. iOS Device:

    • At least one physical iOS device for testing CallKit (CallKit features cannot be fully tested in simulators)
  3. Server-side Requirements:

    • Node.js v12 or later
    • NPM v6 or later (typically included with Node.js)
  4. Apple Developer Account:

    • Required for provisioning profiles and push notifications
  5. VideoSDK:

Now that we've covered the prerequisites, let's dive into building the app. If you’d like a sneak peek at the final result, watch the video and review the complete code for a sample app.

Project Structure In Xcode

CallKitSwiftUI
│
├── AppDelegate.swift
├── GoogleService-Info.plist
├── CallKitSwiftUI.entitlements
├── Info.plist // Default
│
├── Model
│   ├── CallStruct.swift
│   ├── InitiateCallInfo.swift
│   └── RoomsStruct.swift
│
├── ViewModel
│   ├── UserData.swift
│   ├── CallKitManager.swift
│   ├── PushNotificationManager.swift
│   └── MeetingViewController.swift
│
├── Views
│   ├── NavigationState.swift
│   ├── CallKitSwiftUIApp.swift // Default
│   ├── JoinView.swift
│   └── CallingView.swift
│   └── MeetingView.swift

Firebase Setup

  1. Create a Firebase iOS App: Add your iOS app within the Firebase project.

  2. Add GoogleService-info.plist: Download and include the GoogleService-info.plist file in your project.

    Video SDK Image

  3. Integrate Firebase SDK: Use SPM or CocoaPods to add the Firebase SDK and necessary frameworks to your iOS project.

Register of VoIP and APNS

To enable PushKit notifications in your application, it is essential to acquire the necessary certificates from your Apple Developer Program account and set them up for your iOS VoIP application. These certificates are crucial for registering both your app and the device it operates on with APNs.

Request a Certificate Using Keychain

The initial procedure for enabling PushKit functionality within the application involves obtaining a private certificate through the Keychain Access application on a Mac. This certificate establishes a connection to your Apple Developer Program account and is essential for signing iOS VoIP applications that incorporate CallKit support. To generate the certificate, open the Keychain Access application.

  1. Select Certificate Assistant -> Request a Certificate From a Certificate Authority.
Video SDK Image
  1. Choose your email, and common name, and click continue.
Video SDK Image
  1. Modify the certificate’s name and save it.
Video SDK Image

Now we have to create the An App iD From Apple Developer Account

This process requires an active Apple Developer Program account. In this segment, the following actions must be undertaken:

  • Generate an App ID
  • Define the Bundle Identifier
  • Activate Push Notifications within the capabilities.

To proceed with the addition of an App ID, log into your Apple Developer account and adhere to the outlined steps.

  1. Select Identifiers under the section Certificates, Identifiers & Profiles.
Video SDK Image
  1. Click the plus (+) icon next to Identifiers and follow the steps.
Video SDK Image
  1. Add the description, specify your bundle ID, check PushKit under Capabilities, and click continue.
Video SDK Image

The image below shows the finished App ID.

Video SDK Image

Now We have to Create a New VoIP Services Certificate

Again Head to the Certificates category in your Apple Developer Program account and follow the steps below to add a new certificate.

  1. Choose the Certificates category next to Identifiers and click the plus (+) to add a new one.
Video SDK Image
  1. Check VoIP Services Certificate and choose the App ID you created in the previous section of this tutorial. Apple recommends using a reverse domain for App IDs.
Video SDK Image
  1. Select the private certificate you generated in one of the previous steps using Keychain Access and click continue.
Video SDK Image
  1. Now,Download the VoIP services certificate provided by your VoIP service provider. It will be saved as a .cer file named voip_services.cer.
  2. Now you have to Convert .cer to .p12
  • Double-click the voip_services.cer file to open it in Keychain Access.
  • Locate the certificate titled "VoIP Services: YourProductName".
  • Right-click on it and choose the option to export it as a .p12 file.
  • You'll be prompted for a password. Create a strong password and remember it.
  • Save the .p12 file to a secure location on your Mac.
  1. Open a terminal window and navigate to the directory where you saved the .p12 file. Run the following command, replacing YourFileName.p12 with the actual name of your .p12 file:
openssl pkcs12 -in YourCertificates.p12 -out Certificates.pem -nodes -clcerts -legacy
  • You'll be asked for the password you set earlier.
  • A new file named Certificates.pem will be created in the same directory.

Note: The bundle ID of your VoIP services will influence the exact certificate name. This .pem file is now ready for use in your push notification implementation.

PushKit Setup

PushKit will allow us to send the notifications to the iOS device for which, You must upload an APN Auth Key to implement push notifications. We need the following details about the app when sending push notifications via an APN Auth Key:

  • Auth Key file
  • Team ID
  • Key ID
  • Your app’s bundle ID

To create an APN auth key, follow the steps below.

  1. Visit the Apple Developer Member Center

    Video SDK Image

  2. Click on Certificates, Identifiers & Profiles. Go to Keys from the left side. Create a new Auth Key by clicking on the plus button on the top right side.

    Video SDK Image

  3. On the following page, add a Key Name, and select APNs.

    Video SDK Image

  4. Click on the Register button.

    Video SDK Image

  5. You can download your auth key file from this page and upload this file to the Firebase dashboard without changing its name.

    Video SDK Image

  6. In your Firebase project, go to Settings and select the Cloud Messaging tab. Scroll down iOS app configurationand click Upload under APNs Authentication Key

    Video SDK Image

  7. Enter Key ID and Team ID. Key ID is in the file name AuthKey\_{Key ID}.p8 and is 10 characters. Your Team ID is in the Apple Member Center under the membership tab or displayed always under your account name in the top right corner.

    Video SDK Image

  8. Enable Push Notifications in Capabilities

    Video SDK Image

    Video SDK Image

  9. Enable selected permission in Background Modes

    Video SDK Image

Server Setup

Steps

  1. Create a new project directory:
  • Open your terminal or command prompt.
  • Navigate to the desired location for your project.
  • Create a new directory:
mkdir server
cd server
  1. Initialize npm:
  • Create a `package.json` file to manage project dependencies:
npm init -y
  • This will create a `package.json` file with default settings.
  1. Install dependency
npm install express body-parser cors firebase-admin morgan node-fetch uuid https://github.com/node-apn/node-apn.git
  1. Create a server.js file
  • Create a file named `server.js` at the root of your project.
  • Add the following code to the file:
const express = require("express");
const cors = require("cors");
const morgan = require("morgan");
const { v4: uuidv4 } = require("uuid");

const app = express();
const port = 3000;

app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(morgan("dev"));

// Start the server
app.listen(port, () => {
  console.log(`Server running at http://localhost:${port}/`);
});
  1. Start Development Server
node index.js
  • Your application should now be running on port 3000 (or the specified port). You can access it by opening a web browser and going to http://localhost:3000.

App Setup

AppDelegate Setup

The AppDelegate class manages the app's lifecycle, push notifications, and Firebase integration for VoIP. It configures Firebase for push notifications, handles FCM tokens, and registers the app for remote notifications. The AppDelegate is responsible for setting up the application, including logging the APNs token for debugging purposes.

Device Registration in AppDelegate

During the app's initial installation, key steps include:

  • Device Registration: Configures push notifications to enable updates.
  • Token Generation: Creates Device and FCM tokens for notification identification.
  • Error Handling: Manages errors related to remote notification registration.

VoIP Registration and Firebase Cloud Messaging (FCM) Integration

  • This section explains how to set up VoIP registration with PushKit, enabling your iOS app to receive and handle incoming VoIP calls, even when it's not in the foreground. It also covers Firebase Cloud Messaging (FCM) integration to handle push notifications.
  • It will also store the device token and FCM token in your firebase database.

Create a separate Swift file, Model/CallStruct.swift, to manage and store session information and variables.

CallStruct.swift

import Foundation

struct CallingInfo {
    static var deviceToken: String?
    static var fcmTokenOfDevice: String?
    static var otherUIDOf: String?
    static var currentMeetingID: String? {
        get {
            return UserDefaults.standard.string(forKey: "currentMeetingID")
        }
        set {
            UserDefaults.standard.set(newValue, forKey: "currentMeetingID")
        }
    }
}

Create a new Swift file, AppDelegate.swift, to get the device token.

AppDelegate.swift

import UIKit
import FirebaseMessaging
import FirebaseCore

class AppDelegate: NSObject, UIApplicationDelegate {
    func application(_ application: UIApplication,
                    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
        UIApplication.shared.applicationIconBadgeNumber = 0
        return true
    }
    
    func application(_ application: UIApplication,
                     didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
         // Set the APNS token for Firebase Messaging
         Messaging.messaging().apnsToken = deviceToken

         // Convert and log token
         let tokenString = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
         print("APNS token: \(tokenString)")
         
     }
     
     func application(_ application: UIApplication,
                     didFailToRegisterForRemoteNotificationsWithError error: Error) {
         print("Failed to register for remote notifications: \(error.localizedDescription)")
     }
}

extension Notification.Name {
    static let callAnswered = Notification.Name("callAnswered")
}

Create a new Swift file, ViewModel/PushNotificationManager.swift, to manage the PushKit delegate and Remote notifications.

PushNotificationManager.swift

import Foundation
import UserNotifications
import FirebaseMessaging
import PushKit
import UIKit
import SwiftUI

class PushNotificationManager: NSObject, ObservableObject {
    static let shared = PushNotificationManager()
    
    @Published var fcmToken: String?
    private var voipRegistry: PKPushRegistry?
    private var deviceToken: String?
    private var isFcmTokenAvailable: Bool = false
    private var isDeviceTokenAvailable: Bool = false
    @Published var isRegistering: Bool = false
    private var callStatus: String?
    
    override private init() {
        super.init()
        setupNotifications()
        setupVoIP()
    }
    
    private func setupNotifications() {
        UNUserNotificationCenter.current().delegate = self
        Messaging.messaging().delegate = self
        
        UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
            if granted {
                DispatchQueue.main.async {
                    UIApplication.shared.registerForRemoteNotifications()
                }
            }
        }
    }
    
    private func setupVoIP() {
        voipRegistry = PKPushRegistry(queue: .main)
        voipRegistry?.delegate = self
        voipRegistry?.desiredPushTypes = [.voIP]
    }
}

// MARK: - UNUserNotificationCenterDelegate
extension PushNotificationManager: UNUserNotificationCenterDelegate {
    
    func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
        
        let userInfo = notification.request.content.userInfo
        if let callStatus = userInfo["type"] as? String {
            self.callStatus = callStatus
        }
        
        handleFcmNotification()
        completionHandler([.banner, .sound, .badge])
    }
    
    func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
        let userInfo = response.notification.request.content.userInfo
        print("Notification received with userInfo: \(userInfo)")
        
        handleFcmNotification()
        completionHandler()
    }
    
    private func handleFcmNotification() {
        if callStatus == "ACCEPTED" {
            DispatchQueue.main.async {
                      if let meetingId = CallingInfo.currentMeetingID {
                          NavigationState.shared.navigateToMeeting(meetingId: meetingId)
                      }
            }
        } else {
            DispatchQueue.main.async {
                NavigationState.shared.navigateToJoin()
            }
        }
    }
    
}

// MARK: - MessagingDelegate
extension PushNotificationManager: MessagingDelegate {
    func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) {
        self.fcmToken = fcmToken
        CallingInfo.fcmTokenOfDevice = fcmToken
        self.isFcmTokenAvailable = true
        
        self.isRegistering = true
        // Register user if both tokens are available
        if self.isDeviceTokenAvailable && self.isFcmTokenAvailable {
            guard let deviceToken = deviceToken,
                  let fcmToken = fcmToken else { return }
            registerUser(deviceToken: deviceToken, fcmToken: fcmToken)
        } else {
            DispatchQueue.main.asyncAfter(deadline: .now() + 10.0) { [weak self] in
                guard let self = self,
                      let deviceToken = deviceToken,
                      let fcmToken = fcmToken else { return }
                
                if self.isDeviceTokenAvailable && self.isFcmTokenAvailable {
                    self.registerUser(deviceToken: deviceToken, fcmToken: fcmToken)
                } else {
                    self.isRegistering = false
                }
            }
        }
    }
}

// MARK: - PKPushRegistryDelegate
extension PushNotificationManager: PKPushRegistryDelegate {
    func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) {
        let token = pushCredentials.token.map { String(format: "%02x", $0) }.joined()
        CallingInfo.deviceToken = token
        self.deviceToken = token
        self.isDeviceTokenAvailable = true
    }
    
    func pushRegistry(_ registry: PKPushRegistry, didInvalidatePushTokenFor type: PKPushType) {
        print("Push token invalidated for type: \(type)")
    }
    
    func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) {
        // Handle VoIP push notification
        handleVoIPPushPayload(payload)
        completion()
    }
    
    private func handleVoIPPushPayload(_ payload: PKPushPayload) {
        let payloadDict = payload.dictionaryPayload
        guard let callerInfo = payloadDict["callerInfo"] as? [String: Any],
              let callerName = callerInfo["name"] as? String,
              let callerID = callerInfo["callerID"] as? String,
              let videoSDKInfo = payloadDict["videoSDKInfo"] as? [String: Any],
              let meetingId = videoSDKInfo["meetingId"] as? String else {
            return
        }
        
        CallingInfo.otherUIDOf = callerID
        CallingInfo.currentMeetingID = meetingId
        
        CallKitManager.shared.reportIncomingCall(callerName: callerName, meetingId: meetingId)
    }
}

extension PushNotificationManager {
    // Register user in firebase database
    private func registerUser(deviceToken: String, fcmToken: String) {
        let name = UIDevice.current.name
        UserData.shared.registerUser(name: name, deviceToken: deviceToken, fcmToken: fcmToken) { success in
            if success {
                print("user stored")
                self.isRegistering = false
            }
        }
    }
}


By implementing these methods, your app can effectively manage incoming VoIP calls, providing a seamless experience for users even when the app is not active and it ensures that the FCM token is available for push notifications.


CallKit Setup

This section covers setting up CallKit to manage incoming and outgoing calls in your iOS app. CallKit integrates your app with the native iOS calling interface, providing a seamless VoIP experience.
Create a new Swift file, CallKitManager.swift, to manage the CallKit objects for observing, monitoring, and controlling calls.

CallKitManager.swift

import CallKit
import AVFoundation

class CallKitManager: NSObject, ObservableObject, CXProviderDelegate {
    
    static let shared = CallKitManager()

    private var provider: CXProvider
    private var callController: CXCallController
    @Published var callerIDs: [UUID: String] = [:]
    @Published var meetingIDs = [UUID: String]()
    
    override private init() {
        provider = CXProvider(configuration: CXProviderConfiguration(localizedName: "In CallKitSwiftUI"))
        callController = CXCallController()
        super.init()
        provider.setDelegate(self, queue: nil)
    }
    
    func reportIncomingCall(callerName: String, meetingId: String) {
        let uuid = UUID()
        let update = CXCallUpdate()
        update.remoteHandle = CXHandle(type: .generic, value: callerName)
        update.localizedCallerName = callerName
        
        callerIDs[uuid] = callerName
        meetingIDs[uuid] = meetingId
        
        provider.reportNewIncomingCall(with: uuid, update: update) { error in
            if let error = error {
                print("Error reporting incoming call: \(error)")
            }
        }
    }
    
    func endCall() {
        // End all active calls
        for (uuid, _) in callerIDs {
            let endCallAction = CXEndCallAction(call: uuid)
            let transaction = CXTransaction(action: endCallAction)
            
            callController.request(transaction) { error in
                if let error = error {
                    print("Error ending call: \(error.localizedDescription)")
                } else {
                    print("Call ended successfully")
                }
            }
        }
    }
    
    // CXProviderDelegate methods
    func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
        configureAudioSession()
        let update = CXCallUpdate()
        update.remoteHandle = action.handle
        update.localizedCallerName = action.handle.value
        provider.reportCall(with: action.callUUID, updated: update)
        action.fulfill()
    }
    
    func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
        if let callerID = callerIDs[action.callUUID] {
            print("Establishing call connection with caller ID: \(callerID)")
        }
        NotificationCenter.default.post(name: .callAnswered, object: nil)
        UserData.shared.UpdateCallAPI(callType: "ACCEPTED")
        action.fulfill()
    }
    
    func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
        callerIDs.removeValue(forKey: action.callUUID)
        let meetingViewController = MeetingViewController()
        meetingViewController.onMeetingLeft()
        action.fulfill()
        UserData.shared.UpdateCallAPI(callType: "REJECTED")
        DispatchQueue.main.async {
            NavigationState.shared.navigateToJoin()
        }
    }

    func providerDidReset(_ provider: CXProvider) {
        callerIDs.removeAll()
    }
}

Storing User Data

Create a new Swift file, ViewModel/UserData.swift, to store the user data.

UserData.swift

import SwiftUI
import Firebase
import FirebaseFirestore
import FirebaseMessaging


class UserData: ObservableObject {
    @Published var callerID: String = "" // Store the caller ID
    @Published public var otherUserID: String = ""
    static let shared = UserData()
    private let callerIDKey = "callerIDKey" // Key for UserDefaults
    
    let TOKEN_STRING = ""
    
    init() {
        self.callerID = UserDefaults.standard.string(forKey: callerIDKey) ?? ""
    }
    
    // MARK: Generating Unqiue CallerID
    func generateUniqueCallerID() -> String {
        let randomNumber = Int.random(in: 10000...99999)
        print("caller id", randomNumber)
        return String(randomNumber)
    }
    
    // MARK: Check and Register User if Required
    func registerUser(name: String, deviceToken: String, fcmToken: String, completion: @escaping (Bool) -> Void) {
        // First check if user exists with this FCM token
        Firestore.firestore().collection("users")
            .whereField("fcmToken", isEqualTo: fcmToken)
            .getDocuments { [weak self] snapshot, error in
                if let error = error {
                    print("Error checking for existing user: \(error.localizedDescription)")
                    completion(false)
                    return
                }
                
                // If documents exist with this FCM token
                if let snapshot = snapshot, !snapshot.isEmpty {
                    print("User already exists")
                    PushNotificationManager.shared.isRegistering = false
                    completion(false)
                    return
                }
                
                // If no existing user found, create new user
                let callerID = self?.generateUniqueCallerID() ?? ""
                
                DispatchQueue.main.async {
                    self?.callerID = callerID
                    UserDefaults.standard.set(callerID, forKey: self?.callerIDKey ?? "")
                }
                
                Firestore.firestore().collection("users").addDocument(data: [
                    "name": name,
                    "callerID": callerID,
                    "deviceToken": deviceToken,
                    "fcmToken": fcmToken
                ]) { [weak self] error in
                    if let error = error {
                        print("Error adding document: \(error.localizedDescription)")
                        completion(false)
                    } else {
                        print("Document added successfully")
                        self?.storeCallerID(callerID)
                        completion(true)
                    }
                }
            }
    }

    func storeCallerID(_ callerID: String) {
        // Save the caller ID to UserDefaults
        UserDefaults.standard.set(callerID, forKey: callerIDKey)
        self.callerID = callerID
    }
}

We use this function to store the user information in firebase database as shown below in PushNotificationManager.swift

PushNotificationManager.swift

extension PushNotificationManager: MessagingDelegate {
    func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) {
        self.fcmToken = fcmToken
        CallingInfo.fcmTokenOfDevice = fcmToken
        self.isFcmTokenAvailable = true
        
        self.isRegistering = true
        // Register user if both tokens are available
        if self.isDeviceTokenAvailable && self.isFcmTokenAvailable {
            guard let deviceToken = deviceToken,
                  let fcmToken = fcmToken else { return }
            registerUser(deviceToken: deviceToken, fcmToken: fcmToken)
        } else {
            DispatchQueue.main.asyncAfter(deadline: .now() + 10.0) { [weak self] in
                guard let self = self,
                      let deviceToken = deviceToken,
                      let fcmToken = fcmToken else { return }
                
                if self.isDeviceTokenAvailable && self.isFcmTokenAvailable {
                    self.registerUser(deviceToken: deviceToken, fcmToken: fcmToken)
                } else {
                    self.isRegistering = false
                }
            }
        }
    }

    private func registerUser(deviceToken: String, fcmToken: String) {
        let name = UIDevice.current.name
        UserData.shared.registerUser(name: name, deviceToken: deviceToken, fcmToken: fcmToken) { success in
            if success {
                print("user stored")
                self.isRegistering = false
            }
        }
    }
}

Designing the App Interface with SwiftUI

We'll start by creating three SwiftUI views: JoinView, CallingView, and MeetingView. We will also create a NavigationState.swift to manage the navigation between these views.

├── Views
│   ├── CallKitSwiftUIApp.swift // Default
│   ├── JoinView.swift
│   └── CallingView.swift
│   └── NavigationState.swift

This file manages the navigation between the views.

import SwiftUI

enum AppScreen: Hashable {
    case join
    case calling(userName: String, userNumber: String)
    case meeting(meetingId: String)
}

class NavigationState: ObservableObject {
    static let shared = NavigationState()
    
    @Published var path = NavigationPath()
    @Published var currentScreen: AppScreen = .join
    
    func navigateToCall(userName: String, userNumber: String) {
        currentScreen = .calling(userName: userName, userNumber: userNumber)
        path.append(AppScreen.calling(userName: userName, userNumber: userNumber))
    }
    
    func navigateToMeeting(meetingId: String) {
        currentScreen = .meeting(meetingId: meetingId)
        path.append(AppScreen.meeting(meetingId: meetingId))
    }
    
    func navigateToJoin() {
        path.removeLast(path.count)
        currentScreen = .join
    }
}

JoinView.swift

The Views/JoinView.swift is the initial screen users see when they open the app. It displays the user's Caller ID, allows them to enter another user's ID to initiate a call, and manages navigation based on call status.

import SwiftUI
import Firebase
import FirebaseFirestore

struct JoinView: View {
    
    @EnvironmentObject private var userData: UserData
    @EnvironmentObject private var callKitManager: CallKitManager
    @StateObject private var pushNotificationManager = PushNotificationManager.shared
    @EnvironmentObject private var navigationState: NavigationState
    
    @State public var otherUserID: String = ""
    @State private var userName: String = ""
    @State private var userNumber: String = ""
    
    var body: some View {
        NavigationView {
            ZStack {
                VStack(spacing: 30) {
                    Spacer()
                    
                    VStack(alignment: .leading, spacing: 10) {
                        Text("Your Caller ID")
                            .font(.headline)
                            .foregroundColor(.white)
                        
                        HStack(spacing: 10) {
                            Text(userData.callerID)
                                .font(.title)
                                .fontWeight(.bold)
                                .foregroundColor(.white)
                            
                            Image(systemName: "lock.fill")
                                .foregroundColor(.white)
                        }
                    }
                    .padding()
                    .background(Color(red: 0.1, green: 0.1, blue: 0.1))
                    .cornerRadius(12)
                    
                    Spacer(minLength: 2)
                    
                    VStack(alignment: .leading, spacing: 10) {
                        Text("Enter call ID of another user")
                            .font(.headline)
                            .foregroundColor(.white)
                        
                        TextField("Enter ID", text: $otherUserID)
                            .foregroundColor(.black)
                            .textFieldStyle(RoundedBorderTextFieldStyle())
                            .padding(.horizontal)
                    }
                    .padding()
                    .background(Color(red: 0.1, green: 0.1, blue: 0.1))
                    .cornerRadius(12)
                    
                    Spacer(minLength: 2)
                    
                    Button(action: {
                        // initiate call
                        userData.initiateCall(otherUserID: otherUserID) { callerInfo, calleeInfo, videoSDKInfo in
                            print("Initiating call to \(calleeInfo?.name ?? "Unknown")")
                            self.userName = calleeInfo?.name ?? "Unknown"
                            self.userNumber = calleeInfo?.callerID ?? "Unknown"
                            navigationState.navigateToCall(userName: self.userName, userNumber: self.userNumber)
                        }
                    }) {
                        HStack {
                            Text("Start Call")
                            Image(systemName: "phone.circle.fill")
                                .imageScale(.large)
                        }
                    }
                    .buttonStyle(.borderedProminent)
                    .padding(.trailing)
                    
                    Spacer()
                    
                }
                .padding()
                .frame(maxWidth: .infinity, maxHeight: .infinity)
                .background(Color(red: 0.05, green: 0.05, blue: 0.05))
                .edgesIgnoringSafeArea(.all)
                
                if pushNotificationManager.isRegistering {
                    ProgressView()
                        .progressViewStyle(CircularProgressViewStyle(tint: .white))
                        .scaleEffect(1.5)
                        .frame(maxWidth: .infinity, maxHeight: .infinity)
                        .background(Color.black.opacity(0.4))
                }
            }
            .onAppear {
                userData.fetchCallerID()
                NotificationCenter.default.addObserver(forName: .callAnswered, object: nil, queue: .main) { _ in
                    if let meetingId = CallingInfo.currentMeetingID {
                        navigationState.navigateToMeeting(meetingId: meetingId)
                    }
                }
            }
        }
    }
}

Snapshot of JoinView

Video SDK Image

CallingView.swift

import SwiftUI

struct CallingView: View {
    var userNumber: String
    var userName: String

    @EnvironmentObject private var navigationState: NavigationState

    var body: some View {
        ZStack {
            Color.black.edgesIgnoringSafeArea(.all)

            VStack(spacing: 30) {
                HStack(alignment: .center) {
                    Image(systemName: "person.circle.fill")
                        .resizable()
                        .frame(width: 100, height: 100)
                        .foregroundColor(.gray)

                    VStack(alignment: .leading, spacing: 5) {
                        Text(userName)
                            .font(.largeTitle)
                            .foregroundColor(.white)

                        Text(userNumber)
                            .font(.title)
                            .foregroundColor(.white)
                    }
                }

                Spacer()

                Text("Calling...")
                    .font(.title2)
                    .foregroundColor(.gray)

                Spacer()

                Button(action: {
                    navigationState.navigateToJoin()
                }) {
                    Image(systemName: "phone.down.fill")
                        .font(.system(size: 24))
                        .foregroundColor(.white)
                        .padding()
                        .background(Color.red)
                        .clipShape(Circle())
                }
                .padding(.bottom, 50)
            }
            .padding(.horizontal, 30)
        }
    }
}

Snapshot of Calling View

Video SDK Image

MeetingView.swift

├── Views
│   ├── MeetingView.swift

The Views/MeetingView.swift serves as the main meeting screen with controls for the local participant. We will integrate the [VideoSDK](https://www.videosdk.live/) here for Audio and Video Calling.

import SwiftUI
import VideoSDKRTC
import WebRTC

struct MeetingView: View{
    
    @ObservedObject var meetingViewController = MeetingViewController()
    
    // Variables for keeping the state of various controls
    @State var meetingId: String?
    @State var userName: String? = "Demo"
    @State var isUnMute: Bool = true
    @State var camEnabled: Bool = true
    @State var isScreenShare: Bool = false
    
    var userData = UserData()
    
    var body: some View {
        
        VStack {
            if meetingViewController.participants.count == 0 {
                Text("Meeting Initializing")
            } else {
                VStack {
                    VStack(spacing: 20) {
                        Text("Meeting ID: \(CallingInfo.currentMeetingID!)")
                            .padding(.vertical)
                        
                        List {
                            ForEach(meetingViewController.participants.indices, id: \.self) { index in
                                Text("Participant Name: \(meetingViewController.participants[index].displayName)")
                                ZStack {
                                    ParticipantView(track: meetingViewController.participants[index].streams.first(where: { $1.kind == .state(value: .video) })?.value.track as? RTCVideoTrack).frame(height: 250)
                                    if meetingViewController.participants[index].streams.first(where: { $1.kind == .state(value: .video) }) == nil {
                                        Color.white.opacity(1.0).frame(width: UIScreen.main.bounds.width, height: 250)
                                        Text("No media")
                                    }
                                }
                            }
                        }
                    }
                    
                    VStack {
                        HStack(spacing: 15) {
                            // mic button
                            Button {
                                if isUnMute {
                                    isUnMute = false
                                    meetingViewController.meeting?.muteMic()
                                }
                                else {
                                    isUnMute = true
                                    meetingViewController.meeting?.unmuteMic()
                                }
                            } label: {
                                Text("Toggle Mic")
                                    .foregroundStyle(Color.white)
                                    .font(.caption)
                                    .padding()
                                    .background(
                                        RoundedRectangle(cornerRadius: 25)
                                            .fill(Color.blue))
                            }
                            // camera button
                            Button {
                                if camEnabled {
                                    camEnabled = false
                                    meetingViewController.meeting?.disableWebcam()
                                }
                                else {
                                    camEnabled = true
                                    meetingViewController.meeting?.enableWebcam()
                                }
                            } label: {
                                Text("Toggle WebCam")
                                    .foregroundStyle(Color.white)
                                    .font(.caption)
                                    .padding()
                                    .background(
                                        RoundedRectangle(cornerRadius: 25)
                                            .fill(Color.blue))
                            }
                        }
                        HStack{
                            // end meeting button
                            Button {
                                meetingViewController.meeting?.end()
                                NavigationState.shared.navigateToJoin()
                                CallKitManager.shared.endCall()
                                
                            } label: {
                                Text("End Call")
                                    .foregroundStyle(Color.white)
                                    .font(.caption)
                                    .padding()
                                    .background(
                                        RoundedRectangle(cornerRadius: 25)
                                            .fill(Color.red))
                            }
                        }
                        .padding(.bottom)
                    }
                }
            }
        }.onAppear()
        {
            /// MARK :- configuring the videoSDK
            VideoSDK.config(token: meetingViewController.token)
            if meetingId?.isEmpty == false {
                // join an existing meeting with provided meeting Id
                meetingViewController.joinMeeting(meetingId: meetingId!, userName: userName!)
            }
            else {
            }
        }
    }    
}

/// VideoView for participant's video
class VideoView: UIView {
    
    var videoView: RTCMTLVideoView = {
        let view = RTCMTLVideoView()
        view.videoContentMode = .scaleAspectFill
        view.backgroundColor = UIColor.black
        view.clipsToBounds = true
        view.frame = CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: 250)
        
        return view
    }()
    
    init(track: RTCVideoTrack?) {
        super.init(frame: CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: 250))
        backgroundColor = .clear
        DispatchQueue.main.async {
            self.addSubview(self.videoView)
            self.bringSubviewToFront(self.videoView)
            track?.add(self.videoView)
        }
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

/// ParticipantView for showing and hiding VideoView
struct ParticipantView: UIViewRepresentable {
    var track: RTCVideoTrack?
    
    func makeUIView(context: Context) -> VideoView {
        let view = VideoView(track: track)
        view.frame = CGRect(x: 0, y: 0, width: 250, height: 250)
        return view
    }
    
    func updateUIView(_ uiView: VideoView, context: Context) {
        if track != nil {
            track?.add(uiView.videoView)
        } else {
            track?.remove(uiView.videoView)
        }
    }
}

We'll use the MeetingViewController to manage the meeting.

MeetingViewController.swift

├── ViewModel
│   ├── MeetingViewController.swift
import Foundation
import VideoSDKRTC
import WebRTC

class MeetingViewController: ObservableObject {
    
    var token = "YOUR_TOKEN_HERE"
    var meetingId: String = ""
    var name: String = ""
    
    @Published var meeting: Meeting? = nil
    @Published var localParticipantView: VideoView? = nil
    @Published var videoTrack: RTCVideoTrack?
    @Published var participants: [Participant] = []
    @Published var meetingID: String = ""
    
    func initializeMeeting(meetingId: String, userName: String) {
        
        meeting = VideoSDK.initMeeting(
            meetingId: CallingInfo.currentMeetingID!,
            participantName: "iPhone",
            micEnabled: true,
            webcamEnabled: true
        )
        
        meeting?.addEventListener(self)
        meeting?.join()
    }
}

extension MeetingViewController: MeetingEventListener {
    
    func onMeetingJoined() {
        
        guard let localParticipant = self.meeting?.localParticipant else { return }
        
        // add to list
        participants.append(localParticipant)
        
        // add event listener
        localParticipant.addEventListener(self)
        
        localParticipant.setQuality(.high)
    }
    
    func onParticipantJoined(_ participant: Participant) {
        
        participants.append(participant)
        
        // add listener
        participant.addEventListener(self)
        
        participant.setQuality(.high)
    }
    
    func onParticipantLeft(_ participant: Participant) {
        participants = participants.filter({ $0.id != participant.id })
    }
    
    func onMeetingLeft() {
        meeting?.localParticipant.removeEventListener(self)
        meeting?.removeEventListener(self)
        NavigationState.shared.navigateToJoin()
        CallKitManager.shared.endCall()
    }
    
    func onMeetingStateChanged(meetingState: MeetingState) {
        switch meetingState {
            
        case .CLOSED:
            participants.removeAll()
            
        default:
            print("")
        }
    }
}

extension MeetingViewController: ParticipantEventListener {
    func onStreamEnabled(_ stream: MediaStream, forParticipant participant: Participant) {
        
        if participant.isLocal {
            if let track = stream.track as? RTCVideoTrack {
                DispatchQueue.main.async {
                    self.videoTrack = track
                }
            }
        } else {
            if let track = stream.track as? RTCVideoTrack {
                DispatchQueue.main.async {
                    self.videoTrack = track
                }
            }
        }
    }
    
    func onStreamDisabled(_ stream: MediaStream, forParticipant participant: Participant) {
        
        if participant.isLocal {
            if let _ = stream.track as? RTCVideoTrack {
                DispatchQueue.main.async {
                    self.videoTrack = nil
                }
            }
        } else {
            self.videoTrack = nil
        }
    }
}

extension MeetingViewController {
    
    // initialise a meeting with give meeting id (either new or existing)
    func joinMeeting(meetingId: String, userName: String) {
        
        if !token.isEmpty {
            // use provided token for the meeting
            self.meetingID = meetingId
            self.initializeMeeting(meetingId: meetingId, userName: userName)
        }
        else {
            print("Auth token required")
        }
    }
}

With the basic UI in place, you can now focus on implementing the app's functionality, including method execution and API interactions.


Integrating the call initiation API on the Join screen.

Workflow for Call initiation

  1. User Action: User enters recipient's ID and taps "Start Call".

  2. Data Fetching: Retrieve caller and callee information from local storage or backend.

  3. Meeting Creation: Initiate a meeting on the video conferencing platform.

  4. Call Request: Send a call request to the recipient, including meeting details.

  5. UI Update: Display a "Calling..." screen.

InitiateCallInfo.swift

Before building the UI or making API calls, we need to create the Model/InitiateCallInfo.swift file. This file will contain the structures that hold call-related information.

Content:

  • CallerInfo and CalleeInfo structs contain details about the participants of the call, like IDs, names, and tokens.
  • VideoSDKInfo struct holds information required by the video SDK for the call session.
  • CallRequest struct combines all the above information into a single payload to initiate the call.
import Foundation

// USER A: The caller initiating the call
struct CallerInfo: Codable {
    let id: String
    let name: String
    let callerID: String
    let deviceToken: String
    let fcmToken: String
}

// USER B: The callee receiving the call
struct CalleeInfo: Codable {
    let id: String
    let name: String
    let callerID: String
    let deviceToken: String
    let fcmToken: String
}

// Meeting Info Can Be Static
struct VideoSDKInfo: Codable {
    var meetingId: String = MeetingManager.shared.currentMeetingID ?? "null"
}

// Combines all three and sends the information to the server
struct CallRequest: Codable {
    let callerInfo: CallerInfo
    let calleeInfo: CalleeInfo
    let videoSDKInfo: VideoSDKInfo
}

UserData.swift

This file contains the logic for fetching user data, creating meetings, and initiating calls.
Content:

  • UserData class manages the user data, such as caller ID and tokens.
  • It includes functions to fetch `caller` and `callee` info, create a VideoSDK meeting, and initiate a call.
  • The `initiateCall` function combines all the gathered information and sends it to the server.
import SwiftUI
import Firebase

class UserData: ObservableObject {
    @Published var callerID: String = "" // Store the caller ID
    @Published public var otherUserID: String = ""
    static let shared = UserData()
    private let callerIDKey = "callerIDKey" // Key for UserDefaults
    private let TOKEN_STRING = "VideoSDK Token" // Token for Video SDK, you will get from https://app.videosdk.live/api-keys

    init() {
        self.callerID = UserDefaults.standard.string(forKey: callerIDKey) ?? ""
    }

    // MARK: - Fetch CallerID From Defaults

    /// Retrieves the caller ID from UserDefaults
    /// - Returns: The stored caller ID or nil if not found
  func fetchCallerID() -> String? {
        // Retrieve the caller ID from UserDefaults
        if callerID.isEmpty {
            return UserDefaults.standard.string(forKey: callerIDKey)
        }
        return callerID
    }

    // MARK: - Fetch Caller Info

    /// Fetches caller information from Firestore
    /// - Parameter completion: Closure called with the fetched CallerInfo or nil if not found
    func fetchCallerInfo(completion: @escaping (CallerInfo?) -> Void) {
        guard let callerIDDevice = UserDefaults.standard.string(forKey: callerIDKey) else {
            completion(nil)
            return
        }

        Firestore.firestore().collection("users")
            .whereField("callerID", isEqualTo: callerIDDevice)
            .getDocuments { [weak self] snapshot, error in
                self?.handleFirestoreResponse(snapshot: snapshot, error: error, completion: completion)
            }
    }

    // MARK: - Fetch Callee Info

    /// Fetches callee information from Firestore
    /// - Parameters:
    ///   - callerID: The ID of the callee
    ///   - completion: Closure called with the fetched CalleeInfo or nil if not found
    func fetchCalleeInfo(callerID: String, completion: @escaping (CalleeInfo?) -> Void) {
        Firestore.firestore().collection("users")
            .whereField("callerID", isEqualTo: callerID)
            .getDocuments { [weak self] snapshot, error in
                self?.handleFirestoreResponse(snapshot: snapshot, error: error, completion: completion)
            }
    }

    // MARK: - Meeting ID Generation

    /// Creates a new meeting using the Video SDK API
    /// - Parameters:
    ///   - token: The authentication token
    ///   - completion: Closure called with the result containing the room ID or an error
    func createMeeting(token: String, completion: @escaping (Result<String, Error>) -> Void) {
        guard let url = URL(string: "https://api.videosdk.live/v2/rooms") else {
            completion(.failure(NSError(domain: "Invalid URL", code: -1, userInfo: nil)))
            return
        }

        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.addValue(token, forHTTPHeaderField: "Authorization")

        URLSession.shared.dataTask(with: request) { data, response, error in
            DispatchQueue.main.async {
                if let error = error {
                    completion(.failure(error))
                    return
                }

                guard let data = data else {
                    completion(.failure(NSError(domain: "No data", code: 500, userInfo: nil)))
                    return
                }

                do {
                    let dataArray = try JSONDecoder().decode(RoomsStruct.self, from: data)
                    let roomID = dataArray.roomID ?? ""
                    MeetingManager.shared.currentMeetingID = roomID
                    completion(.success(roomID))
                } catch {
                    completion(.failure(error))
                }
            }
        }.resume()
    }

    // MARK: - Initiate Call

    /// Initiates a call by fetching caller and callee info, creating a meeting, and sending a call request
    /// - Parameters:
    ///   - otherUserID: The ID of the user being called
    ///   - completion: Closure called with the caller info, callee info, and Video SDK info
    func initiateCall(otherUserID: String, completion: @escaping (CallerInfo?, CalleeInfo?, VideoSDKInfo?) -> Void) {
        fetchCallerInfo { [weak self] callerInfo in
            guard let self = self, let callerInfo = callerInfo else {
                print("Error fetching caller info")
                completion(nil, nil, nil)
                return
            }

            self.fetchCalleeInfo(callerID: otherUserID) { calleeInfo in
                guard let calleeInfo = calleeInfo else {
                    print("Error fetching callee info")
                    completion(nil, nil, nil)
                    return
                }

                self.createMeeting(token: self.TOKEN_STRING) { result in
                    switch result {
                    case .success(let roomID):
                        print("Meeting created successfully with Room ID: \(roomID)")
                        DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
                            let videoSDKInfo = VideoSDKInfo()
                            completion(callerInfo, calleeInfo, videoSDKInfo)

                            let callRequest = CallRequest(callerInfo: callerInfo, calleeInfo: calleeInfo, videoSDKInfo: videoSDKInfo)
                            self.sendCallRequest(callRequest) { result in
                                switch result {
                                case .success(let data):
                                    print("Call request successful: \(String(describing: data))")
                                case .failure(let error):
                                    print("Error sending call request: \(error)")
                                }
                            }
                        }
                    case .failure(let error):
                        print("Error creating meeting: \(error)")
                        completion(nil, nil, nil)
                    }
                }
            }
        }
    }

    // MARK: - API Calls

    /// Sends a call request to the server
    /// - Parameters:
    ///   - request: The CallRequest object containing call details
    ///   - completion: Closure called with the result of the API call
    public func sendCallRequest(_ request: CallRequest, completion: @escaping (Result<Data?, Error>) -> Void) {
        guard let url = URL(string: "YOURSERVERURL") else {
            completion(.failure(NSError(domain: "Invalid URL", code: -1, userInfo: nil)))
            return
        }

        var urlRequest = URLRequest(url: url)
        urlRequest.httpMethod = "POST"
        urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")

        do {
            let jsonData = try JSONEncoder().encode(request)
            urlRequest.httpBody = jsonData

            URLSession.shared.dataTask(with: urlRequest) { data, response, error in
                if let error = error {
                    completion(.failure(error))
                } else if let response = response as? HTTPURLResponse, response.statusCode == 200 {
                    completion(.success(data))
                } else {
                    let error = NSError(domain: "API Error", code: (response as? HTTPURLResponse)?.statusCode ?? -1, userInfo: nil)
                    completion(.failure(error))
                }
            }.resume()
        } catch {
            completion(.failure(error))
        }
    }

    // MARK: - Helper Methods

    /// Handles the response from Firestore queries
    private func handleFirestoreResponse<T: Codable>(snapshot: QuerySnapshot?, error: Error?, completion: @escaping (T?) -> Void) {
        if let error = error {
            print("Error fetching documents: \(error.localizedDescription)")
            completion(nil)
            return
        }

        guard let snapshot = snapshot, !snapshot.isEmpty, let document = snapshot.documents.first else {
            print("No documents found for the given caller ID")
            completion(nil)
            return
        }

        let data = document.data()
        let name = data["name"] as? String ?? ""
        let deviceToken = data["deviceToken"] as? String ?? ""
        let callerID = data["callerID"] as? String ?? ""
        let fcmToken = data["fcmToken"] as? String ?? ""

        let info = T.self == CallerInfo.self ?
            CallerInfo(id: document.documentID, name: name, callerID: callerID, deviceToken: deviceToken, fcmToken: fcmToken) as? T :
            CalleeInfo(id: document.documentID, name: name, callerID: callerID, deviceToken: deviceToken, fcmToken: fcmToken) as? T

        completion(info)
    }
}

We'll invoke initiateCall method on JoinView Start Call button action of the JoinView.swift file.

Server Side API implementation

server.js

const express = require("express");
const cors = require("cors");
const admin = require("firebase-admin");
const morgan = require("morgan");
var Key = "YOUR_APNS_AUTH_KEY_PATH"; // TODO: Change File Name
var apn = require("apn");
const { v4: uuidv4 } = require("uuid");
const serviceAccount = require("./serviceAccountKey.json"); // Replace with the path to your service account key

const app = express();
const port = 3000;

app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(morgan("dev"));

admin.initializeApp({
  credential: admin.credential.cert(serviceAccount),
});

app.post("/initiate-call", (req, res) => {
  const { calleeInfo, callerInfo, videoSDKInfo } = req.body;

  let deviceToken = calleeInfo.deviceToken;

  var options = {
    token: {
      key: Key,
      keyId: "KEY_ID",
      teamId: "TEAM_ID",
    },
    production: false,
  };

  var apnProvider = new apn.Provider(options);

  var note = new apn.Notification();

  note.expiry = Math.floor(Date.now() / 1000) + 3600; // Expires 1 hour from now.

  note.badge = 1;
  note.sound = "ping.aiff";
  note.alert = "You have a new message";
  note.rawPayload = {
    callerName: callerInfo.name,
    aps: {
      "content-available": 1,
    },
    handle: callerInfo.name,
    callerInfo,
    videoSDKInfo,
    type: "CALL_INITIATED",
    uuid: uuidv4(),
  };
  note.pushType = "voip";
  note.topic = "com.videosdk.live.CallKitSwiftUI.voip";
  apnProvider.send(note, deviceToken).then((result) => {
    if (result.failed && result.failed.length > 0) {
      console.log("RESULT", result.failed[0].response);
      res.status(400).send(result.failed[0].response);
    } else {
      res.status(200).send(result);
    }
  });
});

app.post("/update-call", (req, res) => {
  const { callerInfo, type } = req.body;
  const { name, fcmToken } = callerInfo;

  const message = {
    notification: {
      title: name,
      body: "Hello VideoSDK",
    },
    data: {
      type,
    },
    token: fcmToken,
    apns: {
      payload: {
        aps: {
          sound: "default",
          badge: 1,
        },
      },
    },
  };

  admin
    .messaging()
    .send(message)
    .then((response) => {
      res.status(200).send(response);
      console.log("Successfully sent message:", response);
    })
    .catch((error) => {
      res.status(400).send(error);
      console.log("Error sending message:", error);
    });
});

// Start the server
app.listen(port, () => {
  console.log(`Server running at http://localhost:${port}/`);
});
  • Generate and download a new service account key from your firebase console and replace the serviceAccountKey.json file with the new service account key.
  • Use the Auth key that you have generated from your Apple Developer Account.
  • Replace KEY_ID and TEAM_ID with your key ID and team ID that was generated from your Apple Developer Account.

Accept/Reject Incoming Call

Once the call is initiated and the calling view is displayed, we need to implement logic to manage call acceptance or rejection from the remote user. Depending on their decision, the application should navigate to the common meeting screen or terminate the call.
We must now change the some CallKit functionality and adjust navigation accordingly based on call status changes and before that we have to define UpdateCallAPI in UserData.swift

//MARK:  API Calling For Update Call
    public func UpdateCallAPI(callType: String) {
        let storedCallerID = OtherUserIDManager.SharedOtherUID.OtherUIDOf
        fetchCalleeInfo(callerID: storedCallerID ?? "null") { calleeInfo in
            guard let calleeInfo = calleeInfo else {
                print("No callee info found")
                return
            }
            guard let url = URL(string: "http://172.20.10.3:3000/update-call") else {
                print("Invalid URL")
                return
            }
            var request = URLRequest(url: url)
            request.httpMethod = "POST"
            request.setValue("application/json", forHTTPHeaderField: "Content-Type")

            let callerInfoDict: [String: Any] = [
                "id": calleeInfo.id,
                "name": calleeInfo.name,
                "callerID": calleeInfo.callerID,
                "deviceToken": calleeInfo.deviceToken,
                "fcmToken": calleeInfo.fcmToken
            ]
            let body: [String: Any] = ["callerInfo": callerInfoDict, "type": callType]
            do {
                request.httpBody = try JSONSerialization.data(withJSONObject: body, options: [])
            } catch {
                print("Error encoding request body: \(error)")
                return
            }
            URLSession.shared.dataTask(with: request) { data, response, error in
                if let error = error {
                    print("API call error: \(error)")
                    return
                }
                guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
                    print("Invalid response")
                    return
                }

                if let data = data {
                    print("Response data: \(String(data: data, encoding: .utf8) ?? "")")
                }
            }.resume()
        }
    }

We call UpdateCallAPI method on CallKitManager.swift delegate method.

CallKitManager.swift

    func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
        configureAudioSession()
        if let callerID = callerIDs[action.callUUID] {
            print("Establishing call connection with caller ID: \(callerID)")
        }
        NotificationCenter.default.post(name: .callAnswered, object: nil)
        // Update Call API
        UserData.shared.UpdateCallAPI(callType: "ACCEPTED")
        action.fulfill()
    }
    
    func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
        callerIDs.removeValue(forKey: action.callUUID)
        let meetingViewController = MeetingViewController()
        meetingViewController.onMeetingLeft()
        action.fulfill()
        // Update Call API
        UserData.shared.UpdateCallAPI(callType: "REJECTED")
        DispatchQueue.main.async {
            NavigationState.shared.navigateToJoin()
        }
    }
  • When a user accepts the call, the NotificationManager will send a notification to the caller’s device. The CallingView observes the NotificationManager. When the `NotificationManager` detects the notification indicating that the call has been accepted, it triggers navigateToMeeting method of NavigationState which automatically transitions to the `MeetingView`, where the actual video call takes place.
  • If a user rejects the call, it triggers navigateToJoin method of NavigationState which automatically transitions to the JoinView.
  • When meeting is ended, it triggers navigateToJoin method of NavigationState which automatically transitions to the JoinView.
  • Ending the meeting would also call endCall method of CallKitManager to end the call and navigate to JoinView.

With these, iOS devices should now be able to receive the call and join the video call. This is what the incoming call on an iOS device looks like.

Video SDK Image

Here is the video showing the incoming call and initiating a video session

0:00
/0:45