Webtrit CallKeep is a Flutter plugin that integrates native calling UI on iOS (CallKit) and Android ( ConnectionService). It allows your VoIP/WebRTC app to display incoming/outgoing calls using the device’s system UI — even when your app is in the background or terminated.
Note: CallKit is banned in the China App Store. For distribution in China, consider using an alternative mechanism such as standard local notifications with sound.
-
Incoming Call UI
– On iOS, the system CallKit UI is shown when the app is locked or in the background. When the app is in the foreground, a Flutter-based incoming call screen is used instead.
– On Android, all incoming calls are displayed using a Flutter UI combined with the standard push notification interface — native ConnectionService UI is not used. -
Background Handling
– On iOS, background incoming calls are handled via CallKit and PushKit.
– On Android, background call handling is supported either via persistent signaling services or triggered by push notifications (e.g., FCM). -
Incoming & Outgoing Calls
– Full support for both call directions -
Call Controls
– Answer, decline, mute, hold, and dial pad (DTMF), with delegate event handling
Platform | Minimum |
---|---|
Android | SDK 26+ |
iOS | iOS 11+ |
Add the plugin in your pubspec.yaml
:
dependencies:
webtrit_callkeep: ^latest_version
await _callkeep.setUp(
CallkeepOptions(
ios: CallkeepIOSOptions(
localizedName: context.read<PackageInfo>().appName,
ringtoneSound: Assets.ringtones.incomingCall1,
ringbackSound: Assets.ringtones.outgoingCall1,
iconTemplateImageAssetName: Assets.callkeep.iosIconTemplateImage.path,
maximumCallGroups: 13,
maximumCallsPerCallGroup: 13,
supportedHandleTypes: const {CallkeepHandleType.number},
),
android: CallkeepAndroidOptions(
ringtoneSound: Assets.ringtones.incomingCall1,
ringbackSound: Assets.ringtones.outgoingCall1,
),
),
);
The plugin allows you to register delegate classes to handle call-related events.
Callkeep().setDelegate(MyCallkeepDelegate());
Callkeep().setPushRegistryDelegate(MyPushRegistryDelegate()); // iOS only
setDelegate(...)
: Listen to call events (incoming, answered, ended, etc.) entirely on the Flutter side.setPushRegistryDelegate(...)
: Handle PushKit VoIP push events on iOS only.
await Callkeep().reportNewIncomingCall(
callId,
CallkeepHandle.number('+123456789'),
displayName: 'Caller Name',
hasVideo: false,
);
await Callkeep().startCall(
'outgoing-id',
CallkeepHandle.number('+987654321'),
displayNameOrContactIdentifier: 'Jane Doe',
hasVideo: true,
);
Webtrit CallKeep provides a robust two-way communication system between the Flutter layer and native platforms (iOS and Android), inspired by the CallKit design on iOS.
The following methods are used to notify the native platform about call actions or state changes. These are typically used to trigger UI (e.g., incoming screen), report call status, or request platform-level operations.
Method | Description |
---|---|
reportNewIncomingCall(...) |
Triggers the native UI to display an incoming call. |
reportConnectingOutgoingCall(...) |
Reports that an outgoing call is connecting. |
reportConnectedOutgoingCall(...) |
Reports that an outgoing call is connected. |
reportUpdateCall(...) |
Sends updated call metadata such as display name, video, or proximity sensor usage. |
reportEndCall(...) |
Notifies the platform that a call has ended with a reason. |
startCall(...) |
Starts an outgoing call via native UI. |
answerCall(...) |
Answers a call from Flutter code. |
endCall(...) |
Ends the call from Flutter. |
setHeld(...) |
Puts the call on hold or resumes it. |
setMuted(...) |
Mutes or unmutes the call. |
sendDTMF(...) |
Sends a DTMF tone. |
setSpeaker(...) |
Enables or disables speaker mode. |
These methods are called from the native platform back to Flutter when a user interacts with the system’s call UI ( e.g., answering a call from lock screen) or when system-level events occur (e.g., audio session activation).
Implement these by providing a CallkeepDelegate
via Callkeep().setDelegate(...)
.
Method | Triggered When... |
---|---|
continueStartCallIntent(...) |
System confirms outgoing call intent (e.g., from Siri or Call UI). |
didPushIncomingCall(...) |
System has received and processed an incoming call. |
performStartCall(...) |
User initiates an outgoing call from native UI. |
performAnswerCall(...) |
User answers the call from native UI. |
performEndCall(...) |
User ends the call from system UI. |
performSetHeld(...) |
User toggles hold/resume from system UI. |
performSetMuted(...) |
User toggles mute from system UI. |
performSendDTMF(...) |
User sends DTMF digits from the native dial pad. |
performSetSpeaker(...) |
User enables/disables speaker from system UI. |
didActivateAudioSession() |
System has activated the audio session (important for audio routing). |
didDeactivateAudioSession() |
System has deactivated the audio session. |
didReset() |
System resets the call state, often due to errors or forceful termination. |
🧐 Note:
perform*
methods usually require you to respond asynchronously (e.g., via signaling servers) and return abool
indicating success or failure. Failing to respond may result in incorrect UI behavior or system timeouts.
Below is a simplified lifecycle flow illustrating how an outgoing call is initiated and processed using
Callkeep.startCall
followed by a native event triggering performStartCall
:
// Triggered by your Flutter app logic
final callId = WebtritSignalingClient.generateCallId();
final handle = CallkeepHandle.number('+123456789');
await callkeep.startCall(
callId,
handle,
displayNameOrContactIdentifier: 'John Doe',
hasVideo: false,
);
This will attempt to establish a new connection and validate it. If the connection is successfully created and valid,
the system will invoke performStartCall
to proceed with the call setup.
If the connection is valid, the system will call the following delegate method:
@override
Future<bool> performStartCall(
String callId,
CallkeepHandle handle,
String? displayNameOrContactIdentifier,
bool video,
) async {
// Establish media connection and signaling offer
final stream = await navigator.mediaDevices.getUserMedia({'audio': true});
final peerConnection = await createPeerConnection({...});
final offer = await peerConnection.createOffer();
await _signalingClient.send(OutgoingCallRequest(
callId: callId,
number: handle.normalizedValue(),
jsep: offer.toMap(),
));
await peerConnection.setLocalDescription(offer);
return true;
}
You are expected to:
- Initialize media stream
- Create WebRTC offer
- Send offer to your signaling server
- Set local description on the peer connection
If everything succeeds, return true
. If anything fails, return false
and CallKeep will automatically terminate the
native call screen.
You can add similar flows for answerCall
, endCall
, etc.
Webtrit CallKeep offers two modes to handle background call signaling in Android. Use the one that suits your scenario.
🔎 Note: Some Android classes use the
@Keep
annotation to prevent code shrinking and obfuscation. Ensure ProGuard/R8 rules preserve these classes.
Ideal for apps that do not maintain a persistent connection and instead rely on push notifications to trigger call events.
Configure the push notification isolate with the following code (Optional):
AndroidCallkeepServices.backgroundPushNotificationBootstrapService.configurePushNotificationSignalingService(launchBackgroundIsolateEvenIfAppIsOpen: false);
Initialize the push notification isolate callback.
This callback will be triggered when
AndroidCallkeepServices.backgroundPushNotificationBootstrapService.reportNewIncomingCall
is called, for example, from
an FCM isolate.
AndroidCallkeepServices.backgroundPushNotificationBootstrapService.initializeCallback(onPushNotificationSyncCallback););
You can notify the plugin of a new incoming call from within a background handler (e.g., FCM):
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
AndroidCallkeepServices.backgroundPushNotificationBootstrapService.reportNewIncomingCall(
appNotification.call.id,
CallkeepHandle.number(appNotification.call.handle),
displayName: appNotification.call.displayName,
hasVideo: appNotification.call.hasVideo,
);
}
To manage resources and synchronize signaling from push notifications, implement a background callback like this:
@pragma('vm:entry-point')
Future<void> onPushNotificationCallback(CallkeepPushNotificationSyncStatus status) async {
await _initializeDependencies();
switch (status) {
case CallkeepPushNotificationSyncStatus.synchronizeCallStatus:
await _backgroundCallEventManager?.onStart();
break;
case CallkeepPushNotificationSyncStatus.releaseResources:
await _backgroundCallEventManager?.close();
break;
}
}
Inside this isolate, when a connection is established, we can communicate with the native part using
BackgroundPushNotificationServicee
. This service provides methods for endBackgroundCall
, and
endAllBackgroundCalls
. Additionally, you can set a delegate using setBackgroundServiceDelegate
to listen for events
such as performServiceAnswerCall
, performServiceEndCall
, and endCallReceived
.
class SignalingForegroundIsolateManager implements CallkeepBackgroundServiceDelegate {
@override
void performServiceAnswerCall(String callId) async {
if (!(await _signalingManager.hasNetworkConnection())) {
throw Exception('No connection');
}
// Proceed with answering
}
@override
void performServiceEndCall(String callId) async {
await _signalingManager.declineCall(callId);
}
void anwserCall(String callId) {
BackgroundPushNotificationServicee.answerCall(callId);
}
}
This ensures your app can rehydrate or tear down background services based on the nature of the push event.
Used for always-on signaling, even in the background.
Configure the signaling isolate and set up the notification details for the foreground service(Optional).
AndroidCallkeepServices.backgroundSignalingBootstrapService.setUp(androidNotificationName: "WebTrit Inbound Calls",androidNotificationDescription: "This is required to receive incoming calls",);
Initialize the isolate callback for background signaling. This callback will be triggered when the status of the main isolate changes, the app lifecycle changes, or the isolate starts:
AndroidCallkeepServices.backgroundSignalingBootstrapService.initializeCallback(onSignalingSyncCallback);
await AndroidCallkeepServices.backgroundSignalingBootstrapService.startService();
await AndroidCallkeepServices.backgroundSignalingBootstrapService.stopService();
Register the callback that will be triggered to manage the signaling connection.
This can be done using the CallkeepLifecycleEvent
from CallkeepServiceStatus
, which contains parameters such as the
current activity status (e.g., opened or closed) and the CallkeepSignalingStatus
from CallkeepServiceStatus
,
indicating the current signaling status if provided by the main isolate; otherwise, it will be null.
@pragma('vm:entry-point')
Future<void> onSignalingSyncCallback(CallkeepServiceStatus status) async {
await _initializeDependencies();
await _signalingForegroundIsolateManager?.sync(status);
return;
}
Inside this isolate, when a connection is established, we can communicate with the native part using
BackgroundSignalingService
. This service provides methods for incomingCall
, endBackgroundCall
, and
endAllBackgroundCalls
. Additionally, you can set a delegate using setBackgroundServiceDelegate
to listen for events
such as performServiceAnswerCall
, performServiceEndCall
, and endCallReceived
.
class SignalingForegroundIsolateManager implements CallkeepBackgroundServiceDelegate {
@override
void performServiceAnswerCall(String callId) async {
if (!(await _signalingManager.hasNetworkConnection())) {
throw Exception('No connection');
}
// Proceed with answering
}
@override
void performServiceEndCall(String callId) async {
await _signalingManager.declineCall(callId);
}
void anwserCall(String callId) {
BackgroundSignalingService.answerCall(callId);
}
}
Ensure the following permissions are declared:
android.permission.FOREGROUND_SERVICE
android.permission.MANAGE_OWN_CALLS
android.permission.BIND_TELECOM_CONNECTION_SERVICE
- Enable Push Notifications
- Enable Background Modes → Voice over IP
To see Webtrit CallKeep in action, check out the official open-source demo app:
This is a reference implementation of a Flutter-based VoIP/WebRTC app that uses Webtrit CallKeep to handle incoming and outgoing calls with native UI support on both Android and iOS.
The project demonstrates:
- Integration with signaling and media layers
- Usage of
CallkeepDelegate
and background isolates - Real-world call handling scenarios including mute, hold, and DTMF
- Full support for foreground and background call workflows
Feel free to explore it, fork it, and use it as a starting point for your own app!
Maintained by the Webtrit team.
We welcome contributions from the community! Please follow our contribution guidelines when submitting pull requests.
This project is licensed under the MIT License.