callbundle 1.0.15
callbundle: ^1.0.15 copied to clipboard
Native incoming & outgoing call UI for Flutter. Provides CallKit on iOS and TelecomManager + adaptive notifications on Android.
CallBundle #
Native incoming & outgoing call UI for Flutter. Provides CallKit on iOS and TelecomManager + OEM-adaptive notifications on Android.
Table of Contents #
- Installation
- Platform Setup
- Basic Usage
- API Reference
- Permissions
- FCM Integration
- iOS VoIP Push (PushKit)
- Cold-Start Handling
- Event Handling
- Configuration Options
- Background Reject (Killed State)
- Advanced Usage
Installation #
dependencies:
callbundle: ^1.0.0
The Android (callbundle_android) and iOS (callbundle_ios) packages are endorsed — they are automatically included. No additional dependency lines needed.
Platform Setup #
Android #
No additional setup needed. The plugin ships:
- AndroidManifest.xml with all required permissions (auto-merged)
- Consumer ProGuard rules (no app-level rules needed)
- ConnectionService and BroadcastReceiver registration
Permissions shipped by the plugin:
FOREGROUND_SERVICE
FOREGROUND_SERVICE_PHONE_CALL
USE_FULL_SCREEN_INTENT
MANAGE_OWN_CALLS
WAKE_LOCK
VIBRATE
POST_NOTIFICATIONS
READ_PHONE_STATE
READ_PHONE_NUMBERS
SYSTEM_ALERT_WINDOW
REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
iOS #
Add the VoIP background mode to your Info.plist:
<key>UIBackgroundModes</key>
<array>
<string>voip</string>
</array>
The plugin handles PushKit registration internally — no AppDelegate code needed.
For complete iOS setup including VoIP certificate configuration, see the callbundle_ios README.
Basic Usage #
import 'package:callbundle/callbundle.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// 1. Listen for call events BEFORE configure
CallBundle.onEvent.listen(_handleCallEvent);
// 2. Configure the plugin
await CallBundle.configure(const NativeCallConfig(
appName: 'MyApp',
android: AndroidCallConfig(
phoneAccountLabel: 'MyApp Calls',
notificationChannelName: 'Incoming Calls',
),
ios: IosCallConfig(
supportsVideo: false,
maximumCallGroups: 1,
maximumCallsPerCallGroup: 1,
includesCallsInRecents: true,
),
));
// 3. Check and request permissions
await _handlePermissions();
runApp(const MyApp());
}
void _handleCallEvent(NativeCallEvent event) {
switch (event.type) {
case NativeCallEventType.accepted:
// User tapped Accept — connect VoIP
connectToRoom(event.callId, event.extra);
break;
case NativeCallEventType.declined:
// User tapped Decline
notifyServerCallDeclined(event.callId);
break;
case NativeCallEventType.ended:
if (event.isUserInitiated) {
// User ended from native UI
disconnectFromRoom(event.callId);
}
// else: programmatic end from your code, already handled
break;
default:
break;
}
}
API Reference #
CallBundle (Static API) #
| Method | Returns | Description |
|---|---|---|
configure(NativeCallConfig) |
Future<void> |
Initialize plugin. Call once at startup. |
showIncomingCall(NativeCallParams) |
Future<void> |
Show native incoming call UI. |
showOutgoingCall(NativeCallParams) |
Future<void> |
Show native outgoing call UI. |
endCall(String callId) |
Future<void> |
End a specific call. |
endAllCalls() |
Future<void> |
End all active calls. |
setCallConnected(String callId) |
Future<void> |
Mark call as connected/active. |
getActiveCalls() |
Future<List<NativeCallInfo>> |
Get all active calls. |
checkPermissions() |
Future<NativeCallPermissions> |
Check status without prompting. |
requestPermissions() |
Future<NativeCallPermissions> |
Request permissions (triggers system dialogs). |
requestBatteryOptimizationExemption() |
Future<bool> |
Request Doze mode exemption (Android). |
getVoipToken() |
Future<String?> |
Get iOS VoIP push token. |
onEvent |
Stream<NativeCallEvent> |
All native call events. |
onReady |
Future<void> |
Completes when native side is ready. |
dispose() |
Future<void> |
Release all resources. |
NativeCallConfig #
NativeCallConfig(
appName: 'MyApp', // Required
backgroundReject: BackgroundRejectConfig( // Optional killed-state reject
urlPattern: 'https://api.example.com/v1/api/calls/{callId}/reject',
authStorageKey: 'access_token',
),
android: const AndroidCallConfig(
phoneAccountLabel: 'MyApp Calls', // TelecomManager label
notificationChannelName: 'Calls', // Notification channel name
oemAdaptiveMode: true, // Budget OEM detection
),
ios: IosCallConfig(
supportsVideo: false, // Video call support
maximumCallGroups: 1, // Max concurrent call groups
maximumCallsPerCallGroup: 1, // Max calls per group
includesCallsInRecents: true, // Show in Phone app Recents
iconTemplateImageName: null, // Custom CallKit icon
ringtoneSound: null, // Custom ringtone filename
),
)
NativeCallParams #
NativeCallParams(
callId: 'unique-id', // Required — unique identifier
callerName: 'John Doe', // Required — displayed to user
handle: '+1234567890', // Phone number or identifier
callType: NativeCallType.voice, // voice or video
duration: 60000, // Auto-dismiss timeout (ms)
callerAvatar: 'https://...', // Avatar URL (both platforms)
extra: {'roomId': 'abc'}, // Pass-through metadata
android: const AndroidCallParams(), // Android-specific options
ios: const IosCallParams( // iOS-specific options
handleType: NativeHandleType.phone,
),
)
NativeCallEvent #
| Property | Type | Description |
|---|---|---|
type |
NativeCallEventType |
accepted, declined, ended, incoming, missed, timedOut |
callId |
String |
The call identifier |
isUserInitiated |
bool |
true if user tapped the native UI button |
extra |
Map<String, dynamic> |
Pass-through metadata from NativeCallParams.extra |
eventId |
int |
Monotonic ID for deduplication |
timestamp |
DateTime |
When the event occurred |
NativeCallPermissions #
| Property | Type | Description |
|---|---|---|
notificationPermission |
PermissionStatus |
Notification permission status |
fullScreenIntentPermission |
PermissionStatus |
Full-screen intent (Android 14+) |
phoneAccountEnabled |
bool |
TelecomManager account registered |
batteryOptimizationExempt |
bool |
Exempt from battery optimization |
oemAutoStartEnabled |
bool |
OEM auto-start enabled |
manufacturer |
String |
Device manufacturer |
model |
String |
Device model |
osVersion |
String |
OS version string |
diagnosticInfo |
Map? |
OEM detection diagnostics |
isFullyReady |
bool |
All critical permissions granted |
Permissions #
CallBundle provides a Dart-driven permission flow — check silently, show your own UI, then request:
Future<void> _handlePermissions() async {
// 1. Check current status (no prompts)
final status = await CallBundle.checkPermissions();
if (status.notificationPermission != PermissionStatus.granted) {
// 2. Show YOUR custom explanation dialog
final agreed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Enable Notifications'),
content: const Text(
'We need notification permission to show incoming call '
'alerts. Without this, you may miss important calls.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Not Now'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Allow'),
),
],
),
);
// 3. Request only if user agreed
if (agreed == true) {
final result = await CallBundle.requestPermissions();
print('After request: ${result.notificationPermission.name}');
}
}
}
What requestPermissions() does per platform #
| Platform | Action |
|---|---|
| Android 13+ | System dialog for POST_NOTIFICATIONS |
| Android 14+ | Opens Settings for USE_FULL_SCREEN_INTENT |
| Android < 13 | No dialog needed (auto-granted) |
| iOS | UNUserNotificationCenter.requestAuthorization() |
Battery Optimization Exemption #
Battery optimization (Doze mode) on Android can prevent incoming calls from being delivered reliably:
final perms = await CallBundle.checkPermissions();
if (!perms.batteryOptimizationExempt) {
final shouldRequest = await showBatteryExplanationDialog();
if (shouldRequest) {
final exempt = await CallBundle.requestBatteryOptimizationExemption();
if (!exempt) {
// System dialog shown — re-check after user returns
final newPerms = await CallBundle.checkPermissions();
print('Exempt: ${newPerms.batteryOptimizationExempt}');
}
}
}
| Platform | checkPermissions() |
requestBatteryOptimizationExemption() |
|---|---|---|
| Android 23+ | PowerManager.isIgnoringBatteryOptimizations() |
Opens ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS |
| Android < 23 | Returns true |
Returns true |
| iOS | Returns true |
Returns true (not applicable) |
FCM Integration #
CallBundle handles the native call UI — your app handles push delivery:
@pragma('vm:entry-point')
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
WidgetsFlutterBinding.ensureInitialized();
await CallBundle.configure(const NativeCallConfig(
appName: 'MyApp',
android: AndroidCallConfig(phoneAccountLabel: 'MyApp Calls'),
ios: IosCallConfig(),
));
await CallBundle.showIncomingCall(NativeCallParams(
callId: message.data['callId'] ?? '',
callerName: message.data['callerName'] ?? 'Unknown',
handle: message.data['handle'] ?? '',
callType: NativeCallType.voice,
extra: message.data,
android: const AndroidCallParams(),
ios: const IosCallParams(),
));
}
iOS VoIP Push (PushKit) #
On iOS, use VoIP pushes for the most reliable incoming call experience. The plugin handles PushKit internally and reports the incoming call to CallKit synchronously (required by iOS).
// Get the VoIP token to register with your server
final token = await CallBundle.getVoipToken();
if (token != null) {
await registerTokenWithServer(token);
}
For setting up VoIP push certificates (PEM file creation, APNs configuration), see the callbundle_ios README — VoIP Certificate Setup.
Cold-Start Handling #
When the app is killed and a user taps Accept on a notification:
Android Flow #
1. User taps Accept on notification
2. CallActionReceiver.onReceive() fires
3. If plugin alive → event delivered immediately via onEvent
4. If plugin null → PendingCallStore.savePendingAccept()
5. App restarts → configure() → deliverPendingEvents() → event delivered
iOS Flow #
1. VoIP push arrives → PushKit wakes app
2. reportNewIncomingCall() called synchronously
3. User taps Accept → CallKit delegate fires
4. If Dart ready → event sent immediately
5. If Dart not ready → CallStore.savePendingAccept()
6. Dart calls configure() → deliverPendingEvents() → event delivered
No hardcoded delays. Events are delivered as soon as configure() completes.
// Always listen BEFORE configure to catch cold-start events
CallBundle.onEvent.listen((event) {
if (event.type == NativeCallEventType.accepted) {
connectToVoipRoom(event.callId, event.extra);
}
});
await CallBundle.configure(config); // Pending events delivered here
Event Handling #
The isUserInitiated Pattern #
Every event includes isUserInitiated to distinguish user actions from programmatic actions:
CallBundle.onEvent.listen((event) {
if (event.type == NativeCallEventType.ended) {
if (event.isUserInitiated) {
// User tapped "End Call" on native UI
disconnectRoom(event.callId);
notifyServer(event.callId, 'ended_by_user');
} else {
// Your code called CallBundle.endCall()
// No action needed — avoid double-disconnect
}
}
});
This eliminates the _isEndingCallKitProgrammatically flag pattern.
Complete event handler #
CallBundle.onEvent.listen((event) {
switch (event.type) {
case NativeCallEventType.incoming:
prepareVoipConnection(event.callId);
break;
case NativeCallEventType.accepted:
connectToRoom(event.callId, event.extra);
break;
case NativeCallEventType.declined:
notifyServerCallDeclined(event.callId);
break;
case NativeCallEventType.ended:
if (event.isUserInitiated) {
disconnectRoom(event.callId);
notifyServer(event.callId, 'ended');
}
break;
case NativeCallEventType.missed:
showMissedCallNotification(event.callId);
break;
default:
break;
}
});
Configuration Options #
AndroidCallConfig #
| Property | Type | Default | Description |
|---|---|---|---|
phoneAccountLabel |
String |
Required | TelecomManager registration label |
notificationChannelName |
String? |
null |
Notification channel display name |
notificationChannelId |
String? |
null |
Custom notification channel ID |
useTelecomManager |
bool |
true |
Use ConnectionService + TelecomManager |
oemAdaptiveMode |
bool |
true |
Auto-detect budget OEMs |
IosCallConfig #
| Property | Type | Default | Description |
|---|---|---|---|
supportsVideo |
bool |
false |
Enable video call support |
maximumCallGroups |
int |
1 |
Max concurrent call groups |
maximumCallsPerCallGroup |
int |
1 |
Max calls per group |
includesCallsInRecents |
bool |
true |
Show in Phone app Recents |
iconTemplateImageName |
String? |
null |
Custom CallKit icon asset |
ringtoneSound |
String? |
null |
Custom ringtone filename |
Background Reject (Killed State) #
When the user declines a call from the notification while the app is killed, the Dart isolate is unavailable. BackgroundRejectConfig enables a direct native HTTP request from Kotlin, bypassing Dart entirely:
await CallBundle.configure(NativeCallConfig(
appName: 'MyApp',
backgroundReject: BackgroundRejectConfig(
urlPattern: 'https://api.example.com/v1/api/calls/{callId}/reject',
httpMethod: 'PUT',
authStorageKey: 'access_token',
headers: {'X-Call-Id': '{callId}'},
body: '{"reason": "user_declined"}',
refreshToken: RefreshTokenConfig(
url: 'https://api.example.com/v1/auth/refresh-token',
refreshTokenKey: 'refresh_token',
bodyTemplate: '{"refreshToken": "{refreshToken}"}',
accessTokenJsonPath: 'data.accessToken',
refreshTokenJsonPath: 'data.refreshToken',
),
),
android: const AndroidCallConfig(phoneAccountLabel: 'MyApp'),
ios: const IosCallConfig(),
));
| Property | Type | Default | Description |
|---|---|---|---|
urlPattern |
String |
Required | Full URL with {key} placeholders |
httpMethod |
String |
'PUT' |
HTTP method |
authStorageKey |
String? |
null |
Key in flutter_secure_storage for Bearer token |
authKeyPrefix |
String? |
null |
Custom key prefix for flutter_secure_storage |
headers |
Map<String, String> |
{} |
Additional request headers |
body |
String? |
null |
Request body (supports {key} placeholders) |
Dynamic Placeholders #
| Placeholder | Description |
|---|---|
{callId} |
Unique call identifier |
{callerName} |
Display name of the caller |
{callType} |
Type of call (voice, video) |
{handle} |
Phone number or SIP address |
{uuid} |
Auto-generated UUID per request |
| any custom key | Any extra from the notification |
iOS: Not needed — CallKit/PushKit keep the app alive during calls.
For detailed background reject and token refresh docs, see the callbundle_android README.
Advanced Usage #
Outgoing calls #
await CallBundle.showOutgoingCall(NativeCallParams(
callId: 'outgoing-123',
callerName: 'Jane Smith',
handle: '+1987654321',
callType: NativeCallType.voice,
android: const AndroidCallParams(),
ios: const IosCallParams(),
));
// When VoIP connects:
await CallBundle.setCallConnected('outgoing-123');
// When done:
await CallBundle.endCall('outgoing-123');
Get active calls #
final calls = await CallBundle.getActiveCalls();
for (final call in calls) {
print('${call.callerName} — ${call.state.name}');
}
Wait for native readiness #
await CallBundle.onReady;