Creating a native-like call experience on iOS using Flutter can be challenging—but with the right tools, it becomes seamless. In this guide, we’ll show you how to build a video calling feature for iOS that feels just like a regular phone call.

Using the flutter_callkit_incoming package, you’ll be able to trigger native iOS call screens, manage VoIP-style incoming call notifications, and handle user interactions like answering or rejecting a call—all without writing any Swift code

Explore the full source code to see how everything fits together.

Introduction to Flutter CallKit Incoming

The flutter_callkit_incoming package simplifies the integration of iOS CallKit into Flutter applications. This package allows developers to:

  • Present native iOS call screens.
  • Manage incoming call notifications.
  • Enhance the overall call experience.

It abstracts the complexities of implementing CallKit in Swift, enabling quick and efficient call handling for iOS users.

Overview of the Application in IOS

Initiating a Call:

  • The caller (e.g., Pavan) enters the recipient's unique ID (e.g., Jay) and presses the call button.
  • This action triggers a server request to initiate the call, which sends a Firebase Cloud Messaging (FCM) notification to the recipient's device through API, and a fake call is being triggered on the Receiver Caller Side.

Receiving the Call:

  • If the app is in the foreground: If the App is in the foreground no need to handle the notification, Firebase Messaging Package looks into it. Firebase Messaging Package provided by Firebase in Flutter helps one to manage the Foreground. 
  • If the app is in the background: When it comes to background Firebase provides you the feature using Firebase_background_handler on a method called onBackgroundMessage. Using that we can achieve the concept of handling the background message(Basically the message which is received when the app is closed/Minimised).

Handling Call Status:

  • Upon answering or rejecting the call, the recipient's action triggers a server request (update call). This request updates the call status and is communicated back to the caller (Pavan) to reflect the recipient's response.

This structured interaction between client, server, and platform-specific components ensures a smooth and intuitive experience for both the caller and the recipient, providing reliable video communication across devices.

Core Components of the App

The app integrates several core components to manage video calling and notifications seamlessly across iOS platforms:

flutter_callkit_incoming

  • Purpose: Provides a native call interface on iOS.
  • Function: Displays the incoming call UI and handles actions like answering or rejecting calls, leveraging iOS’s native CallKit functionality without requiring native Swift code.

Push Notifications

  • Purpose: Notifies the recipient about incoming calls.
  • Function: Uses Firebase Cloud Messaging (FCM) and Apple Push Notification Service (APNs) for iOS to send VoIP notifications, triggering the appropriate call UI even if the app is in the background or inactive.

Firebase Realtime Database

  • Purpose: Stores user tokens and caller IDs.
  • Function: Ensures secure and real-time communication between users, facilitating the initiation of calls and status updates.

VideoSDK

  • Purpose: Powers the video calling functionality.
  • Function: Enables real-time video and audio communication, providing high-quality video conferencing features for both Android and iOS users.

Node.js Server

  • Purpose: Acts as the backend service for call initiation and status updates.
  • Function: Sends push notifications via Firebase and APNs, and updates call statuses (e.g., accepted, rejected) between users.

These core components work together to ensure a smooth, feature-rich experience for both video calling and real-time notifications across devices and platforms.

Prerequisites

Before starting with the development of the video calling app, ensure that the following prerequisites are met:

  1. Flutter Development Environment:
  2. Firebase Project:
    • Create a Firebase project in the Firebase Console.
    • Set up Firebase Cloud Messaging (FCM) for push notifications.
  3. VideoSDK Account:
    • Sign up for a VideoSDK account at VideoSDK and obtain the required credentials (API keys and authentication tokens).
  4. Node.js Server:
    • Set up a Node.js server to manage API requests, handle call initiation, and send push notifications. This server will also interact with Firebase to send call-trigger notifications.

With these prerequisites in place, you'll be ready to begin implementing the video calling functionality, leveraging both Firebase and VideoSDK for a seamless cross-platform experience.

Connecting Firebase to Flutter Using FlutterFire CLI

To easily set up Firebase with your Flutter app, follow these steps:

Step 1: Create a Firebase Project

  1. Go to the Firebase Console.
  2. Create a new project by following the prompts.
Video SDK Image

Step 2: Select the Flutter Option

  • Once your project is created, navigate to the "Add app" section.
  • Choose the Flutter option to proceed.
Video SDK Image

Step 3: Install Firebase CLI

  • Use npm to globally install the Firebase CLI. Run the following command in your terminal:
npm install -g firebase-tools

Step 4: Login to Firebase

  • Log in to your Firebase account using the Firebase CLI by running:
firebase login
Video SDK Image
  • This will open a browser window prompting you to sign in with your Google account.

Step 5: Configure Firebase in Flutter Using FlutterFire CLI

  • Use the FlutterFire CLI to connect your Flutter project to Firebase
  • Select an existing Firebase project or create a new one.
  • Choose the platforms (e.g., Android, iOS) you want to integrate Firebase with.
  • This will add new dart file named as firebase_option.dart in your project .
Video SDK Image

Step 6: Add dependency in pubspec.yaml file

  • add firebase_core dependency in your pubspec.yaml file to resolve errors

iOS Setup: Enabling PushKit and CallKit

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. Follow the steps below:

Step 1: Request a Certificate Using Keychain

1. Open the Keychain Access application on your Mac.

2. Select Certificate Assistant -> Request a Certificate From a Certificate Authority.

Video SDK Image

3. Enter your email and common name, then click Continue.

Video SDK Image

4. Modify the certificate’s name and save it.

Video SDK Image

Step 2: Create an App ID in the Apple Developer Account

This process requires an active Apple Developer Program account. Follow these steps:

1. Log into your Apple Developer account.

2. Navigate to Certificates, Identifiers & Profiles and select Identifiers.

Video SDK Image

3. Click the + icon to add a new identifier.

Video SDK Image

4. Add a 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

Step 3: Create a New VoIP Services Certificate


1. Go to the Certificates section in your Apple Developer Program account.

2. Click to add a new certificate.

Video SDK Image

3. Select the VoIP Services Certificate and choose the App ID you created.

Video SDK Image

4. Use the private certificate generated in Keychain Access and click Continue.

Video SDK Image

5. Download the voip_services.cer file provided by your VoIP service provider.

Step 4: Convert .cer to .p12

  1. Double-click the voip_services.cer file to open it in Keychain Access.
  2. Locate the certificate titled "VoIP Services: YourProductName".
  3. Right-click on it and select Export to save it as a .p12 file.
  4. Create a strong password when prompted and save the file securely.

Step 5: Convert .p12 to .pem

  1. Open a terminal and navigate to the directory where the .p12 file is saved.
  2. Enter the password created earlier.
  3. A new file named Certificates.pem will be created in the same directory.

Run the following command:

openssl pkcs12 -in YourFileName.p12 -out Certificates.pem -nodes -clcerts -legacy
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 notifications to the iOS device. To implement push notifications, you must upload an APN Auth Key. The following details about the app are needed when sending push notifications via an APN Auth Key:

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

Creating an APN Auth Key

  1. Visit the Apple Developer Member Center
Video SDK Image
  1. 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
  1. On the following page, add a Key Name, and select APNs.
Video SDK Image
  1. Click on the Register button.
Video SDK Image
  1. 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
  1. In your Firebase project, go to Settings and select the Cloud Messaging tab. Scroll down the iOS app configuration and click Upload under APNs Authentication Key
Video SDK Image
  1. 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
  1. Enable selected permission in Background Modes
Video SDK Image

Project Structure

Client
│
├── ios
│   └── Runner
│       ├── AppDelegate.swift
│       ├── Info.plist
│       └── GoogleService-Info.plist
│
├── lib
│   |
│   ├── meeting
│   |   ├── api_call.dart|   ├── join_screen.dart|   ├── meeting_controls.dart|   ├── meeting_screen.dart|   └── participant_tile.dart|
│   ├── firebase_options.dart
│   ├── home.dart
│   └── main.dart
│
├── .env
├── pubspec.yaml
└── README.md

Now go to the server side and setup our server

Step 1 : Create a New Project Directory

mkdir server
cd server 
npm init -y 

Step 2 : Install required dependencies

npm install express cors morgan firebase-admin uuid

Step 3 : Set Up Firebase 

Enable Realtime Database and set the rule true and Firebase Cloud messaging (FCM).

Video SDK Image

Step 4 :

Download the private key by navigating to Project Settings > Service Accounts, selecting Node.js, and then clicking Download.

Video SDK Image

Step 5 :

Place this File into your server side and your server side structure look like this.

Server
  ├── node_modules/
  ├── server.js
  ├── callkit-3ec73-firebase-adminsdk-ghfto-9d9fc7a362.json
  ├── package-lock.json
  └── package.json

Step 6 : now go to server.js we have to add several API in our server.js

  1. GET /: Returns a "Hello Coding" message to confirm the server is running.
  2. POST /register-device: Registers a device by storing its unique ID and FCM token in Firebase.
  3. POST /api/add: Stores call details (callerId, roomId, and calleeId) in memory.
  4. GET /api/getByCallee/:calleeId: Retrieves call details for a given callee ID from memory.
  5. POST /send-call-status: Sends a notification to the caller about the status of a call (e.g., accepted, rejected, ended).
  6. POST /send-notification: Sends a notification to a caller with details about an incoming call, including room ID and VideoSDK token.

Step 7 : Now we add below code to run the server at your ip 

const PORT = process.env.PORT || 9000; 
const LOCAL_IP = '10.0.0.161'; // Replace with your actual local IP address 
app.listen(PORT, LOCAL_IP, () => {
console.log(Server running on http://${LOCAL_IP}:${PORT});
 }); 

Step 8 :

we setup ngrok and using this below command we redirect our local ip to temporary public URL that can you share outside to your local network ngrok http http://10.0.0.161:9000

Video SDK Image

It looks like that we can use this https://8190-115-246-20-252.ngrok-free.app as our server url.

Refer the complete code of server,js here.

VideoSDK is a cutting-edge platform that enables seamless audio and video calling with low latency and robust performance. Start by signing up at VideoSDK, generate your API token from the dashboard, and integrate real-time calling into your app effortlessly!

Video SDK Image

Now add this token into your .env file

Video SDK Image

Now add all permission for iOS


For iOS we have to add all the permission in info.plist file

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>CADisableMinimumFrameDurationOnPhone</key>
	<true/>
	<key>CFBundleDevelopmentRegion</key>
	<string>$(DEVELOPMENT_LANGUAGE)</string>
	<key>CFBundleExecutable</key>
	<string>$(EXECUTABLE_NAME)</string>
	<key>CFBundleIdentifier</key>
	<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
	<key>CFBundleInfoDictionaryVersion</key>
	<string>6.0</string>
	<key>CFBundleName</key>
	<string>Flutter VideoSDK App</string>
	<key>CFBundlePackageType</key>
	<string>APPL</string>
	<key>CFBundleShortVersionString</key>
	<string>$(FLUTTER_BUILD_NAME)</string>
	<key>CFBundleSignature</key>
	<string>????</string>
	<key>CFBundleVersion</key>
	<string>$(FLUTTER_BUILD_NUMBER)</string>
	<key>LSRequiresIPhoneOS</key>
	<true/>
	<key>NSCameraUsageDescription</key>
	<string>$(PRODUCT_NAME) Camera Usage!</string>
	<key>NSMicrophoneUsageDescription</key>
	<string>$(PRODUCT_NAME) Microphone Usage!</string>
	<key>RTCAppGroupIdentifier</key>
	<string>group.com.example.broadcastScreen</string>
	<key>RTCScreenSharingExtension</key>
	<string>live.videosdk.flutter.example.FlutterBroadcast</string>
	<key>UIApplicationSupportsIndirectInputEvents</key>
	<true/>
	<key>UIBackgroundModes</key>
	<array>
		<string>voip</string>
		<string>fetch</string>
		<string>processing</string>
		<string>remote-notification</string>
	</array>
	<key>BGTaskSchedulerPermittedIdentifiers</key>
	<array>
		<string>dev.flutter.background.refresh</string>
	</array>
	<key>UILaunchStoryboardName</key>
	<string>LaunchScreen</string>
	<key>UIMainStoryboardFile</key>
	<string>Main</string>
	<key>UISupportedInterfaceOrientations</key>
	<array>
		<string>UIInterfaceOrientationPortrait</string>
		<string>UIInterfaceOrientationLandscapeLeft</string>
		<string>UIInterfaceOrientationLandscapeRight</string>
	</array>
	<key>UISupportedInterfaceOrientations~ipad</key>
	<array>
		<string>UIInterfaceOrientationPortrait</string>
		<string>UIInterfaceOrientationPortraitUpsideDown</string>
		<string>UIInterfaceOrientationLandscapeLeft</string>
		<string>UIInterfaceOrientationLandscapeRight</string>
	</array>
	<key>UIViewControllerBasedStatusBarAppearance</key>
	<false/>
	
</dict>
</plist>

Along with info.plist file also check the AppDelegate.swift file

import UIKit
import Flutter

@main
@objc class AppDelegate: FlutterAppDelegate {
  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    GeneratedPluginRegistrant.register(with: self)
      application.registerForRemoteNotifications()
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }
}

Okay, let's move forward with integrating the VideoSDK into our project. Please refer to the Quickstart documentation provided in the link and follow the initial steps to build the necessary steps till step 4 which is creating a paticipant_tile.dart screen.

Now, after adding the other files to the Meeting folder, lets create a meeting_screen.dart file through which our meeting will be created, rendered and managed.

import 'dart:convert';

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:videosdk/videosdk.dart';
import 'package:videosdk_flutter_example/home.dart';

import 'package:videosdk_flutter_example/meeting/meeting_controls.dart';
import './participant_tile.dart';
import 'package:http/http.dart' as http;

class MeetingScreen extends StatefulWidget {
  final String meetingId;
  final String token;
  final String url;
  final String callerId;
  String? source;
  MeetingScreen(
      {Key? key,
      required this.meetingId,
      required this.token,
      required this.callerId,
      required this.url,
      this.source})
      : super(key: key);

  @override
  State<MeetingScreen> createState() => _MeetingScreenState();
}

class _MeetingScreenState extends State<MeetingScreen> {
  late Room _room;
  var micEnabled = true;
  var camEnabled = true;

  Map<String, Participant> participants = {};

  @override
  void initState() {
    // create room
    if (widget.source == "true") {
      sendnotification(
          widget.url, widget.callerId, "Call Accepted", widget.meetingId);
    }
    _room = VideoSDK.createRoom(
        roomId: widget.meetingId,
        token: widget.token,
        displayName: "John Doe",
        micEnabled: micEnabled,
        camEnabled: camEnabled,
        defaultCameraIndex: kIsWeb
            ? 0
            : 1 // Index of MediaDevices will be used to set default camera
        );

    setMeetingEventListener();

    // Join room
    _room.join();

    super.initState();
  }

  Future<void> sendnotification(String api, callerId, status, roomId) async {
    await sendCallStatus(
        serverUrl: api, callerId: callerId, status: status, roomId: roomId);
  }

  @override
  void setState(fn) {
    if (mounted) {
      super.setState(fn);
    }
  }

  // listening to meeting events
  void setMeetingEventListener() {
    _room.on(Events.roomJoined, () {
      setState(() {
        participants.putIfAbsent(
            _room.localParticipant.id, () => _room.localParticipant);
      });
    });

    _room.on(
      Events.participantJoined,
      (Participant participant) {
        setState(
          () => participants.putIfAbsent(participant.id, () => participant),
        );
      },
    );

    _room.on(Events.participantLeft, (String participantId) {
      if (participants.containsKey(participantId)) {
        setState(
          () => participants.remove(participantId),
        );
      }
    });

    _room.on(Events.roomLeft, () {
      participants.clear();
      Navigator.pushAndRemoveUntil(
        context,
        MaterialPageRoute(
            builder: (context) => Home(
                  callerID: widget.callerId,
                )),
        (route) => false, // Removes all previous routes
      );
    });
  }

  // onbackButton pressed leave the room
  Future<bool> _onWillPop() async {
    _room.leave();
    return true;
  }

  Future<void> sendCallStatus({
    required String serverUrl,
    required String callerId,
    required String status,
    required String roomId,
  }) async {
    final url = Uri.parse('$serverUrl/send-call-status');
   
    try {
      // Request payload
      final body = jsonEncode({
        'callerId': callerId,
        'status': status,
        'roomId': roomId,
      });

      // Sending the POST request
      final response = await http.post(
        url,
        headers: {
          'Content-Type': 'application/json',
        },
        body: body,
      );

      // Handling the response
      if (response.statusCode == 200) {
        print("Notification sent successfully: ${response.body}");
      } else {
        print("Failed to send notification: ${response.statusCode}");
  
      }
    } catch (e) {
      print("Error sending call status: $e");
    }
  }

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    // ignore: deprecated_member_use
    return WillPopScope(
      onWillPop: () => _onWillPop(),
      child: Scaffold(
        appBar: AppBar(
          title: const Text('VideoSDK QuickStart'),
        ),
        body: Padding(
          padding: const EdgeInsets.all(8.0),
          child: Column(
            children: [
              Text(widget.meetingId),
              //render all participant
              Expanded(
                child: Padding(
                  padding: const EdgeInsets.all(8.0),
                  child: GridView.builder(
                    gridDelegate:
                        const SliverGridDelegateWithFixedCrossAxisCount(
                      crossAxisCount: 2,
                      crossAxisSpacing: 10,
                      mainAxisSpacing: 10,
                      mainAxisExtent: 300,
                    ),
                    itemBuilder: (context, index) {
                      return ParticipantTile(
                          key: Key(participants.values.elementAt(index).id),
                          participant: participants.values.elementAt(index));
                    },
                    itemCount: participants.length,
                  ),
                ),
              ),
              MeetingControls(
                micEnabled: micEnabled,
                camEnabled: camEnabled,
                onToggleMicButtonPressed: () {
                  setState(() {
                    micEnabled = !micEnabled;
                  });
                  micEnabled ? _room.unmuteMic() : _room.muteMic();
                },
                onToggleCameraButtonPressed: () {
                  setState(() {
                    camEnabled = !camEnabled;
                  });
                  camEnabled ? _room.enableCam() : _room.disableCam();
                },
                onLeaveButtonPressed: () {
                  _room.leave();
                },
              ),
            ],
          ),
        ),
      ),
      //home: JoinScreen(),
    );
  }
}

Great, we have successfully added our videosdk meeting screens. Lets deep dive into main.dart file and home.dart file.

main.dart

import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:videosdk_flutter_example/home.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await dotenv.load(fileName: ".env");
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      home: Home(),
    );
  }
}

After creating the main.dart file, now its time to create a home.dart file. In home.dart file we will be working on managing the notification and also the call triggering logic is present in this file.

Future<void> sendNotification({
    required String callerId,
    required String callerInfo,
    required String roomId,
    required String token,
  }) async {
    // Prepare the request payload

    final Map<String, dynamic> payload = {
      'callerId': callerId,
      'callerInfo': {'id': callerInfo},
      'videoSDKInfo': {'roomId': roomId, 'token': token},
    };

    try {
      // Send POST request to the API
      final response = await http.post(
        Uri.parse("${apiUrl!}/send-notification"),
        headers: {'Content-Type': 'application/json'},
        body: jsonEncode(payload),
      );

      // Handle the response from the API
      if (response.statusCode == 200) {
        print('Notification sent successfully');
        if (mounted) {
          ScaffoldMessenger.of(context).showSnackBar(
            const SnackBar(content: Text("Message sent successfully")),
          );
        }
        print(response.body);
      } else {
        print('Failed to send notification: ${response.body}');
      }
    } catch (e) {
      print('Error occurred while sending notification: $e');
    }
  }


Future<void> makeFakeCallInComing(String callerId) async {

  {
    _currentUuid = const Uuid().v4();

    final params = CallKitParams(
      id: _currentUuid,
      appName: 'VideoSdk',
      avatar: 'https://i.pravatar.cc/100',
      handle: "VideoSdk",
      type: 0,
      duration: 30000,
      textAccept: 'Accept',
      textDecline: 'Decline',
      missedCallNotification: const NotificationParams(
        showNotification: true,
        isShowCallback: true,
        subtitle: 'Missed call',
        callbackText: 'Call back',
      ),
      extra: <String, dynamic>{'userId': '1a2b3c4d'},
      headers: <String, dynamic>{'apiKey': 'Abc@123!', 'platform': 'flutter'},
      ios: const IOSParams(
        iconName: 'CallKitLogo',
        handleType: '',
        supportsVideo: true,
        maximumCallGroups: 2,
        maximumCallsPerCallGroup: 1,
        audioSessionMode: 'default',
        audioSessionActive: true,
        audioSessionPreferredSampleRate: 44100.0,
        audioSessionPreferredIOBufferDuration: 0.005,
        supportsDTMF: true,
        supportsHolding: true,
        supportsGrouping: false,
        supportsUngrouping: false,
        ringtonePath: 'system_ringtone_default',
      ),
    );
    await FlutterCallkitIncoming.showCallkitIncoming(params);
    Future.delayed(const Duration(seconds: 10), () async {
      await FlutterCallkitIncoming.endAllCalls();
      // _initializePhoneAccount();
    });
  }
}

To get entire code for home.dart file you can refer here.

Note: The above code is only applicable when app is in Foreground state or when app is open in Background, yet working on the case where app is totally terminated


App Reference
Video SDK Image
Video SDK Image

Conclusion

With this, we've successfully built the Flutter iOS video calling app using the Flutter_callkit_incoming package, VideoSDK, and Firebase, Node js. For additional features like chat messaging and screen sharing, feel free to refer to our documentation. If you encounter any issues with the implementation, don’t hesitate to reach out to us through our Discord community.