utd_calls_kit
LiveKit-based, WhatsApp-style 1:1 audio & video calling for Flutter — an
independent UTD-Stream kit (no dependency on utd_stream_sdk).
Features
- 1:1 audio & video calls over the UTD-Stream Engine (LiveKit).
- No-backend mint: ship
app_id+ the publishableapp_key; the per-user bearer is minted server-side. The projectserver_secretnever ships. - Presence + incoming-call signalling over a single WebSocket.
- Default call UI (incoming / outgoing / in-call), live audio↔video upgrade, speaker/earpiece/bluetooth routing, proximity blanking, in-app minimize and Android OS Picture-in-Picture.
- OS-native incoming-call UI — CallKit on iOS, ConnectionService on Android.
- v0.9: wake-from-killed — iOS PushKit VoIP and Android FCM data pushes ring the device into CallKit even when the app is backgrounded or killed; accepting from a cold start resumes and joins the call.
Capabilities & billing
Calling rides the paid signaling capability (call invitation + presence),
billed separately from chat. Both are sold via a subscription plan
(Free / Signaling / Pro / Enterprise); billing is MAU-based — no per-minute or
per-message charge.
The session mint surfaces the flags + plan limits on the controller (all
nullable — null means the engine didn't report it, on an older engine):
await controller.init();
if (controller.signalingEnabled == false) {
// Signaling isn't enabled for this plan — show an upsell instead of calling.
}
If a call is refused because signaling isn't enabled, the failure ends with
UTDCallEndReason.notActivated and the rethrown UTDStreamException carries
isFeatureDisabled / isSignalingDisabled, so the UI can show an upsell rather
than a generic error.
Getting started
final controller = UTDCallController(
const UTDCallsConfig(
appId: '9325906999',
appKey: 'pk_live_...', // publishable app key (no server secret)
userId: 'user-42',
userName: 'Sara',
// enableCallKit: true, // default — native incoming-call UI
),
);
await controller.init(); // mint bearer + connect signal WS + start CallKit
await controller.startCall('user-99', name: 'Omar', type: UTDStreamType.videoCall);
Incoming calls — native UI (v0.5)
When UTDCallsConfig.enableCallKit is true (the default), an incoming
call.invitation over the signal WebSocket shows the OS-native call screen
(CallKit / ConnectionService) via UTDCallKitBridge:
- Accept on the native screen →
controller.acceptIncoming(). - Decline / timeout →
controller.reject(). - End from the native UI → hangs up / declines as appropriate.
- The native call timer starts when the media room connects (
setConnected). - The native UI is dismissed on any terminal state.
The existing in-app UTDIncomingCallScreen remains the fallback whenever
CallKit is disabled or the host app is not configured for it. Set
enableCallKit: false to use the in-app screen only.
Wake-from-killed — VoIP / FCM push (v0.9)
When UTDCallsConfig.enablePush is true (the default) the kit registers this
device's push tokens with the engine and wakes the app for an incoming call even
when it is backgrounded or killed:
- iOS (PushKit VoIP): the engine sends a VoIP push; iOS delivers it straight
into CallKit via
flutter_callkit_incoming(no Dart needed while killed). The user's accept/decline flows throughUTDCallKitBridge→ the controller. On a cold start (accepted while killed), the controller looks up thecall_id, fetches the call, and joins. - Android (FCM data): a top-level
@pragma('vm:entry-point')background handler (utdFirebaseBackgroundHandler) shows the full-screen CallKit incoming UI (backed by the plugin's foreground service) on a{type:'call'}data message. Foreground messages take the same path.
Token collection is automatic on controller.init():
FlutterCallkitIncoming.getDevicePushTokenVoIP() (iOS VoIP) and/or
FirebaseMessaging.instance.getToken() (FCM) are registered via
POST /api/v1/devices/push-token; refreshes re-register. Call
controller.unregisterPush() on sign-out.
Token collection degrades gracefully: if the host's Firebase / PushKit setup is missing, no tokens are registered and the foreground signal-WS path keeps working. Set
enablePush: falseto opt out entirely.
Engine gating: the engine only dispatches pushes once its
APNS_*(iOS) andFCM_*(Android) credentials are provisioned. Until then devices register tokens but receive no background pushes — calls arrive only via the foreground signal WebSocket.
REQUIRED HOST APP config
Firebase (both platforms)
firebase_messaging requires the host app's Firebase project files (this package
cannot provide them):
- Android:
android/app/google-services.json, the Google Services Gradle plugin applied in the host app. - iOS:
ios/Runner/GoogleService-Info.plist. - The host must call
await Firebase.initializeApp()(e.g. inmain(), withWidgetsFlutterBinding.ensureInitialized()) beforecontroller.init(). The kit does not initialise Firebase for you.
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(); // host responsibility
runApp(const MyApp());
}
flutter_callkit_incoming needs native configuration in the host app (it
cannot be provided by this package). Without it the native call UI will not
appear and only the in-app fallback works.
iOS (ios/Runner/Info.plist)
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
<string>voip</string>
</array>
voipenables PushKit VoIP wake-from-killed;audiokeeps the audio session alive in-call.- APNs + PushKit: in Xcode enable the Push Notifications capability and
the Background Modes → Voice over IP + Remote notifications options.
Upload your APNs key/cert to the Firebase project (FCM proxies APNs) and/or
provision the engine's
APNS_*credentials for direct VoIP pushes. - Microphone / camera usage descriptions are already needed for calls:
NSMicrophoneUsageDescription,NSCameraUsageDescription. - Minimum iOS 12 for CallKit / PushKit.
Android (android/app/src/main/AndroidManifest.xml)
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<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.READ_PHONE_NUMBERS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Already required by the calls kit: -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.INTERNET" />
- On Android 13+ request
POST_NOTIFICATIONSat runtime, and on Android 14+ the full-screen-intent permission may needFlutterCallkitIncoming.requestFullIntentPermission()/requestNotificationPermission(...). compileSdk/targetSdk34+ recommended;minSdk21+.- Follow the
flutter_callkit_incomingREADME for theCallkitIncomingActivity/ receiver manifest entries if you customise the native screen. - FCM: the Google Services Gradle plugin +
google-services.jsonare required forfirebase_messagingto receive the{type:'call'}data message that the kit's background handler turns into a CallKit screen.
Engine env (ops / backend)
Push dispatch is performed by the engine and is gated until its push credentials are provisioned. Until then, devices register tokens normally but no background push is sent — calls arrive only via the foreground signal WebSocket.
APNS_*— Apple Push (PushKit VoIP) credentials for iOS.FCM_*— Firebase Cloud Messaging credentials for Android.
The engine endpoints the kit consumes (already implemented):
POST / DELETE /api/v1/devices/push-token (Bearer) for registration, and the
data-push payload {type:'call', call_id, caller_identity, caller_name, call_type} the kit parses.
Additional information
- The kit follows the UTD-Stream layering (
lib/src/...,utd_prefix,flutter_lints); only the public API is re-exported fromutd_calls_kit.dart. - Device QA pending — 1.0.0 is gated on it. Push wake-from-killed, CallKit,
the pickers, and cold start cannot be exercised by
flutter analyzeor unit tests. Required checklist:- iOS (PushKit VoIP): incoming call rings via CallKit while the app is (a) locked, (b) backgrounded, (c) killed; accept from each foregrounds the app and joins; decline rejects; cold-start accept resumes + joins; VoIP token registers and re-registers on rotation.
- Android 13 & 14 (FCM data): incoming call shows the full-screen CallKit
UI while the app is (a) backgrounded and (b) killed; accept joins; decline
rejects;
POST_NOTIFICATIONS(13+) and full-screen-intent (14+) prompts behave; FCM token registers and re-registers on refresh. - Both:
unregisterPush()on sign-out stops further pushes; graceful no-op when Firebase/PushKit is not configured.
Libraries
- utd_calls_kit
- utd_calls_kit — LiveKit-based WhatsApp-style 1:1 audio & video calling for
Flutter. Built on the shared
utd_signalingpackage for the signalling plane (presence + call invitations over the engine WS/REST); LiveKit media, CallKit and the UI live here. No dependency on utd_stream_sdk.