Ringing and CallKit

Adding Ringing And CallKit To Your Application

Introduction

This guide details how to add an end-to-end call flow (ringing) to your Flutter application.

Ringing is done through a custom interface for Android and CallKit for iOS. An end-to-end call flow allows you to add an immersive calling experience to your application.

Ringing requires push/VoIP notifications to be sent to your device. Stream Video sends push/VoIP notifications to members that have at least one registered device.

To receive push notifications from Stream Video, you’ll need to:

  1. Configure your push notification provider on the Stream Dashboard.
  2. Add the client-side integration. For Flutter this guide demonstrates using Firebase Cloud Messaging (FCM) for Android and Apple Push Notification Service (APNs) for iOS devices.

To get the best experience we strongly suggest using APNs for iOS. While our goal is to ensure compatibility with both providers on iOS, Firebase is not yet fully supported.

Creating a ringing Call

To create a ringing call, you need to follow the same steps as in the basic call flow with the difference of adding the ringing and memberIds parameters to the getOrCreateCall() method.

final call = StreamVideo.instance.makeCall(callType: StreamCallType.defaultType(), id: 'Your-call-ID');
await call.getOrCreate(memberIds: ['user1_id', 'user2_id'], ringing: true, video: true);
  • When you set ringing to true, Stream will send a notification to the users on the call, triggering the platform call screen on iOS and Android. The ringing notification will indicate whether it’s a video call or an audio-only call, depending on whether you set the video parameter to true or false.
  • memberIds is a list of user IDs to immediately add to the call. Combining this with a ringing parameter will trigger the call to ring on the devices of the members.

In subsequent steps, we will show you how to configure your Stream app in the Dashboard and your Flutter app to properly send and receive ringing notifications.

Common steps for both iOS and Android

Configuring Push Notification Manager

To handle push notifications, you need to configure the pushNotificationManagerProvider in the StreamVideo instance. It manages device token registration, incoming call handling, and listening to call events (for example to end the call on the callee side when the caller ends the call).

When creating a StreamVideo instance, you need to pass a pushNotificationManagerProvider parameter. This parameter is an instance of StreamVideoPushNotificationManager that is created using the StreamVideoPushNotificationManager.create method.

 StreamVideo(
      // ...
       options: const StreamVideoOptions(
        // It's important to keep connections alive when the app is in the background to properly handle incoming calls while the app is in the background
        keepConnectionsAliveWhenInBackground: true,
      ),
      // Make sure you initialise push notification manager
      pushNotificationManagerProvider: StreamVideoPushNotificationManager.create(
        iosPushProvider: const StreamVideoPushProvider.apn(
          name: 'your-ios-provider-name',
        ),
        androidPushProvider: const StreamVideoPushProvider.firebase(
          name: 'your-fcm-provider',
        ),
        pushParams: const StreamVideoPushParams(
          appName: kAppName,
          ios: IOSParams(iconName: 'IconMask'),
        ),
      ),
    );
  • For androidPushProvider use the provider name we will create later in Firebase integration

  • For iosPushProvider use the provider name we will create later in APN integration

  • Add app icon Asset in Xcode for displaying in CallKit screen dedicated button (named IconMask in the code below). See details here

Handling CallKit events (for both iOS and Android)

CallKit events are events exposed by the flutter_callkit_incoming package that we utilize to handle incoming calls on both iOS and Android. It is important to handle these events to ensure a seamless calling experience regardless of which provider is used for push.

In a high-level widget in your app, add this code to listen to CallKit events:

import 'package:rxdart/rxdart.dart';

final _compositeSubscription = CompositeSubscription();

@override
void initState() {
  ...
  _observeCallKitEvents()
}

void _observeCallKitEvents() {
  final streamVideo = StreamVideo.instance;

  // You can use our helper method to observe core CallKit events
  // It will handled call accepted, declined and ended events
  _compositeSubscription.add(
      streamVideo.observeCoreCallKitEvents(
        onCallAccepted: (callToJoin) {
          // <---- IMPLEMENT NAVIGATION TO CALL SCREEN HERE
        },
      ),
    );

  // Or you can handle them by yourself, and/or add additional events such as handling mute events from CallKit 
  // _compositeSubscription.add(streamVideo.onCallKitEvent<ActionCallToggleMute>(_onCallToggleMute));
}

@override
void dispose() {
  // ...
  _compositeSubscription.cancelAll();
}

Remember to implement navigation in a marked line.

If you need to manage the CallKit call, you can use the StreamVideo.pushNotificationManager. As an example, let’s say you want to end all calls on the CallKit side, you can end them this way:

StreamVideo.instance.pushNotificationManager?.endAllCalls();

Handling calls while in the foreground

You can manage an incoming call by listening to Stream events and displaying the incoming call screen within your app.

StreamVideo.instance.state.incomingCall.listen(_onNavigateToCall);

If you navigate to your call screen, that uses StreamCallContainer, while the call is still ringing (not yet accepted) the incoming call screen will be displayed.

This method will not show an incoming call screen when the app is in the background or terminated state. To handle this, you would need proper VoIP push handling. Additionally, if VoIP push/CallKit is configured, it will display a ringing notification while also showing the incoming screen when the app is in the foreground.

Integrating Firebase for Android

Step 1 - Get the Firebase Credentials

These credentials are the private key file for your service account, in Firebase console.

To generate a private key file for your service account in the Firebase console:

  • Open Settings > Service Accounts.

  • Click Generate New Private Key, then confirm by clicking Generate Key.

  • Securely store the JSON file containing the key.

This JSON file contains the credentials that need to be uploaded to Stream’s server, as explained in the next step.

Step 2 - Upload the Firebase Credentials to Stream

You now need to upload your Firebase credentials using the Stream dashboard.

  • Go to the dashboard of your video project at the Stream website.

  • Open the Push Notifications tab under Video & Audio.

  • Select New Configuration and select Firebase.

Firebase Configuration

  • Add a name for your push provider in the Name field. You will use this name later in the code to identify which provider to use for Android notifications.

  • Add your previously generated Firebase Credentials in the Credentials JSON field.

  • Enable this provider using toggle button.

  • Click Create and your push provider should be ready.

Step 3 - Add dependencies to your app

To integrate push notifications in your Flutter app, you need to use the firebase_messaging package.

Follow the Flutter Firebase documentation to set up the plugin for Android and iOS. Make sure you complete additional setup described here and here.

Once that’s done, FCM should be able to send push notifications to your devices.

Step 4 - Add native permissions

Add these permissions to AndroidManifest.xml in order to support video calling:

<uses-feature android:name="android.hardware.camera"/>
<uses-feature android:name="android.hardware.camera.autofocus"/>
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE"/>
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>

Step 5 - Add code to listen to push notifications

We recommend storing user credentials locally when the user logs in so you can automatically set up the user when a push notification is received.

  • Add the following code in your main.dart as global functions to listen to background notifications:

  • Replace yourUserCredentialsGetMethod() with your implementation to get logged in user credentials

  • Configure pushNotificationManagerProvider in the same way you did in the previous setup steps

// As this runs in a separate isolate, we need to setup the app again.
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {

  // Initialise Firebase
  await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);

  try {
    // Get stored user credentials
    var credentials = yourUserCredentialsGetMethod();
    if (credentials == null) return;

    // Initialise StreamVideo
    StreamVideo(
      // ...
      // Make sure you initialise push notification manager
      pushNotificationManagerProvider: StreamVideoPushNotificationManager.create(
        iosPushProvider: const StreamVideoPushProvider.apn(
          name: 'your-ios-provider-name',
        ),
        androidPushProvider: const StreamVideoPushProvider.firebase(
          name: 'your-fcm-provider',
        ),
        pushParams: const StreamVideoPushParams(
          appName: kAppName,
          ios: IOSParams(iconName: 'IconMask'),
        ),
      ),
    );

    // Observe Declined CallKit event to handle declining the call even when app is terminated
    streamVideo.observeCallDeclinedCallKitEvent();

    // Pass it along to the handler
    await _handleRemoteMessage(message);
  } catch (e, stk) {
    debugPrint('Error handling remote message: $e');
    debugPrint(stk.toString());
  }

  StreamVideo.reset();
}

Future<void> _handleRemoteMessage(RemoteMessage message) async {
  await StreamVideo.instance.handleVoipPushNotification(message.data);
}

In a high-level widget in your app, add this code to listen to FCM messages:

@override
void initState() {
  ...
  _observeFcmMessages()
}

_observeFcmMessages() {
  FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
  _fcmSubscription = FirebaseMessaging.onMessage.listen(_handleRemoteMessage);
}

The code until this point handles calls for the background and foreground state of the app. To handle calls from a terminated state, we need to add some additional code.

In a high-level widget, add this method and call it from the initState() method:

Add navigator key to MaterialApp widget: navigatorKey: _navigatorKey.

final _navigatorKey = GlobalKey<NavigatorState>();

@override
void initState() {
  //...
  _tryConsumingIncomingCallFromTerminatedState();
}

void _tryConsumingIncomingCallFromTerminatedState() {
  // This is only relevant for Android.
  if (CurrentPlatform.isIos) return;

  if (_navigatorKey.currentContext == null) {
    // App is not running yet. Postpone consuming after app is in the foreground
    WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
      _consumeIncomingCall();
    });
  } else {
    // no-op. If the app is already running we'll handle it via events
  }
}

Future<void> _consumeIncomingCall() async {
  final calls = await StreamVideo.instance.pushNotificationManager?.activeCalls();
  if (calls == null || calls.isEmpty) return;

  final callResult = await StreamVideo.instance.consumeIncomingCall(
    uuid: calls.first.uuid!,
    cid: calls.first.callCid!,
  );

  callResult.fold(success: (result) async {
      final call = result.data;
      await call.accept();

      //Navigate to call screen <---- IMPLEMENT NAVIGATION HERE
    }, failure: (error) {
      debugPrint('Error consuming incoming call: $error');
    });
}

Step 6 - Request notification permission from user

For Android 13+ you need to request the POST_NOTIFICATIONS permission. You can do it using the permission_handler package.

Remember to follow official best practices (especially showing prompt before the request).

Integrating APNs for iOS

Step 1 - Get the iOS certificate for push notifications

  • Generate push notification service key here. Make sure you select Apple Push Notifications service SSL (Sandbox & Production).

  • You will need to create Certificate Signing Request - follow this steps

  • Convert the aps.cer file you created in the last step to a.p12 certificate file using keychain access. Make sure that you configure no password for the p12 file.

    • Add app.cer to login keychain

    • Find it in Certificate tab, right click and export as.p12 file

    • Remember not to set any password while exporting

Step 2 - Upload the certificate and create a push provider

  • Go to the dashboard of your video project at the Stream website.

  • Open the Push Notifications tab under Video & Audio.

  • Select New Configuration and select APN.

APNs Configuration

  • Add a name for your push provider in the Name field. This is the name used while setting up iOS push notifications in the code we did earlier

  • Add your previously generated P12 file with your additional Apple information.

  • Enable this provider using toggle button

  • Click Create and your push provider should be ready.

Step 3 - Add dependencies

There are no dependencies on the Flutter side that you need to add specifically for iOS.

Step 4 - Add native permissions

Add these permissions to Info.plist in order to support video calling:

<key>NSCameraUsageDescription</key>
<string>$(PRODUCT_NAME) needs access to your camera for video calls.</string>
<key>NSMicrophoneUsageDescription</key>
<string>$(PRODUCT_NAME) needs access to your microphone for voice and video calls.</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
	 <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
</array>
<key>UIBackgroundModes</key>
<array>
	<string>audio</string>
	<string>fetch</string>
	<string>processing</string>
	<string>remote-notification</string>
	<string>voip</string>
</array>

Step 5 - Add callback to handle call in terminated state

When an iOS app is terminated, the Flutter engine is not running. The engine needs to be started up to handle Stream call events whenever a call is received by the app. The Stream SDK performs the job of running a Flutter engine instance whenever a call is received. However, on the app side, a callback handle needs to be registered that will connect to StreamVideo.

@pragma('vm:entry-point')
Future<void> _backgroundVoipCallHandler() async {
  WidgetsFlutterBinding.ensureInitialized();
  
  // Get stored user credentials
  var credentials = yourUserCredentialsGetMethod();
  if (credentials == null) return;

  // Initialise StreamVideo
  StreamVideo(
    // ...
    // Make sure you initialise push notification manager
    pushNotificationManagerProvider: StreamVideoPushNotificationManager.create(
      iosPushProvider: const StreamVideoPushProvider.apn(
        name: 'your-ios-provider-name',
      ),
      androidPushProvider: const StreamVideoPushProvider.firebase(
        name: 'your-fcm-provider',
      ),
      pushParams: const StreamVideoPushParams(
        appName: kAppName,
        ios: IOSParams(iconName: 'IconMask'),
      ),
    ),
  );
}

The _backgroundVoipCallHandler method should then be set when StreamVideo is initialised:

StreamVideo(
    // ...
    pushNotificationManagerProvider: StreamVideoPushNotificationManager.create(
      // ...
      backgroundVoipCallHandler: _backgroundVoipCallHandler,
    ),
  );

Step 6 - Add native code to the iOS project

In your iOS project, add the following imports to your AppDelegate.swift:

import UIKit
import Flutter
import stream_video_push_notification

In the same file, add an extra line to your AppDelegate class which registers the app for push notifications:

override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
    GeneratedPluginRegistrant.register(with: self)

    // Register for push notifications.
    StreamVideoPKDelegateManager.shared.registerForPushNotifications()

    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}

Registering a Device With Stream Backend

Once you configure a push provider and set it up on the Stream dashboard, a device that is supposed to receive push notifications needs to be registered on the Stream backend.

Device registration is carried out in the SDK every time a user logs in and does not need to be implemented in your app.

© Getstream.io, Inc. All Rights Reserved.