callbundle_ios 1.1.1
callbundle_ios: ^1.1.1 copied to clipboard
iOS implementation of the CallBundle plugin using CallKit. Handles PushKit, audio session, and missed call notifications inside the plugin.
callbundle_ios #
The iOS implementation of callbundle.
Table of Contents #
- Usage
- Architecture
- VoIP Certificate Setup (PEM File)
- PushKit Integration
- CallKit Integration
- Audio Session Management
- Cold-Start Persistence
- Caller Avatar
- Thread Safety
- Permissions
- Requirements
Usage #
This package is endorsed — simply add callbundle to your pubspec.yaml and this package is included automatically on iOS.
dependencies:
callbundle: ^1.0.0
Add the VoIP background mode to your Info.plist:
<key>UIBackgroundModes</key>
<array>
<string>voip</string>
</array>
Architecture #
| Component | File | Responsibility |
|---|---|---|
CallBundlePlugin |
CallbundleIosPlugin.swift |
MethodChannel handler, singleton, event dispatch |
CallKitController |
CallKitController.swift |
CXProvider + CXCallController for native call UI |
PushKitHandler |
PushKitHandler.swift |
PKPushRegistry delegate, VoIP token management |
AudioSessionManager |
AudioSessionManager.swift |
AVAudioSession configuration + route handling |
CallStore |
CallStore.swift |
Thread-safe call tracking + cold-start persistence |
MissedCallNotificationManager |
MissedCallNotificationManager.swift |
UNUserNotificationCenter for missed calls (with avatar attachment) |
VoIP Certificate Setup (PEM File) #
iOS requires a VoIP push certificate to send VoIP pushes via Apple Push Notification service (APNs). This section covers creating the VoIP certificate and exporting it as a PEM file for your server.
Step 1: Create VoIP Services Certificate #
- Go to Apple Developer — Certificates
- Click + to create a new certificate
- Under Services, select VoIP Services Certificate
- Click Continue
- Select your App ID (must match your app's bundle identifier)
- Click Continue
- Upload a Certificate Signing Request (CSR):
- Open Keychain Access on your Mac
- Menu: Keychain Access → Certificate Assistant → Request a Certificate From a Certificate Authority
- Enter your email, select Saved to disk, click Continue
- Save the
.certSigningRequestfile
- Upload the CSR and click Continue
- Download the generated
.cerfile
Step 2: Export as PEM File #
- Double-click the downloaded
.cerfile to install it in Keychain Access - In Keychain Access, find the certificate under My Certificates:
- It will be named VoIP Services: com.yourcompany.yourapp
- Right-click the certificate → Export → choose
.p12format - Set an export password (you'll need it in the next step)
- Convert
.p12to.pemusing Terminal:
# Extract the certificate
openssl pkcs12 -in voip_cert.p12 -out voip_cert.pem -nodes -clcerts
# Or split into certificate and key files (some servers require this)
openssl pkcs12 -in voip_cert.p12 -out voip_cert_only.pem -nodes -nokeys
openssl pkcs12 -in voip_cert.p12 -out voip_key.pem -nodes -nocerts
Step 3: Configure Your Server #
Your push server needs the PEM file to send VoIP pushes to APNs. Here's an example using curl:
# Send a VoIP push to APNs (Development)
curl -v \
--cert voip_cert.pem \
--header "apns-topic: com.yourcompany.yourapp.voip" \
--header "apns-push-type: voip" \
--header "apns-priority: 10" \
--header "apns-expiration: 0" \
--data '{"callId":"abc-123","callerName":"John Doe","handle":"+1234567890","callerAvatar":"https://example.com/photo.jpg"}' \
https://api.sandbox.push.apple.com/3/device/<VOIP_DEVICE_TOKEN>
# Production
# Replace api.sandbox.push.apple.com with api.push.apple.com
APNs Payload Format #
The plugin expects these fields in the VoIP push payload:
{
"callId": "unique-call-id",
"callerName": "John Doe",
"handle": "+1234567890",
"hasVideo": false,
"callerAvatar": "https://example.com/photos/john.jpg"
}
| Field | Type | Required | Description |
|---|---|---|---|
callId |
String |
Yes | Unique call identifier |
callerName |
String |
Yes | Displayed on the CallKit screen |
handle |
String |
No | Phone number or SIP address |
hasVideo |
Bool |
No | Show video call UI (default: false) |
callerAvatar |
String |
No | URL for caller profile photo (used in missed call notifications) |
Alternative field names are also supported: caller_name, phone, has_video, caller_avatar.
Step 4: Token-Based Authentication (Alternative) #
Instead of PEM certificates, you can use APNs token-based authentication (.p8 key). This is recommended for new projects:
- Go to Apple Developer — Keys
- Click + → Enable Apple Push Notifications service (APNs) → Download the
.p8file - Note the Key ID and your Team ID
- Use these with your server's APNs library (no PEM needed)
# Example with curl + JWT token (p8-based auth)
curl -v \
--header "authorization: bearer <JWT_TOKEN>" \
--header "apns-topic: com.yourcompany.yourapp.voip" \
--header "apns-push-type: voip" \
--header "apns-priority: 10" \
--data '{"callId":"abc-123","callerName":"John Doe"}' \
https://api.push.apple.com/3/device/<VOIP_DEVICE_TOKEN>
Important Notes #
- VoIP certificates are separate from regular APNs certificates
- The APNs topic for VoIP pushes must end with
.voip(e.g.,com.yourapp.voip) - VoIP certificates expire after 1 year — set a reminder to renew
- Development (sandbox) and Production use different APNs endpoints
- The VoIP token from
CallBundle.getVoipToken()is device-specific and changes on reinstall
PushKit Integration #
PushKit VoIP push is handled inside the plugin. No AppDelegate code needed.
When a VoIP push arrives:
PushKitHandlerreceives the payload viaPKPushRegistryDelegatereportNewIncomingCallis called synchronously (required by iOS — app is terminated if not)- CallKit shows the native incoming call screen
- User interaction → event delivered to Dart via MethodChannel
Getting the VoIP Token #
final token = await CallBundle.getVoipToken();
if (token != null) {
// Send this hex string token to your push server
await registerTokenWithServer(token);
}
The token is a hex-encoded string of the device's PushKit credentials. It's updated automatically — listen for onVoipTokenUpdated events for real-time updates.
How PushKit Differs from FCM #
| Feature | PushKit (VoIP) | FCM |
|---|---|---|
| Wake on delivery | Always (even killed) | Not guaranteed |
| Background processing | Guaranteed | Limited |
| CallKit requirement | Must report call synchronously | Not required |
| Token type | Separate VoIP token | FCM token |
| Best for | iOS incoming calls | Android + cross-platform |
Recommendation: Use PushKit for iOS incoming calls and FCM for Android.
CallKit Integration #
Full CXProvider delegate implementation:
- Incoming calls:
reportNewIncomingCallwith caller name, handle, and video support - Outgoing calls:
CXStartCallActionfor the green status bar indicator - Call connected/ended: State management via
CXCallController isUserInitiated: Every event correctly flags whether the user or the system/app initiated it
Programmatic vs User-Initiated Ends #
The plugin tracks programmaticEndUUIDs — when your code calls CallBundle.endCall(), the resulting CXEndCallAction is correctly flagged as isUserInitiated: false. This eliminates the _isEndingCallKitProgrammatically flag pattern.
Declined vs Ended #
CallKit delivers both "Decline" on the incoming call screen and "End" on an
active call as CXEndCallAction. The plugin tracks which incoming calls have
not been answered yet, so the two are reported distinctly — matching Android:
| User action | Event | isUserInitiated |
|---|---|---|
| Taps Decline on an unanswered incoming call | declined |
true |
| Taps End on a call they answered | ended |
true |
| Ends an outgoing call | ended |
true |
CallBundle.endCall() from Dart (e.g. caller cancelled) |
ended |
false |
Audio Session Management #
AudioSessionManager configures AVAudioSession the way CallKit expects:
- Category:
.playAndRecord, mode.voiceChat, options[.allowBluetooth] - Configured before activation: the category is applied when the call is
reported/answered/started — never for the first time inside
provider(_:didActivate:). Configuring a session CallKit has already activated desynchronizes CallKit's speaker button from the real output route. - Activation: CallKit activates the session (
provider(_:didActivate:)); deactivation happens on call end. - Route defence: the manager observes
AVAudioSession.routeChangeNotificationand re-applies the user's speaker/receiver choice if another audio client (a WebRTC engine initializing after a cold start, HMS/100ms, …) reconfigures the session mid-call. External routes — headphones, Bluetooth, CarPlay — are never overridden.
Note:
.defaultToSpeakerand.mixWithOthersare deliberately not used while a call is up. CallKit owns the output route:.defaultToSpeakermakes "speaker off" resolve back to the speaker, and.mixWithOthersmarks the session as non-primary so CallKit's speaker button cannot change the route.
Cold-Start Persistence #
CallStore handles the case where the user answers a call before the Dart engine is ready:
- VoIP push arrives → CallKit shows incoming call
- User taps Accept →
CXAnswerCallActionfires - If Dart not ready →
CallStore.savePendingAccept()(backed byUserDefaults.synchronize()) - Dart calls
CallBundle.configure()→deliverPendingEvents()delivers stored event - No hardcoded delays — events delivered as soon as Dart is ready
Declines are persisted the same way (savePendingDecline()), so a call
rejected before the engine is ready still reaches Dart on the next start and
your backend can be told the call was rejected.
Call metadata (extra, callerName, handle, callerAvatar) is preserved through the store.
Caller Avatar #
When callerAvatar is provided (via NativeCallParams or the VoIP push payload), the plugin uses it for missed call notifications:
MissedCallNotificationManagerdownloads the image from the URL asynchronously- The image is saved to a temp file and attached via
UNNotificationAttachment - iOS displays the avatar as the notification thumbnail
- If the download fails, the notification is shown without an avatar (graceful fallback)
Note: CallKit's incoming call UI uses the contact's photo from
Contacts.framework— thecallerAvatarfield does not affect the CallKit screen.
Thread Safety #
All CallStore operations use a serial DispatchQueue:
private let queue = DispatchQueue(label: "com.callbundle.callstore", qos: .userInitiated)
This ensures thread-safe access from multiple callbacks (PushKit, CallKit, MethodChannel).
Permissions #
checkPermissions: ReadsUNNotificationSettingswithout requesting — no system dialogrequestPermissions: CallsUNUserNotificationCenter.requestAuthorization()— triggers system dialog- Battery optimization: Returns
true(not applicable on iOS)
Requirements #
| Requirement | Value |
|---|---|
| iOS | 13.0+ |
| Swift | 5.0+ |
| CocoaPods | 1.10+ |
iOS Frameworks Used #
| Framework | Purpose |
|---|---|
CallKit |
Native incoming/outgoing call UI |
PushKit |
VoIP push notification delivery |
AVFoundation |
Audio session management |
UserNotifications |
Missed call notifications |