Skip to main content
Implement native VoIP calling that works when your app is in the background or killed. This guide shows how to integrate platform-specific push notification services with CometChat to display system call UI and provide a native calling experience.

Overview

VoIP calling differs from basic in-app ringing by leveraging platform-native call frameworks to:
  • Show incoming calls on lock screen with system UI
  • Handle calls when app is in background or killed
  • Integrate with Bluetooth, car systems, and wearables
  • Provide consistent call experience across devices

Prerequisites

Before implementing VoIP calling, ensure you have:
VoIP calling in Flutter requires native platform code alongside your Dart code. The push notification handling and system call UI integration must be implemented natively on each platform, then bridged to Flutter via method channels or platform-specific plugins.
This documentation builds on the Ringing functionality. Make sure you understand basic call signaling before implementing VoIP.

Architecture Overview

The VoIP implementation consists of platform-specific components working together with your Flutter app:
ComponentPlatformPurpose
FirebaseMessagingServiceAndroidReceives push notifications for incoming calls when app is in background
ConnectionServiceAndroidAndroid Telecom framework integration — manages call state with the system
PushKit / APNsiOSReceives VoIP push notifications to wake the app
CallKitiOSDisplays native iOS call UI on lock screen and in-app
Method ChannelFlutterBridges native call events to Dart code

Android: FCM Integration

On Android, incoming calls are delivered via Firebase Cloud Messaging (FCM). When a call notification arrives while the app is in the background, you use Android’s ConnectionService to show the system call UI.

Step 1: Add FCM Dependencies

Add Firebase Messaging to your Android build.gradle:
dependencies {
    implementation 'com.google.firebase:firebase-messaging:23.4.0'
}

Step 2: Create FirebaseMessagingService

In your Android native code (android/app/src/main/), create a service to handle incoming call push notifications:
class CallFirebaseMessagingService : FirebaseMessagingService() {

    override fun onMessageReceived(remoteMessage: RemoteMessage) {
        val data = remoteMessage.data

        // Check if this is a call notification
        if (data["type"] == "call") {
            handleIncomingCall(data)
        }
    }

    private fun handleIncomingCall(data: Map<String, String>) {
        val sessionId = data["sessionId"] ?: return
        val callerName = data["senderName"] ?: "Unknown"
        val callerUid = data["senderUid"] ?: return
        val callType = data["callType"] ?: "video"

        // If app is in foreground, let CometChat's CallListener handle it
        if (isAppInForeground()) return

        // App is in background/killed — show system call UI
        // Use ConnectionService or a high-priority notification
        showIncomingCallNotification(sessionId, callerName, callerUid, callType)
    }

    override fun onNewToken(token: String) {
        // Register the FCM token with CometChat
        // Bridge to Flutter via method channel or shared preferences
    }
}

Step 3: Register in AndroidManifest

<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <!-- VoIP permissions -->
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
    <uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
    <uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
    <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

    <application>
        <service
            android:name=".CallFirebaseMessagingService"
            android:exported="false">
            <intent-filter>
                <action android:name="com.google.firebase.MESSAGING_EVENT" />
            </intent-filter>
        </service>
    </application>
</manifest>
For the full Android ConnectionService implementation including PhoneAccount registration, CallConnection, and CallNotificationManager, refer to the Android VoIP Calling documentation. The native Android code is identical whether used in a native Android app or a Flutter app’s Android module.

iOS: APNs + CallKit Integration

On iOS, incoming VoIP calls are delivered via Apple Push Notification service (APNs) with VoIP push certificates. CallKit provides the native iOS call UI.

Step 1: Enable Capabilities

In Xcode, enable the following capabilities for your iOS target:
  • Push Notifications
  • Background Modes → Voice over IP, Remote notifications

Step 2: Configure PushKit and CallKit

In your iOS native code (ios/Runner/), set up PushKit to receive VoIP pushes and CallKit to display the call UI:
import PushKit
import CallKit
import Flutter

class CallKitManager: NSObject, PKPushRegistryDelegate, CXProviderDelegate {
    static let shared = CallKitManager()
    
    private let provider: CXProvider
    private let voipRegistry = PKPushRegistry(queue: .main)
    private var methodChannel: FlutterMethodChannel?
    
    override init() {
        let config = CXProviderConfiguration()
        config.supportsVideo = true
        config.maximumCallsPerGroup = 1
        config.supportedHandleTypes = [.generic]
        provider = CXProvider(configuration: config)
        
        super.init()
        provider.setDelegate(self, queue: nil)
        voipRegistry.delegate = self
        voipRegistry.desiredPushTypes = [.voIP]
    }
    
    func setMethodChannel(_ channel: FlutterMethodChannel) {
        methodChannel = channel
    }
    
    // MARK: - PKPushRegistryDelegate
    
    func pushRegistry(
        _ registry: PKPushRegistry,
        didUpdate pushCredentials: PKPushCredentials,
        for type: PKPushType
    ) {
        let token = pushCredentials.token
            .map { String(format: "%02x", $0) }
            .joined()
        // Register VoIP token with CometChat
        methodChannel?.invokeMethod("onVoIPToken", arguments: token)
    }
    
    func pushRegistry(
        _ registry: PKPushRegistry,
        didReceiveIncomingPushWith payload: PKPushPayload,
        for type: PKPushType,
        completion: @escaping () -> Void
    ) {
        guard type == .voIP else { completion(); return }
        
        let data = payload.dictionaryPayload
        let sessionId = data["sessionId"] as? String ?? ""
        let callerName = data["senderName"] as? String ?? "Unknown"
        let callType = data["callType"] as? String ?? "video"
        let hasVideo = callType == "video"
        
        // Report incoming call to CallKit
        let update = CXCallUpdate()
        update.remoteHandle = CXHandle(type: .generic, value: sessionId)
        update.localizedCallerName = callerName
        update.hasVideo = hasVideo
        
        let uuid = UUID()
        provider.reportNewIncomingCall(with: uuid, update: update) { error in
            if let error = error {
                print("Failed to report call: \(error)")
            }
            completion()
        }
    }
    
    // MARK: - CXProviderDelegate
    
    func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
        // User tapped Accept — bridge to Flutter
        methodChannel?.invokeMethod("onCallAccepted", arguments: nil)
        action.fulfill()
    }
    
    func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
        // User tapped Decline or End — bridge to Flutter
        methodChannel?.invokeMethod("onCallDeclined", arguments: nil)
        action.fulfill()
    }
    
    func providerDidReset(_ provider: CXProvider) {
        // Provider was reset — clean up
    }
}

Step 3: Bridge to Flutter

Set up a method channel in your AppDelegate to communicate between native iOS code and Flutter:
import Flutter

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
    override func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        let controller = window?.rootViewController as! FlutterViewController
        let channel = FlutterMethodChannel(
            name: "com.yourapp/calls",
            binaryMessenger: controller.binaryMessenger
        )
        
        CallKitManager.shared.setMethodChannel(channel)
        
        channel.setMethodCallHandler { (call, result) in
            switch call.method {
            case "endCall":
                // End CallKit call from Flutter side
                result(nil)
            default:
                result(FlutterMethodNotImplemented)
            }
        }
        
        GeneratedPluginRegistrant.register(with: self)
        return super.application(application, didFinishLaunchingWithOptions: launchOptions)
    }
}

Flutter: Handle Call Events

On the Dart side, set up a method channel to receive call events from native code and manage the call lifecycle:
import 'package:flutter/services.dart';
import 'package:cometchat_calls_sdk/cometchat_calls_sdk.dart';

class VoIPCallManager {
  static const _channel = MethodChannel('com.yourapp/calls');

  static void initialize() {
    _channel.setMethodCallHandler(_handleMethodCall);
  }

  static Future<void> _handleMethodCall(MethodCall call) async {
    switch (call.method) {
      case 'onCallAccepted':
        // User accepted the call from native UI
        await _acceptAndJoinCall();
        break;
      case 'onCallDeclined':
        // User declined the call from native UI
        await _rejectCall();
        break;
      case 'onVoIPToken':
        // Register VoIP token with CometChat (iOS)
        final token = call.arguments as String;
        _registerVoIPToken(token);
        break;
    }
  }

  static Future<void> _acceptAndJoinCall() async {
    // Accept via CometChat Chat SDK, then join via Calls SDK
    // Navigate to call screen
  }

  static Future<void> _rejectCall() async {
    // Reject via CometChat Chat SDK
  }

  static void _registerVoIPToken(String token) {
    // Register token with CometChat push notification service
  }
}

Register FCM Token

Register the FCM token with CometChat to receive push notifications on Android:
import 'package:firebase_messaging/firebase_messaging.dart';

Future<void> registerPushToken() async {
  final token = await FirebaseMessaging.instance.getToken();
  if (token != null) {
    // Register with CometChat Chat SDK
    // CometChat.registerTokenForPushNotification(token, ...)
  }
}

Initiate Outgoing Calls

To make an outgoing VoIP call, use the CometChat Chat SDK to initiate the call, then join the session:
Future<void> initiateVoIPCall({
  required String receiverId,
  required String callType,
}) async {
  // Initiate call via CometChat Chat SDK
  // This sends a push notification to the receiver
  // On success, navigate to call screen and join session

  final sessionSettings = CometChatCalls.SessionSettingsBuilder()
      ..setType(callType == "video" ? SessionType.video : SessionType.audio);

  CometChatCalls.joinSession(
    sessionId: sessionId,
    sessionSettings: sessionSettings.build(),
    onSuccess: (Widget? widget) {
      // Navigate to call screen with the widget
    },
    onError: (CometChatCallsException e) {
      debugPrint("Failed to join: ${e.message}");
    },
  );
}

Complete Flow Diagram

This diagram shows the complete flow for both incoming and outgoing VoIP calls:

Troubleshooting

IssueSolution
Calls not received in background (Android)Verify FCM configuration and ensure high-priority notifications are enabled
Calls not received in background (iOS)Ensure VoIP push certificate is configured and PushKit is registered
CallKit UI not showing (iOS)Verify Background Modes capability includes “Voice over IP”
System call UI not showing (Android)Ensure PhoneAccount is registered and enabled in system settings
Audio routing issuesEnsure audioModeIsVoip is set on the Android Connection
Method channel not respondingVerify channel name matches between native and Dart code