Call Kit for ZEGO
Real-time audio/video calling for Flutter, built on ZEGOCLOUD, with native CallKit on iOS and ConnectionService on Android for the incoming-call UI.
Unofficial. Not affiliated with or endorsed by ZEGOCLOUD — it builds on their
zego_express_engineSDK.
Features
- Audio & Video Calling - High-quality real-time communication using ZEGOCLOUD
- In-Session Chat - Text messaging with typing indicators and delivery/read receipts
- Flexible CallKit Integration - Choose between native CallKit UI or custom app UI
- Native mode: Full-screen incoming call UI on locked screens (iOS/Android)
- Custom mode: Complete control over call UI with your own design
- Socket Agnostic - Works with any WebSocket/Socket.IO implementation
- State Machine - Robust call state management with transition guards
- Network Resilience - Automatic reconnection with exponential backoff
- Plugin Architecture - Optional analytics, crash reporting, and call storage
- WhatsApp-like UX - 30-second ringing timeout, proper call lifecycle
- Demo signaling server included - a working Node server in
demo_server/so you can place a real call before writing any backend
Table of Contents
- Demo Server
- Installation
- Quick Start
- CallKit Options
- Architecture
- Configuration
- Socket Integration
- Handling Call Events
- Making and Receiving Calls
- In-Session Chat
- Platform Setup
- Plugins
- Advanced Usage
- Troubleshooting
Demo Server — place a real call in 5 minutes
This repo ships a complete signaling server in demo_server/,
so you can make a working call before writing any backend code.
Calling needs two halves: media (ZEGOCLOUD handles that) and signaling — telling the other person "you have a call". This package is signaling-agnostic on purpose, so you provide the second half. The demo server is a working implementation you can call against today and copy from later.
It is a test harness, not a backend: no auth, no database, everything lives in memory and dies with the process.
1. Start the server
cd demo_server
npm install
npm start
It listens on port 3000. Check it and see who is connected:
curl localhost:3000/health
2. Point the app at it
The example reads the URL from a --dart-define:
cd example
flutter run --dart-define=SIGNALING_URL=http://192.168.1.20:3000
| Where the app runs | URL to use |
|---|---|
| Android emulator | http://10.0.2.2:3000 (the default) |
| iOS simulator | http://localhost:3000 |
| Real device | http://<your-machine-LAN-IP>:3000 |
Find your IP with ipconfig getifaddr en0 (macOS) or hostname -I (Linux).
A real device must be on the same Wi-Fi as your machine.
10.0.2.2is an emulator-only alias for your machine's localhost. On a physical device it resolves to nothing and you get[SocketAdapter] Connection error: timeout.
3. Make the call
Add your ZEGOCLOUD appId / appSign in example/lib/main.dart — signaling
works without them, but there will be no audio or video. Then launch the app on
two devices, pick a different user on each, and call.
Accept the microphone and camera prompts on both. A denied mic does not fail the call — it publishes an empty stream, so you get a connected call with silence.
Verify signaling without any devices
npm test in demo_server/ drives two fake clients through a full call —
ring, accept, chat, hang up, plus the reject and offline paths — asserting the
exact field names the mapper reads:
cd demo_server && npm test
Writing your own server
demo_server/README.md documents the full protocol —
every event in both directions and its payload — plus the three things a real
backend must get right. Each of them fails silently, as a call that connects
and looks fine:
sessionStartedmust reach both parties, and it is what starts media. The controller deliberately does not join the ZEGO room on the offer or the accept; it waits for your authoritativeroomIdhere.- Both parties need the identical
roomId. Two different ones means two people alone in two rooms, with the UI showing "connected" throughout. - Never infer direction from
userId/listenerId. The mapper puts whoever is emitting inuserId, so the callee occupies that field onaccept-request. Look the call up byrequestIdinstead.
Installation
Add to your pubspec.yaml:
dependencies:
call_kit_for_zego: ^1.0.0
Then run:
flutter pub get
Quick Start
1. Initialize the Service
import 'package:call_kit_for_zego/call_kit_for_zego.dart';
// Initialize the service
await RtcCommunicationService.initialize(
config: CommunicationConfig(
zegoConfig: ZegoConfig(
appId: YOUR_ZEGO_APP_ID, // From ZEGOCLOUD console
appSign: 'YOUR_ZEGO_APP_SIGN', // From ZEGOCLOUD console
),
socketConfig: SocketConfig(
url: 'https://your-server.com',
),
callKitConfig: CallKitConfig(
appName: 'Your App Name',
iconName: 'app_icon', // Optional: custom icon for incoming call
),
useCallKit: true, // Optional: Use native CallKit (default: true)
),
socketAdapter: YourSocketAdapter(), // Your socket implementation
);
// Get the service instance
final rtcService = RtcCommunicationService.instance;
💡 Note: Set
useCallKit: falseto use your own custom incoming call UI instead of native CallKit. See CallKit Options.
2. Set Current User
rtcService.setCurrentUser(UserInfo(
userId: 'user_123',
userName: 'John Doe',
userImage: 'https://example.com/avatar.jpg',
));
3. Listen to Events
// Listen to call events
rtcService.callEvents.listen((event) {
switch (event.type) {
case CallEventType.incomingCall:
// Show incoming call UI (or let native CallKit handle it)
break;
case CallEventType.callAccepted:
// Navigate to call screen
break;
case CallEventType.callEnded:
// Clean up and navigate back
break;
case CallEventType.callDeclined:
// Show "call declined" message
break;
}
});
4. Make a Call
await rtcService.startCall(
calleeId: 'user_456',
calleeName: 'Jane Smith',
calleeImage: 'https://example.com/jane.jpg',
callType: CallType.video,
);
5. Handle Incoming Calls
// Accept call
await rtcService.acceptCall();
// Reject call
await rtcService.rejectCall();
6. End Call
await rtcService.endCall();
CallKit Options
The package offers two modes for handling incoming calls:
Native CallKit Mode (Default)
When useCallKit = true (default), the package uses native iOS CallKit and Android ConnectionService to display full-screen incoming call UI, even on locked screens.
Perfect for:
- WhatsApp/Telegram-like call experience
- System-level call integration
- Calls that work on locked screens
- Less implementation work
Configuration:
await RtcCommunicationService.initialize(
config: CommunicationConfig(
zegoConfig: ZegoConfig(
appId: YOUR_ZEGO_APP_ID,
appSign: 'YOUR_ZEGO_APP_SIGN',
),
socketConfig: SocketConfig(url: 'https://your-server.com'),
callKitConfig: CallKitConfig(
appName: 'My App',
iconName: 'app_icon',
),
useCallKit: true, // ✅ Native CallKit UI (default)
),
socketAdapter: socketAdapter,
);
What you get:
- ✅ Full-screen incoming call on locked screen
- ✅ System ringtone
- ✅ Native accept/decline buttons
- ✅ iOS CallKit & Android ConnectionService integration
- ✅ Your callbacks still fire for navigation
Custom UI Mode
When useCallKit = false, you have complete control over the incoming call UI using your own custom screens.
Perfect for:
- Fully custom call UI design
- Unique UX requirements
- Showing additional context on call screen
- Testing without native UI complexity
Configuration:
await RtcCommunicationService.initialize(
config: CommunicationConfig(
zegoConfig: ZegoConfig(
appId: YOUR_ZEGO_APP_ID,
appSign: 'YOUR_ZEGO_APP_SIGN',
),
socketConfig: SocketConfig(url: 'https://your-server.com'),
useCallKit: false, // ❌ No native CallKit - you handle UI
),
socketAdapter: socketAdapter,
);
// Listen to events and show your custom UI
rtcService.callEvents.listen((event) {
if (event.type == CallEventType.incomingCall) {
// Show YOUR custom incoming call screen
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => MyCustomIncomingCallScreen(
callerName: event.callData?.caller.userName,
callerImage: event.callData?.caller.avatarUrl,
onAccept: () async {
await rtcService.acceptCall();
// Navigate to call screen
},
onReject: () async {
await rtcService.rejectCall();
Navigator.pop(context);
},
),
),
);
}
});
What you get:
- ✅ Complete control over UI design
- ✅ Show custom info (caller bio, mutual friends, etc.)
- ✅ Custom animations and styling
- ✅ Easier testing and debugging
- ⚠️ Requires more implementation work
- ⚠️ Won't work on locked screen without push notifications
Comparison:
| Feature | Native CallKit (true) |
Custom UI (false) |
|---|---|---|
| Full-screen on locked screen | ✅ | ❌ |
| System integration | ✅ | ❌ |
| Custom UI design | Limited | ✅ Full control |
| Implementation complexity | ⭐ Easy | ⭐⭐⭐ More work |
| Works with system UI | ✅ | ❌ |
💡 Tip: Start with
useCallKit = falseduring development for easier testing, then switch totruefor production.
📖 Full guide: See CallKit usage below for detailed examples and a migration guide.
Architecture
┌─────────────────────────────────────────────────────────────┐
│ Your Flutter App │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ ┌─────────────────────────────┐ │
│ │ Your Socket │ │ RtcCommunicationService │ │
│ │ (Socket.IO, │──────▶│ (Main Entry Point) │ │
│ │ WebSocket) │ └─────────────────────────────┘ │
│ └─────────────────┘ │ │
│ │ │
│ ┌──────────────────────────────┼──────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌───────────────┐ ┌───────────────┐ ┌──────────┐│
│ │ CallManager │ │ SessionManager│ │ ChatMgr ││
│ │ (Call Events) │ │ (User State) │ │ (Text) ││
│ └───────────────┘ └───────────────┘ └──────────┘│
│ │ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ RtcCallController │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌────────────────┐ │ │
│ │ │ ZegoService │ │ StateManager│ │ NativeCallKit │ │ │
│ │ │ (A/V Engine)│ │ (State M/C) │ │ (iOS/Android) │ │ │
│ │ └─────────────┘ └─────────────┘ └────────────────┘ │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
Configuration
ZegoConfig
ZegoConfig(
appId: 123456789, // Required: Your ZEGOCLOUD App ID
appSign: 'your_app_sign', // Required: Your ZEGOCLOUD App Sign
)
CallKitConfig
CallKitConfig(
appName: 'My App', // Shown on incoming call screen
iconName: 'app_icon', // Icon resource name (optional)
ringtoneSound: 'ringtone.mp3', // Custom ringtone (optional)
duration: 30000, // Ring duration in ms (default: 30s)
textAccept: 'Accept', // Accept button text
textDecline: 'Decline', // Decline button text
missedCallNotification: true, // Show missed call notification
android: AndroidCallKitConfig(
isShowLogo: true,
isShowFullLockedScreen: true,
),
ios: IOSCallKitConfig(
supportsVideo: true,
maximumCallGroups: 1,
),
)
CommunicationConfig
CommunicationConfig(
zegoConfig: ZegoConfig(...), // Required: ZEGO configuration
socketConfig: SocketConfig(...), // Required: Socket configuration
callKitConfig: CallKitConfig(...), // Optional: CallKit configuration
useCallKit: true, // Optional: Enable/disable native CallKit (default: true)
signalingMapper: MyCustomMapper(), // Optional: Custom signaling mapper
enableLogging: true, // Optional: Enable debug logs (default: true)
logLevel: LogLevel.info, // Optional: Log level (default: info)
)
useCallKit parameter:
true(default): Native CallKit handles incoming call UIfalse: Your app handles UI using callbacks- See CallKit Options section for detailed comparison
RtcCallConfig (Advanced)
RtcCallConfig(
// Call lifecycle
ringingTimeout: Duration(seconds: 30), // Auto-cancel if unanswered
// Reconnection (exponential backoff: 2s, 4s, 8s, 16s, 30s)
reconnectRetryCount: 5,
reconnectBackoff: Duration(seconds: 2),
maxReconnectBackoff: Duration(seconds: 30),
// Audio
audioBitrate: 48, // kbps
enableEchoCancellation: true, // AEC
enableNoiseSuppression: true, // ANS
enableAutoGainControl: true, // AGC
// Video (defaults are portrait HD, matching phone screens)
videoBitrate: 2000, // kbps
videoWidth: 720,
videoHeight: 1280,
// Diagnostics
enableLogs: true,
logLevel: RtcLogLevel.info, // verbose | info | warning | error
enableAudioDebug: false, // Detailed audio pipeline logs
audioDebugInProduction: false, // Keep those logs in release builds
)
Two presets are provided:
RtcCallConfig.development() // verbose logging, 60s ringing timeout
RtcCallConfig.production() // info logging, 30s ringing timeout
Socket Integration
The package is socket-agnostic. You need to provide a SocketAdapter that wraps your socket implementation.
SocketAdapter Interface
abstract class SocketAdapter {
/// Whether socket is currently connected
bool get isConnected;
/// Register event listener
void on(String event, Function(dynamic) callback);
/// Remove event listener
void off(String event);
/// Emit event with data
void emit(String event, dynamic data);
}
Socket.IO Implementation Example
import 'package:socket_io_client/socket_io_client.dart' as IO;
import 'package:call_kit_for_zego/call_kit_for_zego.dart';
class SocketIOAdapter implements SocketAdapter {
final IO.Socket _socket;
SocketIOAdapter(this._socket);
@override
bool get isConnected => _socket.connected;
@override
void on(String event, Function(dynamic) callback) {
_socket.on(event, callback);
}
@override
void off(String event) {
_socket.off(event);
}
@override
void emit(String event, dynamic data) {
_socket.emit(event, data);
}
}
// Usage
final socket = IO.io('https://your-server.com', IO.OptionBuilder()
.setTransports(['websocket'])
.enableAutoConnect()
.build());
final adapter = SocketIOAdapter(socket);
await rtcService.initialize(
config: config,
socketAdapter: adapter,
);
Custom Signaling Mapper
If your backend uses different event names/formats, implement SignalingMapper:
class MyBackendMapper implements SignalingMapper {
@override
List<String> get incomingEventNames => [
'receiveChatRequest', // Your incoming call event
'requestAccepted', // Your call accepted event
'requestRejected', // Your call rejected event
'sessionStarted', // Your session started event
'sessionEnded', // Your session ended event
];
@override
RtcSignalingEvent? mapIncomingEvent(String eventName, Map<String, dynamic> data) {
switch (eventName) {
case 'receiveChatRequest':
return RtcSignalingEvent.callOffer(
callId: data['requestId'] ?? _generateId(data),
fromUserId: data['userId'],
toUserId: data['listenerId'],
callType: data['type'] == 'video' ? 'video' : 'audio',
roomId: data['roomId'],
metadata: {
'senderName': data['userName'],
'senderImage': data['userImage'],
},
);
// ... handle other events
}
return null;
}
@override
SignalingEmitData mapOutgoingEvent(RtcSignalingEvent event, {...}) {
// Map outgoing events to your backend format
}
@override
String getEventName(RtcSignalingEventType type) {
switch (type) {
case RtcSignalingEventType.callOffer:
return 'chat-request';
case RtcSignalingEventType.callAnswer:
return 'accept-request';
case RtcSignalingEventType.callReject:
return 'reject-request';
case RtcSignalingEventType.callEnd:
return 'session-end';
// ...
}
}
}
// Use custom mapper
await rtcService.initialize(
config: config,
socketAdapter: adapter,
signalingMapper: MyBackendMapper(),
);
Handling Call Events
All call events are emitted through the callEvents stream, regardless of whether you're using native CallKit (useCallKit: true) or custom UI (useCallKit: false). This allows you to navigate to appropriate screens and handle UI updates.
💡 Important: With
useCallKit: false, you must handle the incoming call UI yourself by listening toCallEventType.incomingCalland showing your custom screen.
CallEvent Types
rtcService.callEvents.listen((event) {
switch (event.type) {
case CallEventType.incomingCall:
final callId = event.callId;
final caller = event.remoteUser;
print('Incoming call from ${caller?.userName}');
// If useCallKit = false, show your custom incoming call screen here
if (!usingCallKit) {
showCustomIncomingCallScreen(event.callData);
}
break;
case CallEventType.outgoingCall:
print('Call initiated, waiting for answer...');
break;
case CallEventType.callAccepted:
print('Call accepted, connecting...');
// Navigate to call screen
Navigator.push(context, MaterialPageRoute(
builder: (_) => CallScreen(callId: event.callId),
));
break;
case CallEventType.callConnected:
print('Call connected!');
break;
case CallEventType.callDeclined:
print('Call was declined');
break;
case CallEventType.callEnded:
print('Call ended: ${event.reason}');
Navigator.pop(context);
break;
case CallEventType.callFailed:
print('Call failed: ${event.error}');
break;
case CallEventType.callMissed:
print('Missed call from ${event.remoteUser?.userName}');
break;
case CallEventType.remoteUserJoined:
print('Remote user joined');
break;
case CallEventType.remoteUserLeft:
print('Remote user left');
break;
}
});
Making and Receiving Calls
Start an Outgoing Call
try {
await rtcService.startCall(
calleeId: 'user_456',
calleeName: 'Jane Smith',
calleeImage: 'https://example.com/jane.jpg',
callType: CallType.video, // or CallType.audio
);
// Show outgoing call UI
} catch (e) {
print('Failed to start call: $e');
}
Accept Incoming Call
// When user taps "Accept" (or handled automatically by CallKit)
await rtcService.acceptCall();
Reject Incoming Call
// When user taps "Decline"
await rtcService.rejectCall();
End Active Call
await rtcService.endCall();
In-Call Controls
// Toggle mute
await rtcService.toggleMute();
bool isMuted = rtcService.isMuted;
// Toggle camera
await rtcService.toggleCamera();
bool isCameraOn = rtcService.isCameraOn;
// Switch camera (front/back)
await rtcService.switchCamera();
// Toggle speaker
await rtcService.toggleSpeaker();
Video Widget
Display local and remote video:
// In your call screen widget
@override
Widget build(BuildContext context) {
return Stack(
children: [
// Remote video (full screen)
Positioned.fill(
child: RtcVideoView(
userId: remoteUserId,
isLocal: false,
),
),
// Local video (picture-in-picture)
Positioned(
top: 50,
right: 20,
width: 120,
height: 160,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: RtcVideoView(
userId: localUserId,
isLocal: true,
),
),
),
],
);
}
In-Session Chat
The package includes a built-in chat system for sending text messages during active sessions. Chat works over the same socket connection used for call signaling.
Chat Features
- Send and receive text messages
- Typing indicators (is typing / stopped typing)
- Message status tracking:
sending→sent→delivered→read - Message deduplication (prevents duplicate processing)
- Session-aware messaging (tied to call sessions)
Listening to Chat Events
rtcService.chatEvents.listen((event) {
switch (event.type) {
case ChatEventType.messageReceived:
// New message from remote user
final message = event.message!;
print('${message.senderName}: ${message.content}');
break;
case ChatEventType.messageSent:
// Message successfully sent
print('Sent: ${event.message?.content}');
break;
case ChatEventType.messageDelivered:
// Message delivered to recipient
final messageId = event.metadata?['messageId'];
print('Delivered: $messageId');
break;
case ChatEventType.messageRead:
// Message read by recipient
final messageIds = event.metadata?['messageIds'];
print('Read: $messageIds');
break;
case ChatEventType.typingIndicator:
// Remote user is typing
final isTyping = event.metadata?['isTyping'] as bool;
final userName = event.user?.userName;
print('$userName is ${isTyping ? "typing..." : "idle"}');
break;
case ChatEventType.sessionStarted:
print('Chat session started: ${event.sessionId}');
break;
case ChatEventType.sessionEnded:
print('Chat session ended: ${event.sessionId}');
break;
}
});
Sending Messages
// Send a text message
final result = await rtcService.sendMessage(
sessionId: 'session_123',
content: 'Hello! How are you?',
);
if (result.success) {
print('Message sent: ${result.message?.messageId}');
} else {
print('Failed: ${result.error}');
}
Typing Indicators
// Notify that the user started typing
await rtcService.sendTypingIndicator(
sessionId: 'session_123',
isTyping: true,
);
// Notify that the user stopped typing
await rtcService.sendTypingIndicator(
sessionId: 'session_123',
isTyping: false,
);
Session Management
// Set the active chat session (auto-set when a call session starts)
rtcService.setCurrentChatSession('session_123');
// Get the current chat session ID
final sessionId = rtcService.currentChatSessionId;
Chat Socket Events
The chat system uses these socket events by default:
| Direction | Socket Event | Description |
|---|---|---|
| Incoming | receiveMessage |
New message from remote user |
| Incoming | messageDelivered |
Delivery confirmation |
| Incoming | messageRead |
Read receipt |
| Incoming | typing |
Typing indicator |
| Outgoing | sendMessage |
Send a message |
| Outgoing | typing |
Send typing indicator |
ChatEvent Types
| Type | Description |
|---|---|
sessionStarted |
Chat session started |
sessionEnded |
Chat session ended |
messageReceived |
New message received |
messageSent |
Message successfully sent |
messageDelivered |
Message delivered to server |
messageRead |
Message read by recipient |
messageFailed |
Message failed to send |
typingIndicator |
Remote user typing status |
MessageStatus
| Status | Description |
|---|---|
sending |
Message is being sent |
sent |
Message sent to server |
delivered |
Message delivered to recipient |
read |
Message read by recipient |
failed |
Message failed to send |
📖 Widget: The package ships a ready-made chat UI in lib/src/widgets/chat_screen.dart — message bubbles, typing indicators and status icons included.
Platform Setup
call_kit_for_zego is a pure-Dart package — it has no android/ or ios/ folder of
its own. Everything below is configuration your app owns. The example app
under example/ is a working reference for all of it.
Android
Permissions are mostly automatic. flutter_callkit_incoming ships its own
AndroidManifest.xml, and the Gradle manifest merger folds it into your app.
You get these without doing anything:
INTERNET · RECORD_AUDIO · CAMERA · POST_NOTIFICATIONS ·
MANAGE_OWN_CALLS · USE_FULL_SCREEN_INTENT · FOREGROUND_SERVICE ·
FOREGROUND_SERVICE_PHONE_CALL · FOREGROUND_SERVICE_CAMERA ·
FOREGROUND_SERVICE_MICROPHONE · WAKE_LOCK · VIBRATE ·
DISABLE_KEYGUARD · TURN_SCREEN_ON · ACCESS_NOTIFICATION_POLICY
Add to android/app/src/main/AndroidManifest.xml only what the merger does not
give you:
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Not in the plugin manifest; needed for the speaker/earpiece toggle -->
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<!-- Keeps audio-only devices eligible on the Play Store -->
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.microphone" android:required="false" />
<application>
<activity
android:name=".MainActivity"
android:launchMode="singleTop"
android:showWhenLocked="true"
android:turnScreenOn="true">
</activity>
</application>
</manifest>
⚠️ Delete
android:taskAffinity=""from MainActivity
flutter createputs it there by default, and it breaks accepting a call.flutter_callkit_incominglaunches your MainActivity withFLAG_ACTIVITY_NEW_TASKwhen the user accepts. With an empty task affinity Android cannot reuse the running task, so it starts a second MainActivity in a second task: a brand-new route stack that shows a black screen while the real call screen sits in the original task, plus a duplicate entry in the recents switcher.Removing the attribute lets the activity share the app's default affinity, so the running task is brought forward instead.
In android/app/build.gradle.kts:
android {
defaultConfig {
// The full-screen incoming-call UI needs API 23+
minSdk = maxOf(flutter.minSdkVersion, 23)
}
}
Required Gradle workaround. zego_express_engine 3.25.0 still declares
compileSdkVersion 31, while its own AndroidX dependencies require 33+. Without
this, any app depending on it fails to build with
Dependency 'androidx.window.extensions.core:core:1.0.0' requires ... version 33 or later.
Add to android/build.gradle.kts, above the existing
subprojects { project.evaluationDependsOn(":app") } block:
subprojects {
afterEvaluate {
extensions.findByName("android")?.let { ext ->
val android = ext as com.android.build.gradle.BaseExtension
if (android.compileSdkVersion?.substringAfter("android-")?.toIntOrNull()
?.let { it < 35 } == true) {
android.compileSdkVersion(35)
}
}
}
}
Order matters — placed after evaluationDependsOn, Gradle fails with
Cannot run Project.afterEvaluate(Action) when the project is already evaluated.
iOS
Add to ios/Runner/Info.plist:
<key>NSMicrophoneUsageDescription</key>
<string>Microphone access is required to make and receive calls.</string>
<key>NSCameraUsageDescription</key>
<string>Camera access is required for video calls.</string>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
<string>voip</string>
<string>remote-notification</string>
<string>fetch</string>
</array>
Then in Xcode (ios/Runner.xcworkspace → Signing & Capabilities), add:
- Push Notifications
- Background Modes → Audio/AirPlay/PiP, Voice over IP, Background fetch, Remote notifications
⚠️ Runtime permissions are your app's job
This is the single most common cause of "the call connects but there is no
audio." Declaring RECORD_AUDIO in the manifest is not the same as being
granted it. Android 6+ requires a runtime grant, and neither ZEGOCLOUD nor
flutter_callkit_incoming requests microphone or camera access for you.
If the permission is missing, the ZEGO engine still joins the room and still publishes a stream — a silent one. Both sides show a connected call and nothing in the logs looks wrong.
Request the permissions before calling startCall() or acceptCall(). Any
permission plugin works; permission_handler is the usual choice — it is
deliberately not a dependency of this package, so you are free to use
whatever your app already has:
import 'package:permission_handler/permission_handler.dart';
Future<bool> ensureCallPermissions({required bool isVideo}) async {
final needed = <Permission>[
Permission.microphone,
if (isVideo) Permission.camera,
if (Platform.isAndroid) Permission.notification, // Android 13+
];
final results = await needed.request();
return results.values.every((s) => s.isGranted);
}
// Before starting or accepting a call:
if (!await ensureCallPermissions(isVideo: callType == CallType.video)) {
// Show your own "permissions required" UI — do not start the call.
return;
}
await rtcService.startCall(/* ... */);
On Android, also request the full-screen-intent permission once at startup so incoming calls can appear over the lock screen:
if (Platform.isAndroid) {
await FlutterCallkitIncoming.requestFullIntentPermission();
}
The package verifies, even though it cannot request. If the engine reports
that a device was never authorized, call_kit_for_zego surfaces a
CallError.permissionDenied on its error stream rather than letting the call
fail silently. Listen for it:
rtcService.errors.listen((error) {
if (error.code == CallError.codePermissionDenied) {
// Mic or camera was never granted, or was revoked mid-call.
}
});
This also fires for a mic that is unavailable for other reasons — occupied by another app, or taken over by Siri mid-call.
Plugins
Analytics Plugin
Track call metrics with your analytics service:
class FirebaseAnalyticsPlugin implements AnalyticsPlugin {
final FirebaseAnalytics _analytics = FirebaseAnalytics.instance;
@override
Future<void> logCallStarted({
required String callId,
required String callType,
required String callerId,
required String calleeId,
bool isOutgoing = true,
}) async {
await _analytics.logEvent(
name: 'call_started',
parameters: {
'call_id': callId,
'call_type': callType,
'is_outgoing': isOutgoing,
},
);
}
// Implement other methods...
}
// Register plugin
rtcService.registerPlugin(FirebaseAnalyticsPlugin());
Crash Reporting Plugin
Report call errors to your crash reporting service:
class FirebaseCrashlyticsPlugin implements CrashReportingPlugin {
final FirebaseCrashlytics _crashlytics = FirebaseCrashlytics.instance;
@override
Future<void> recordError(
dynamic error,
StackTrace? stackTrace, {
String? reason,
bool fatal = false,
}) async {
await _crashlytics.recordError(
error,
stackTrace,
reason: reason,
fatal: fatal,
);
}
// Implement other methods...
}
rtcService.registerPlugin(FirebaseCrashlyticsPlugin());
Call Storage Plugin
Persist call history:
class HiveCallStoragePlugin implements CallStoragePlugin {
final Box<CallRecord> _callBox;
@override
Future<void> saveCallToHistory(CallRecord record) async {
await _callBox.put(record.callId, record);
}
// Implement other methods...
}
rtcService.registerPlugin(HiveCallStoragePlugin(callBox));
Advanced Usage
Custom Call State Handling
rtcService.onStateChanged = (CallState oldState, CallState newState) {
print('State changed: $oldState → $newState');
// Custom handling based on state
if (newState == CallState.connecting) {
showConnectingOverlay();
} else if (newState == CallState.connected) {
hideConnectingOverlay();
startCallTimer();
}
};
Network Reconnection
The package automatically handles network reconnection. You can customize behavior:
rtcService.onReconnecting = () {
showReconnectingBanner();
};
rtcService.onReconnected = () {
hideReconnectingBanner();
};
rtcService.onReconnectFailed = () {
showReconnectFailedDialog();
};
Call Duration Timer
// Access current call duration
final duration = rtcService.currentCallDuration;
print('Call duration: ${duration.inMinutes}:${duration.inSeconds % 60}');
// Or listen to duration updates
rtcService.durationStream.listen((duration) {
updateDurationUI(duration);
});
Troubleshooting
Common Issues
1. Incoming call not showing on locked screen (Android)
- Ensure
USE_FULL_SCREEN_INTENTpermission is declared - Call
FlutterCallkitIncoming.requestFullIntentPermission()at app start - Check that
showOnLockScreenandturnScreenOnare set in Activity
2. No audio after call connects
- Check microphone permission is granted
- Ensure ZEGO credentials are correct
- Verify both users joined the same roomId
3. Call state stuck
- Enable debug logging:
RtcLogger.setLogLevel(LogLevel.debug) - Check socket connection status
- Verify signaling events are being received
4. CallKit not dismissing when call is cancelled
- Ensure
endCall()is called with correct callId - Check that socket events are properly mapped
5. Want to test without native CallKit complexity
- Set
useCallKit: falsein CommunicationConfig - Implement custom incoming call UI using
onIncomingCallcallback - Call
rtcService.acceptCall()orrtcService.rejectCall()from your UI - See CallKit Options for complete example
6. Incoming call not showing (useCallKit: false)
- Verify you're listening to
rtcService.callEventsstream - Check that you show your custom UI in
CallEventType.incomingCallhandler - Ensure your incoming call screen calls
acceptCall()orrejectCall()
Debug Logging
// Enable verbose logging
RtcLogger.setLogLevel(LogLevel.debug);
// Logs will show:
// [RtcCallKit] INFO: Initializing...
// [RtcCallKit] DEBUG: Socket event received: incoming_call
// [RtcCallKit] INFO: State transition: idle → ringing
API Reference
RtcCommunicationService
| Method | Description |
|---|---|
initialize() |
Initialize the service with config |
setCurrentUser() |
Set the current user |
startCall() |
Initiate an outgoing call |
acceptCall() |
Accept incoming call |
rejectCall() |
Reject incoming call |
endCall() |
End active call |
toggleMute() |
Toggle microphone |
toggleCamera() |
Toggle camera |
switchCamera() |
Switch front/back camera |
sendMessage() |
Send a chat message |
sendTypingIndicator() |
Send typing indicator |
setCurrentChatSession() |
Set active chat session |
dispose() |
Clean up resources |
CallEvent Types
| Type | Description |
|---|---|
incomingCall |
Received incoming call |
outgoingCall |
Outgoing call initiated |
callAccepted |
Call was accepted |
callConnected |
Both parties connected |
callDeclined |
Call was declined |
callEnded |
Call ended normally |
callFailed |
Call failed with error |
callMissed |
Call was missed (timeout) |
remoteUserJoined |
Remote user joined room |
remoteUserLeft |
Remote user left room |
CallState
| State | Description |
|---|---|
idle |
No active call |
initiating |
Starting outgoing call |
ringing |
Waiting for answer |
connecting |
Call accepted, connecting |
connected |
Call active |
reconnecting |
Reconnecting after network loss |
ended |
Call ended |
failed |
Call failed |
License
MIT License - see LICENSE file for details.
Contributing
Contributions are welcome — please open an issue before starting significant work.
Support
- Issue Tracker
- CallKit usage - Native vs custom UI, below
- ZEGOCLOUD Documentation
CallKit usage
Overview
The useCallKit flag allows you to control whether native CallKit UI is used or if your app handles the call UI manually.
Configuration
Option 1: Use Native CallKit (Default)
When useCallKit = true (default), the native CallKit UI handles incoming calls with full-screen notification even on locked screens.
await RtcCommunicationService.initialize(
config: CommunicationConfig(
zegoConfig: ZegoConfig(
appId: YOUR_ZEGO_APP_ID,
appSign: 'YOUR_ZEGO_APP_SIGN',
),
socketConfig: SocketConfig(url: 'https://your-server.com'),
callKitConfig: CallKitConfig(
appName: 'My App',
iconName: 'app_icon',
),
useCallKit: true, // ✅ Native CallKit handles UI
),
socketAdapter: socketAdapter,
);
What happens:
- ✅ Native full-screen incoming call UI on iOS/Android
- ✅ Works on locked screen
- ✅ System ringtone plays
- ✅ User accepts/declines via native UI
- ✅ Your callbacks (
onIncomingCall,onCallAccepted, etc.) still fire for navigation
Option 2: Custom App UI
When useCallKit = false, your app handles all call UI using callbacks.
await RtcCommunicationService.initialize(
config: CommunicationConfig(
zegoConfig: ZegoConfig(
appId: YOUR_ZEGO_APP_ID,
appSign: 'YOUR_ZEGO_APP_SIGN',
),
socketConfig: SocketConfig(url: 'https://your-server.com'),
callKitConfig: CallKitConfig( // Optional: still provide config for reference
appName: 'My App',
),
useCallKit: false, // ❌ CallKit disabled - app handles UI
),
socketAdapter: socketAdapter,
);
What happens:
- ❌ No native CallKit UI
- ✅
onIncomingCallcallback fires - ✅ You show your custom incoming call screen
- ✅ User accepts/declines via your UI
- ✅ You call
rtcService.acceptCall()orrtcService.rejectCall()
Implementation Example
With Custom UI (useCallKit = false)
class MyApp extends StatefulWidget {
@override
_MyAppState createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
late RtcCommunicationService rtcService;
@override
void initState() {
super.initState();
_initializeRTC();
}
Future<void> _initializeRTC() async {
await RtcCommunicationService.initialize(
config: CommunicationConfig(
zegoConfig: ZegoConfig(
appId: YOUR_APP_ID,
appSign: 'YOUR_APP_SIGN',
),
socketConfig: SocketConfig(url: 'https://your-server.com'),
useCallKit: false, // Custom UI mode
),
socketAdapter: socketAdapter,
);
rtcService = RtcCommunicationService.instance;
// Listen to call events
rtcService.callEvents.listen((event) {
switch (event.type) {
case CallEventType.incomingCall:
// Show YOUR custom incoming call screen
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => CustomIncomingCallScreen(
callData: event.callData,
onAccept: () async {
await rtcService.acceptCall();
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => CallScreen()),
);
},
onReject: () async {
await rtcService.rejectCall();
Navigator.pop(context);
},
),
),
);
break;
case CallEventType.callEnded:
Navigator.popUntil(context, (route) => route.isFirst);
break;
// Handle other events...
}
});
}
}
// Your custom incoming call screen
class CustomIncomingCallScreen extends StatelessWidget {
final CallData callData;
final VoidCallback onAccept;
final VoidCallback onReject;
const CustomIncomingCallScreen({
required this.callData,
required this.onAccept,
required this.onReject,
});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black87,
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircleAvatar(
radius: 60,
backgroundImage: NetworkImage(callData.caller.avatarUrl ?? ''),
),
SizedBox(height: 24),
Text(
'${callData.caller.userName} is calling...',
style: TextStyle(color: Colors.white, fontSize: 24),
),
SizedBox(height: 48),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
// Reject button
IconButton(
icon: Icon(Icons.call_end, size: 40),
color: Colors.red,
onPressed: onReject,
),
// Accept button
IconButton(
icon: Icon(Icons.call, size: 40),
color: Colors.green,
onPressed: onAccept,
),
],
),
],
),
),
);
}
}
Comparison
| Feature | useCallKit = true |
useCallKit = false |
|---|---|---|
| Native full-screen UI | ✅ Yes | ❌ No |
| Works on locked screen | ✅ Yes | ❌ No (requires push notification) |
| System ringtone | ✅ Yes | ❌ No (you handle audio) |
| Custom UI design | ❌ Limited | ✅ Full control |
| iOS CallKit integration | ✅ Yes | ❌ No |
| Android ConnectionService | ✅ Yes | ❌ No |
| Implementation complexity | ⭐ Easy | ⭐⭐⭐ More work |
Use Cases
When to use useCallKit = true (Native UI)
- ✅ You want WhatsApp/Telegram-like native call experience
- ✅ Calls must work on locked screens
- ✅ You want system-level call integration
- ✅ Less implementation work
When to use useCallKit = false (Custom UI)
- ✅ You need fully custom call UI design
- ✅ You want to show additional info on incoming call screen
- ✅ Your app has unique UX requirements
- ✅ You're okay with more implementation work
- ✅ Testing/debugging without native UI complexity
Migration
If you have existing code using CallKit, nothing changes by default:
// Old code - still works the same
await RtcCommunicationService.initialize(
config: CommunicationConfig(
callKitConfig: CallKitConfig(appName: 'My App'),
// useCallKit defaults to true
),
);
To switch to custom UI:
// New code - custom UI mode
await RtcCommunicationService.initialize(
config: CommunicationConfig(
callKitConfig: CallKitConfig(appName: 'My App'),
useCallKit: false, // 🔄 Add this line
),
);
// Then implement your custom incoming call UI
rtcService.callEvents.listen((event) {
if (event.type == CallEventType.incomingCall) {
showYourCustomIncomingCallScreen();
}
});
Important Notes
-
Callbacks always fire: Regardless of
useCallKitsetting, all callbacks (onIncomingCall,onCallAccepted,onCallEnded, etc.) always fire. This allows you to navigate to the appropriate screens. -
Background calls: When
useCallKit = false, incoming calls won't work on locked screens without additional push notification setup. -
Testing: Set
useCallKit = falseduring development to test without native UI complexity. -
Platform support:
- iOS: Native CallKit fully supported
- Android: Native ConnectionService fully supported
- Both work with
useCallKit = false
Questions?
If you have questions, check the main README.md or open an issue.
CallKit flag history
Overview
Added useCallKit boolean flag to make native CallKit UI optional. When disabled, the app handles call UI using callbacks.
Changes Made
1. RtcCallController (lib/src/controllers/rtc_call_controller.dart)
Added Fields
final bool useCallKit; // Flag to enable/disable CallKit (line 47)
Constructor Changes
RtcCallController({
// ... existing parameters
this.useCallKit = true, // NEW: Default to true (backward compatible)
// ...
})
Initialization Logic
- Only initializes CallKit when BOTH conditions are true:
nativeCallKitConfig != nulluseCallKit == true
Updated Methods
All methods that interact with CallKit now check the flag:
init()- Only initializes CallKit ifuseCallKit == truereceiveIncomingCall()- Only shows CallKit UI ifuseCallKit == true- Important:
onIncomingCallcallback ALWAYS fires regardless of flag
- Important:
rejectCall()- Only ends CallKit ifuseCallKit == trueendCall()- Only ends CallKit ifuseCallKit == truemarkCallConnected()- Only marks CallKit connected ifuseCallKit == true_handleCallAnswerEvent()- Only marks CallKit connected ifuseCallKit == true_handleCallRejectEvent()- Only ends CallKit ifuseCallKit == true_handleCallCancelEvent()- Only ends CallKit ifuseCallKit == truedispose()- Only disposes CallKit ifuseCallKit == true
2. CommunicationConfig (lib/src/api/models/communication_config.dart)
Added Field
/// Enable native CallKit UI
/// When true: Native CallKit handles incoming call UI
/// When false: App handles UI using callbacks (onIncomingCall, etc.)
/// Default: true (use CallKit if config is provided)
final bool useCallKit;
Constructor Changes
const CommunicationConfig({
// ... existing parameters
this.useCallKit = true, // NEW: Default to true
// ...
});
3. RtcCommunicationService (lib/src/api/rtc_communication_service.dart)
Updated Initialization
_rtcController = RtcCallController(
// ... existing parameters
useCallKit: _config.useCallKit, // NEW: Pass through from config
// ...
);
4. Bug Fixes
Fixed Syntax Error (line 365)
// Before
} else{
// After
} else {
5. Documentation
Documented in the CallKit usage section of this README.
- Comprehensive guide on using the new flag
- Code examples for both modes
- Comparison table
- Migration guide
- Use case recommendations
Backward Compatibility
✅ 100% Backward Compatible
Default value is true, so existing code continues to work exactly as before:
// Existing code - no changes needed
await RtcCommunicationService.initialize(
config: CommunicationConfig(
callKitConfig: CallKitConfig(appName: 'My App'),
// useCallKit defaults to true
),
);
Usage Examples
Native CallKit (Default)
CommunicationConfig(
callKitConfig: CallKitConfig(appName: 'My App'),
useCallKit: true, // or omit - defaults to true
)
Custom App UI
CommunicationConfig(
callKitConfig: CallKitConfig(appName: 'My App'),
useCallKit: false, // CallKit disabled
)
// Handle incoming calls in your app
rtcService.callEvents.listen((event) {
if (event.type == CallEventType.incomingCall) {
showMyCustomIncomingCallScreen();
}
});
Benefits
- Flexibility: Developers can choose native or custom UI
- Testing: Easier to test without native UI complexity
- Custom UX: Full control over call UI design
- Backward Compatible: No breaking changes
- Clean Architecture: Flag is passed through all layers
Testing Checklist
xCallKit works whenuseCallKit = truexCallKit is bypassed whenuseCallKit = falsexCallbacks fire in both modesxNo null pointer errorsxBackward compatibility maintainedIntegration tests with real ZEGO callsTest on iOS and Android
Next Steps
- Test with real calls on iOS/Android
- Update main README.md with
useCallKitdocumentation - Add unit tests for flag behavior
- Consider adding to example app
Files Modified
lib/src/controllers/rtc_call_controller.dartlib/src/api/models/communication_config.dartlib/src/api/rtc_communication_service.dart- The CallKit usage section of this README
CHANGELOG_CALLKIT_FLAG.md(this file)
Libraries
- call_kit_for_zego
- RTC Call Kit - A comprehensive Flutter package for real-time audio/video calling