callbundle_android 1.0.14
callbundle_android: ^1.0.14 copied to clipboard
Android implementation of the CallBundle federated Flutter plugin. Provides ConnectionService + TelecomManager + OEM-adaptive notifications.
callbundle_android #
The Android implementation of callbundle.
Table of Contents #
- Usage
- Architecture
- OEM-Adaptive Notifications
- Caller Avatar
- 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.
Caller Avatar #
When callerAvatar is provided in NativeCallParams, the plugin displays the caller's profile photo in:
- Incoming call full-screen Activity — profile photo loaded via Coil with
CircleCropTransformation. Falls back to colored initials circle on error. - Incoming call notification —
setLargeIcon()with the downloaded bitmap. On Android 12+ withCallStyle, thePersonicon is also set. - Ongoing call notification — large icon set to the avatar bitmap.
Avatar images are downloaded synchronously on the background thread (FCM/service) using HttpURLConnection with a 3-second timeout. If the download fails, all UI gracefully falls back to the default (initials or no icon).
await CallBundle.showIncomingCall(NativeCallParams(
callId: 'call-123',
callerName: 'Jane Smith',
callerAvatar: 'https://example.com/photos/jane.jpg',
callType: NativeCallType.voice,
));
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+ |