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
- 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.
- 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.
- Firebase & VoIP Notifications
- Purpose: Manages push notifications via Firebase and APNs. - Function: Triggers incoming call UI and manages call events.
- 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:
-
Development Environment:
- Xcode (latest version recommended) for iOS app development
- macOS computer to run Xcode
-
iOS Device:
- At least one physical iOS device for testing CallKit (CallKit features cannot be fully tested in simulators)
-
Server-side Requirements:
- Node.js v12 or later
- NPM v6 or later (typically included with Node.js)
-
Apple Developer Account:
- Required for provisioning profiles and push notifications
-
VideoSDK:
- Valid VideoSDK Token (obtainable from Dashboard > API-Key) Video Tutorial
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
-
Create a Firebase iOS App: Add your iOS app within the Firebase project.
-
Add
GoogleService-info.plist
: Download and include theGoogleService-info.plist
file in your project. -
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.
- Select Certificate Assistant -> Request a Certificate From a Certificate Authority.
- Choose your email, and common name, and click continue.
- Modify the certificate’s name and save it.
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.
- Select Identifiers under the section Certificates, Identifiers & Profiles.
- Click the plus (+) icon next to Identifiers and follow the steps.
- Add the description, specify your bundle ID, check PushKit under Capabilities, and click continue.
The image below shows the finished App ID.
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.
- Choose the Certificates category next to Identifiers and click the plus (+) to add a new one.
- 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.
- Select the private certificate you generated in one of the previous steps using Keychain Access and click continue.
- Now,Download the VoIP services certificate provided by your VoIP service provider. It will be saved as a .cer file named voip_services.cer.
- 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.
- 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.
-
Visit the Apple Developer Member Center
-
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.
-
On the following page, add a Key Name, and select APNs.
-
Click on the Register button.
-
You can download your auth key file from this page and upload this file to the Firebase dashboard without changing its name.
-
In your Firebase project, go to Settings and select the Cloud Messaging tab. Scroll down iOS app configurationand click Upload under APNs Authentication Key
-
Enter
Key ID
andTeam ID
. Key ID is in the file nameAuthKey\_{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.
-
Enable Push Notifications in Capabilities
-
Enable selected permission in Background Modes
Server Setup
Steps
- 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
- Initialize npm:
- Create a `package.json` file to manage project dependencies:
npm init -y
- This will create a `package.json` file with default settings.
- Install dependency
npm install express body-parser cors firebase-admin morgan node-fetch uuid https://github.com/node-apn/node-apn.git
- 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}/`);
});
- 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
NavigationState.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
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
CallingView.swift
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
MeetingView.swift
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 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
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
-
User Action: User enters recipient's ID and taps "Start Call".
-
Data Fetching: Retrieve caller and callee information from local storage or backend.
-
Meeting Creation: Initiate a meeting on the video conferencing platform.
-
Call Request: Send a call request to the recipient, including meeting details.
-
UI Update: Display a "Calling..." screen.
InitiateCallInfo.swift
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
andCalleeInfo
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
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
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
andTEAM_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. TheCallingView
observes theNotificationManager
. 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.
Here is the video showing the incoming call and initiating a video session