callbundle_android
The Android implementation of callbundle.
Table of Contents
- Usage
- Architecture
- OEM-Adaptive Notifications
- Cold-Start Event Persistence
- Background Call Rejection (Killed State)
- Automatic Token Refresh
- Consumer ProGuard Rules
- Permissions
- Battery Optimization Exemption
- Requirements
Usage
This package is endorsed — simply add callbundle to your pubspec.yaml and this package is included automatically on Android.
dependencies:
callbundle: ^1.0.0
No additional Android setup needed. The plugin ships AndroidManifest.xml with all required permissions, ConnectionService registration, and consumer ProGuard rules.
Architecture
| Component | File | Responsibility |
|---|---|---|
CallBundlePlugin |
CallBundlePlugin.kt |
MethodChannel handler, lifecycle, permission requests |
CallConnectionService |
CallConnectionService.kt |
Android TelecomManager ConnectionService |
NotificationHelper |
NotificationHelper.kt |
OEM-adaptive notification builder |
CallStateManager |
CallStateManager.kt |
Thread-safe in-memory call tracking |
PendingCallStore |
PendingCallStore.kt |
SharedPreferences cold-start event persistence |
BackgroundCallRejectHelper |
BackgroundCallRejectHelper.kt |
Native HTTP reject for killed-state decline |
CallActionReceiver |
CallActionReceiver.kt |
BroadcastReceiver for notification actions |
OemDetector |
OemDetector.kt |
Budget OEM manufacturer detection |
OEM-Adaptive Notifications
The plugin auto-detects the device manufacturer and selects the optimal notification strategy:
- Modern OEMs (API 31+):
CallStyle.forIncomingCall()— native system-style incoming call notification - Standard OEMs (API 26-30): High-priority notification with Accept/Decline action buttons
- Budget OEMs (Xiaomi, Oppo, Vivo, Realme, etc.): Simplest layout — avoids
RemoteViewsinflation failures common on budget devices
Static Media Resources
Ringtone (mediaPlayer) and vibration (vibrator) instances are static/companion fields shared across all NotificationHelper instances. This ensures reliable cleanup across background FCM engine instances.
Notification Auto-Timeout
Incoming call notifications auto-dismiss after the configured duration (default 60s). A timedOut event is sent to Dart. This acts as a safety net for delayed call_cancelled FCM messages.
Cold-Start Event Persistence
When the app is killed and user taps Accept or Decline:
CallActionReceiver.onReceive()fires (works even when app is killed)- If plugin is alive → normal event flow via MethodChannel
- If plugin is null →
PendingCallStore.savePendingAccept()viaSharedPreferences.commit()(synchronous) - App restarts →
configure()→deliverPendingEvents()→ event delivered to Dart
Accept Button Implementation
The Accept button uses PendingIntent.getActivity() instead of getBroadcast(). This provides a strong OS-level Background Activity Launch (BAL) exemption that works on Android 12+ and all OEMs:
- Background state: Intent handled in
onNewIntent - Killed state: Intent handled in
onAttachedToActivity
Background Call Rejection (Killed State)
When the app is killed and user taps Decline:
CallActionReceiverfires → cancels notification + stops ringtone (immediate)BackgroundCallRejectHelper.rejectCall()makes a native HTTP request directly from Kotlin- Reads auth token from
EncryptedSharedPreferences(same store asflutter_secure_storage) using the correct key prefix - URL, method, headers, and body are configured via
BackgroundRejectConfigduringconfigure() {callId}placeholder is supported in URL, body, and header values{uuid}is a special placeholder — auto-generated as a freshUUID.randomUUID()per request- Custom
authKeyPrefixis supported for apps using non-defaultflutter_secure_storagekey prefixes - As fallback,
PendingCallStore.savePendingDecline()persists the event for delivery on next app start
This bypasses Dart entirely — the MethodChannel event stream is unreliable in killed state.
Configuration
BackgroundRejectConfig(
urlPattern: 'https://api.example.com/v1/api/calls/{callId}/reject',
httpMethod: 'PUT',
authStorageKey: 'access_token',
// authKeyPrefix: 'custom_prefix', // Only if using custom AndroidOptions(preferencesKeyPrefix:)
headers: {
'Content-Type': 'application/json',
'X-Trail-ID': '{uuid}', // Auto-generated per request
},
body: '{"reason": "user_declined"}', // {callId} supported in body too
)
Key Prefix
flutter_secure_storage prefixes all keys in EncryptedSharedPreferences with a namespace string. The default prefix is VGhpcyBpcyB0aGUgcHJlZml4IGZvciBhIHNlY3VyZSBzdG9yYWdlCg (base64 of "This is the prefix for a secure storage"). This package handles the prefix automatically — only set authKeyPrefix if your app uses a custom prefix via AndroidOptions(preferencesKeyPrefix:).
Dynamic Placeholders
| Placeholder | Resolved To | Available In |
|---|---|---|
{callId} |
Unique call identifier | URL, headers, body |
{callerName} |
Display name of the caller | URL, headers, body |
{callType} |
voice or video | URL, headers, body |
{handle} |
Phone number or SIP address | URL, headers, body |
{callerAvatar} |
Avatar URL | URL, headers, body |
{uuid} |
Fresh UUID.randomUUID() per request |
URL, headers, body |
| any custom key | Any extra from notification | URL, headers, body |
{uuid}is synthesized at request time. All other placeholders come from call metadata. Unmatched placeholders are left as-is.
Automatic Token Refresh
When a native reject call receives a 401 Unauthorized, the plugin automatically:
- Reads the refresh token from
flutter_secure_storage(EncryptedSharedPreferences) - Makes an HTTP request to the configured refresh endpoint
- Parses the new access token from the JSON response using dot-notation path
- Stores the new access token (and optionally new refresh token) back in secure storage
- Retries the original reject request with the new token
Configuration
RefreshTokenConfig(
url: 'https://api.example.com/v1/auth/refresh-token',
httpMethod: 'POST',
refreshTokenKey: 'refresh_token',
bodyTemplate: '{"refreshToken": "{refreshToken}"}',
accessTokenJsonPath: 'data.accessToken',
refreshTokenJsonPath: 'data.refreshToken', // If server rotates tokens
headers: {
'Content-Type': 'application/json',
},
)
Properties
| Property | Type | Default | Description |
|---|---|---|---|
url |
String |
required | Full URL of the refresh token endpoint |
httpMethod |
String |
'POST' |
HTTP method for the refresh request |
refreshTokenKey |
String |
required | Key in flutter_secure_storage for the refresh token |
bodyTemplate |
String |
'{"refreshToken": "{refreshToken}"}' |
Request body with {refreshToken} placeholder |
accessTokenJsonPath |
String |
required | Dot-notation path to access token in response |
refreshTokenJsonPath |
String? |
null |
Dot-notation path to new refresh token |
headers |
Map<String, String> |
{} |
Additional headers for the refresh request |
JSON Path Resolution
// Response: {"data": {"accessToken": "new-jwt", "refreshToken": "new-rt"}}
// accessTokenJsonPath: "data.accessToken" → "new-jwt"
// refreshTokenJsonPath: "data.refreshToken" → "new-rt"
Consumer ProGuard Rules
Shipped in proguard-rules.pro — automatically applied to consumer apps. No app-level ProGuard configuration needed.
Permissions
The plugin's AndroidManifest.xml includes all required permissions (auto-merged):
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
<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.WAKE_LOCK" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.READ_PHONE_NUMBERS" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
Permission Requesting
checkPermissions: Returns current status without triggering any system dialogsrequestPermissions: Triggers system dialogs forPOST_NOTIFICATIONS(Android 13+) and opens Settings forUSE_FULL_SCREEN_INTENT(Android 14+)
Battery Optimization Exemption
Battery optimization (Doze mode) can prevent incoming calls from being delivered reliably.
final perms = await CallBundle.checkPermissions();
if (!perms.batteryOptimizationExempt) {
final exempt = await CallBundle.requestBatteryOptimizationExemption();
// Opens ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS dialog
}
| Platform | checkPermissions() |
requestBatteryOptimizationExemption() |
|---|---|---|
| Android 23+ | PowerManager.isIgnoringBatteryOptimizations() |
Opens system dialog |
| Android < 23 | Returns true (Doze didn't exist) |
Returns true |
Requirements
| Requirement | Value |
|---|---|
| Min SDK | 21 (Android 5.0) |
| Compile SDK | 35 |
| Kotlin | 1.9+ |
Links
Libraries
- callbundle_android
- Android implementation of the CallBundle plugin.