Flutter Push Notification App Setup
Introduction
This document provides a guide on how to set up push notifications for incoming calls on the Telnyx Voice SDK Flutter Plugin.
It is important to understand how push notifications work in general in relation to Telnyx services. When you connect to the TelnyxClient, you establish a socket connection that can receive and handle incoming calls. However, if your application is in the background or terminated, the socket connection closes and can no longer receive invitations.
To address this limitation, the Telnyx Voice SDK uses push notifications to inform the device of incoming calls. When you log in to the TelnyxClient and provide an APNS or FCM token, the SDK sends a push notification to your device whenever an incoming call is received. Note that this notification only indicates that an invitation is on the way.
Once you respond to a push notification—for example, by tapping “Accept”—you must handle the logic to launch and reconnect to the TelnyxClient. After reconnection, the socket connection is re-established, and the backend will send the actual invitation to your device. In this scenario, it may be helpful to store the fact that the push notification was accepted so that when the invitation arrives (as soon as the socket is connected), you can automatically accept the call.
The following sections provide more detail on how to implement these steps.
Handling Foreground and Terminated Calls
When the app is in the foreground you do not need to use push notifications to receive calls, however it still might be beneficial to use CallKit to show native UI for the calls. When the app is terminated you will need to use push notifications to receive calls as described below.
Android
The Android platform makes use of Firebase Cloud Messaging in order to deliver push notifications. To receive notifications when receiving calls on your Android mobile device you will have to enable Firebase Cloud Messaging within your application.
The Demo app uses the FlutterCallkitIncoming plugin to show incoming calls. To show a notification when receiving a call, you can follow the steps below:
- Listen for Background Push Notifications, Implement the
FirebaseMessaging.onBackgroundMessage
method in yourmain
method
@pragma('vm:entry-point')
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
if (defaultTargetPlatform == TargetPlatform.android) {
// Android Only - Push Notifications
await Firebase.initializeApp();
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
await FirebaseMessaging.instance
.setForegroundNotificationPresentationOptions(
alert: true,
badge: true,
sound: true,
);
}
runApp(const MyApp());
}
- Optionally Add the
metadata
to CallKitParamsextra
field
static Future showNotification(RemoteMessage message) {
CallKitParams callKitParams = CallKitParams(
android:...,
ios:...,
extra: message.data,
)
await FlutterCallkitIncoming.showCallkitIncoming(callKitParams);
}
- Handle the push notification in the
_firebaseMessagingBackgroundHandler
method
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
// Show notifcation
showNotification(message);
// Listen to action from FlutterCallkitIncoming
FlutterCallkitIncoming.onEvent.listen((CallEvent? event) async {
switch (event!.event) {
case Event.actionCallAccept:
// Set the telnyx metadata for access when the app comes to foreground
TelnyxClient.setPushMetaData(
message.data, isAnswer: true, isDecline: false);
break;
case Event.actionCallDecline:
/*
* When the user declines the call from the push notification, the app will no longer be visible, and we have to
* handle the endCall user here.
* Login to the TelnyxClient and end the call
* */
...
}});
}
- Use the
TelnyxClient.getPushMetaData()
method to retrieve the metadata when the app comes to the foreground. This data is only available on 1st access and becomesnull
afterward.
Future<void> _handlePushNotification() async {
final data = await TelnyxClient.getPushMetaData();
PushMetaData? pushMetaData = PushMetaData.fromJson(data);
if (pushMetaData != null) {
_telnyxClient.handlePushNotification(pushMetaData, credentialConfig, tokenConfig);
}
}
- To Handle push calls on foreground, Listen for Call Events and invoke the
handlePushNotification
method
FlutterCallkitIncoming.onEvent.listen((CallEvent? event) {
switch (event!.event) {
case Event.actionCallIncoming:
// retrieve the push metadata from extras
final data = await TelnyxClient.getPushData();
...
_telnyxClient.handlePushNotification(pushMetaData, credentialConfig, tokenConfig);
break;
case Event.actionCallStart:
....
break;
case Event.actionCallAccept:
...
logger.i('Call Accepted Attach Call');
break;
});
Best Practices for Push Notifications on Android
- Request for Notification Permissions for android 13+ devices to show push notifications. More information can be found here
- Push Notifications only work in foreground for apps that are run in
debug
mode (You will not receive push notifications when you terminate the app while running in debug mode). - On Foreground calls, you can use the
FirebaseMessaging.onMessage.listen
method to listen for incoming calls and show a notification.
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
TelnyxClient.setPushMetaData(message.data);
NotificationService.showNotification(message);
mainViewModel.callFromPush = true;
});
- To handle push notifications on the background, use the
FirebaseMessaging.onBackgroundMessage
method to listen for incoming calls and show a notification and make sure to set theTelnyxClient.setPushMetaData
when user answers the call.
TelnyxClient.setPushMetaData(
message.data, isAnswer: true, isDecline: false);
-
When you call the
telnyxClient.handlePushNotification
it connects to thetelnyxClient
, make sure not to call thetelnyxClient.connect()
method after this. e.g an Edge case might be if you calltelnyxClient.connect()
on Widgetinit
method it will always call theconnect
method -
Early Answer/Decline : Users may answer/decline the call too early before a socket connection is established. To handle this situation, assert if the
IncomingInviteParams
is not null and only accept/decline if this is available.
bool waitingForInvite = false;
void accept() {
if (_incomingInvite != null) {
// accept the call if the incomingInvite arrives on time
_currentCall = _telnyxClient.acceptCall(
_incomingInvite!, _localName, _localNumber, "State");
} else {
// set waitingForInvite to true if we have an early accept
waitingForInvite = true;
}
}
_telnyxClient.onSocketMessageReceived = (TelnyxMessage message) {
switch (message.socketMethod) {
...
case SocketMethod.INVITE:
{
if (callFromPush) {
// For early accept of call
if (waitingForInvite) {
//accept the call
accept();
waitingForInvite = false;
}
callFromPush = false;
}
}
...
}
}
iOS
The iOS Platform makes use of the Apple Push Notification Service (APNS) and Pushkit in order to deliver and receive push notifications For a detailed tutorial, please visit our official Push Notification Docs
Native Swift Code Changes
For a full example please view the Demo Application Example
- In AppDelegate.swift setup the ability to receive VOIP push notifications by adding these lines to your application function
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
//Setup VOIP
let mainQueue = DispatchQueue.main
let voipRegistry: PKPushRegistry = PKPushRegistry(queue: mainQueue)
voipRegistry.delegate = self
voipRegistry.desiredPushTypes = [PKPushType.voIP]
RTCAudioSession.sharedInstance().useManualAudio = true
RTCAudioSession.sharedInstance().isAudioEnabled = false
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
Note: It is important to add the following lines to your Info.plist
file to enable push notifications
<key>UIBackgroundModes</key>
<array>
<string>processing</string>
<string>remote-notification</string>
<string>voip</string>
</array>
Also, as we are using WebRTC we need to add the following lines to avoid a bug where there is no audio on iOS when using it with CallKit. Add the following lines to your AppDelegate.swift
file in the application function as demonstrated above
RTCAudioSession.sharedInstance().useManualAudio = true
RTCAudioSession.sharedInstance().isAudioEnabled = false
- Implement the CallkitIncomingAppDelegate within AppDelegate so that you can action on calls that are received. Callin action.fulfill() will allows us to listen to the events and act on them in our dart code.
@main
@objc class AppDelegate: FlutterAppDelegate, PKPushRegistryDelegate, CallkitIncomingAppDelegate {
func onAccept(_ call: flutter_callkit_incoming.Call, _ action: CXAnswerCallAction) {
NSLog("onRunner :: Accept")
action.fulfill()
}
func onDecline(_ call: flutter_callkit_incoming.Call, _ action: CXEndCallAction) {
NSLog("onRunner :: Decline")
action.fulfill()
}
func onEnd(_ call: flutter_callkit_incoming.Call, _ action: CXEndCallAction) {
NSLog("onRunner :: onEnd")
action.fulfill()
}
func onTimeOut(_ call: flutter_callkit_incoming.Call) {
NSLog("onRunner :: TimeOut")
}
func didActivateAudioSession(_ audioSession: AVAudioSession) {
NSLog("onRunner :: Activate Audio Session")
RTCAudioSession.sharedInstance().audioSessionDidActivate(audioSession)
RTCAudioSession.sharedInstance().isAudioEnabled = true
}
func didDeactivateAudioSession(_ audioSession: AVAudioSession) {
NSLog(":: DeActivate Audio Session")
RTCAudioSession.sharedInstance().audioSessionDidDeactivate(audioSession)
RTCAudioSession.sharedInstance().isAudioEnabled = false
}
....
Note: Notice for didActivateAudioSession and didDeactivateAudioSession that we are handling WebRTC manually. This is to handle the before mentioned bug where there is no audio on iOS when using it with CallKit.
- Register / Invalidate the push device token for iOS within AppDelegate.swift class
func pushRegistry(_ registry: PKPushRegistry, didUpdate credentials: PKPushCredentials, for type: PKPushType) {
print(credentials.token)
let deviceToken = credentials.token.map { String(format: "%02x", $0) }.joined()
//Save deviceToken to your server
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP(deviceToken)
}
func pushRegistry(_ registry: PKPushRegistry, didInvalidatePushTokenFor type: PKPushType) {
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP("")
}
- For foreground calls to work, you need to register with callkit on the restorationHandler delegate function. You can also choose to register with callkit using iOS official documentation on CallKit.
override func application(_ application: UIApplication,
continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
let nameCaller = handleObj.getDecryptHandle()["nameCaller"] as? String ?? ""
let handle = handleObj.getDecryptHandle()["handle"] as? String ?? ""
let data = flutter_callkit_incoming.Data(id: UUID().uuidString, nameCaller: nameCaller, handle: handle, type: isVideo ? 1 : 0)
//set more data...
data.nameCaller = "dummy"
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.startCall(data, fromPushKit: true)
}
- Listen for incoming calls in AppDelegate.swift class and grab the relevant metadata from the push payload to pass to showCallkitIncoming (eg. the callerName, callerNumber, callID, etc)
func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) {
print("didReceiveIncomingPushWith")
guard type == .voIP else { return }
if let metadata = payload.dictionaryPayload["metadata"] as? [String: Any] {
var callID = UUID.init().uuidString
if let newCallId = (metadata["call_id"] as? String),
!newCallId.isEmpty {
callID = newCallId
}
let callerName = (metadata["caller_name"] as? String) ?? ""
let callerNumber = (metadata["caller_number"] as? String) ?? ""
let id = payload.dictionaryPayload["call_id"] as? String ?? UUID().uuidString
let data = flutter_callkit_incoming.Data(id: id, nameCaller: callerName, handle: callerNumber, type: isVideo ? 1 : 0)
data.extra = payload.dictionaryPayload as NSDictionary
data.normalHandle = 1
let caller = callerName.isEmpty ? (callerNumber.isEmpty ? "Unknown" : callerNumber) : callerName
let uuid = UUID(uuidString: callID)
data.uuid = uuid!.uuidString
data.nameCaller = caller
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.showCallkitIncoming(data, fromPushKit: true)
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
completion()
}
}
}
Note: it is important to call completion() after showing the callkit incoming screen to avoid the app being terminated by the system. See the last line above. if you don't call completion() in pushRegistry(......, completion: @escaping () -> Void), there may be app crash by system when receiving voIP
Dart / Flutter Code Changes
- Listen for Call Events and invoke the
handlePushNotification
method
FlutterCallkitIncoming.onEvent.listen((CallEvent? event) {
switch (event!.event) {
case Event.actionCallIncoming:
// retrieve the push metadata from extras
PushMetaData? pushMetaData = PushMetaData.fromJson(event.body['extra']['metadata']);
_telnyxClient.handlePushNotification(pushMetaData, credentialConfig, tokenConfig);
break;
case Event.actionCallStart:
....
break;
case Event.actionCallAccept:
...
logger.i('Call Accepted Attach Call');
break;
});
- Sending an invitation on iOS when using Callkit. Because of the native changes we made in AppDelegate.swift related to WebRTC audio handling, we now need to start a callkit call whenever we send out an invitation so that we can have an active audio session. This simply means starting a callkit call whenever we send out an invitation. Your call() method could look something like:
void call(String destination) {
_currentCall = telnyxClient.newInvite(
_localName,
_localNumber,
destination,
'State',
customHeaders: {'X-Header-1': 'Value1', 'X-Header-2': 'Value2'},
);
var params = CallKitParams(
id: _currentCall?.callId,
nameCaller: _localName,
appName: 'My Calling App',
handle: destination,
);
FlutterCallkitIncoming.startCall(params);
}
Handling Late Notifications
If notifications arrive very late due to no internet connectivity, It is good to always flag it as a missed call. You can do that using the code snippet below :
const CALL_MISSED_TIMEOUT = 60;
DateTime nowTime = DateTime.now();
Duration? difference = nowTime?.difference(message.sentTime!);
if (difference.inSeconds > CALL_MISSED_TIMEOUT) {
NotificationService.showMissedCallNotification(message);
return;
}
Handling Background Calls
Background calls are handled slightly differently than terminated calls as the application is not disconnected and in a fresh state. The application is in the background and the socket connection is still active - meaning we can receive an invite on the socket but not be notified about it.
We can get around this by listening to application lifecycle events and checking if the application is in the background, and if so disconnecting from the socket manually. This way we can listen to the push notification and reconnect to the socket when the user accepts the call.
You can do this by either manually creating a lifecycle state listener in dart using the WidgetsBindingObserver or using a library like flutter_fgbg to listen to the application lifecycle events.
An implementation could look like this:
@pragma('vm:entry-point')
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
if (!AppInitializer()._isInitialized) {
await AppInitializer()
.initialize(); // handle any initialization here (setting up listeners, etc)
}
runApp(
FGBGNotifier(
onEvent: (FGBGType type) =>
switch (type) {
FGBGType.foreground =>
{
print('App in the foreground (FGBGNotifier)'),
// Check if we are in the foreground as a result of a push notification, if we are do nothing, reconnection will happen there in handlePush. Otherwise connect
if (telnyxClient.callFromPush)
{
telnyxClient.connectWithCredential(getCredentialConfig)
},
},
FGBGType.background =>
{
print('App in the background (FGBGNotifier)'),
telnyxClient.disconnect(),
}
},
child: const MyApp()),
);
}
Note: Notice that in the foreground event we check if we are in the foreground as a result of a push notification, if we are do nothing, reconnection will happen there in handlePush. Otherwise connect. This is because handlePushNotification will be called when the user accepts the call from the push notification and we will reconnect to the socket there.
This also means however that when we are showing a notification, which on iOS can be full screen, we need to make sure that we don't disconnect when the notification is shown. We can do this with FGBG's ignoreWhile method like so:
Future<void> showNotification(IncomingInviteParams message) async {
// Temporarily ignore FGBG events while showing the CallKit notification
FGBGEvents.ignoreWhile(() async {
CallKitParams callKitParams = CallKitParams(
id: message.callID,
nameCaller: message.callerIdName,
appName: 'Telnyx Flutter Demo',
handle: message.callerIdNumber,
type: 0,
textAccept: 'Accept',
textDecline: 'Decline',
missedCallNotification: const NotificationParams(
showNotification: false,
isShowCallback: false,
subtitle: 'Missed call',
),
duration: 30000,
extra: {},
headers: <String, dynamic>{'platform': 'flutter'},
);
await FlutterCallkitIncoming.showCallkitIncoming(callKitParams);
});
}
Best Practices for Push Notifications on iOS
-
Push Notifications only work in foreground for apps that are run in
debug
mode (You will not receive push notifications when you terminate the app while running in debug mode). Make sure you are inrelease
mode. Preferably test using Testfight or Appstore. To test if push notifications are working, disconnect the telnyx client (while app is in foreground) and make a call to the device. You should receive a push notification.