Push Notifications on Android
Overview
Android VoIP push notifications require two system integrations working together:
- Firebase Cloud Messaging (FCM) — delivers high-priority data messages to wake the app, even when killed
- Jetpack Core-Telecom (
CallsManager) — registers your call with the Android Telecom stack for the native call treatment: system call UI, audio routing, and foreground execution priority
Component Responsibilities
| Component | Role |
|---|---|
FirebaseMessagingService | Receives FCM messages, delegates to your call manager |
VoiceClient | Vonage SDK — session management, call signaling, media |
CallsManager | Jetpack Core-Telecom — registers the call with the OS via addCall |
CallControlScope | The handle addCall returns — drive (answer / hold / disconnect / route audio) and observe the OS call |
Started Service | Owns the CallStyle notification, hosts the registration, and keeps the call alive when backgrounded or swiped |
This guide uses CallsManager (the androidx.core.telecom Jetpack library), Google's recommended API for VoIP calls and what the reference sample app uses. The legacy android.telecom.ConnectionService still works — see Legacy: ConnectionService for why CallsManager is preferred.
Key Difference from iOS
Unlike iOS PushKit, Android FCM has no strict deadline for showing call UI. You can safely perform async work (session restoration, token refresh) before displaying the incoming call. This eliminates the race conditions that plague iOS implementations.
Project Setup
Gradle Dependencies
dependencies {
// Firebase
implementation(platform("com.google.firebase:firebase-bom:33.0.0"))
implementation("com.google.firebase:firebase-messaging")
// Jetpack Core-Telecom — CallsManager
implementation("androidx.core:core-telecom:1.0.1")
// Vonage Client SDK
implementation("com.vonage:client-sdk-voice:latest.release")
}plugins {
id("com.google.gms.google-services") version "4.4.2" apply false
}plugins {
id("com.google.gms.google-services")
}google-services.json
Download from the Firebase Console and place in app/google-services.json. Add it to .gitignore — it contains your Firebase project credentials.
Manifest Entries
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
<uses-permission android:name="android.permission.CALL_PHONE" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<application ...>
<!-- FCM Service -->
<service
android:name=".services.PushNotificationService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<!-- Started (NOT foreground) service: owns the CallStyle notification,
hosts the CallsManager registration, and keeps the call alive when
backgrounded/swiped. Core-Telecom provides the foreground service
itself, so you do NOT declare one of your own. -->
<service
android:name=".telecom.CallService"
android:exported="false" />
<!-- Delivers the CallStyle notification's Decline / Hang up actions. -->
<receiver
android:name=".telecom.CallActionReceiver"
android:exported="false" />
<!-- Incoming call activity (shows over lock screen) -->
<activity
android:name=".ui.CallActivity"
android:showOnLockScreen="true"
android:turnScreenOn="true"
android:showWhenLocked="true"
android:exported="false" />
</application>
</manifest>Runtime Permissions
Request these at runtime before attempting any call:
private val requiredPermissions = arrayOf(
Manifest.permission.RECORD_AUDIO,
Manifest.permission.READ_PHONE_STATE,
)
// For API 33+
private val api33Permissions = arrayOf(
Manifest.permission.POST_NOTIFICATIONS,
)INFO
MANAGE_OWN_CALLS and CALL_PHONE are normal permissions — they are granted automatically at install time and do not require runtime dialogs.
WARNING
RECORD_AUDIO must be granted before any call attempt. If denied, the SDK will connect the call but the remote party will hear silence.
SDK Initialization
Initialize VoiceClient once in your Application.onCreate() or a singleton. Never recreate it — doing so clears all listeners and loses session state.
class CoreContext(context: Context) {
// Immutable snapshot of the in-progress call — the single source of truth
// for the UI and the call notification (replaces a Connection subclass).
val activeCall = MutableStateFlow<ActiveCall?>(null)
var pushToken: String? = null
var lastRegisteredPushToken: String? = null
var deviceId: String? = null
var authToken: String? = null
var refreshToken: String? = null
}class App : Application() {
lateinit var voiceClientManager: VoiceClientManager
private set
lateinit var coreContext: CoreContext
private set
override fun onCreate() {
super.onCreate()
instance = this
coreContext = CoreContext(this)
val config = VGClientInitConfig(LoggingLevel.Info)
val client = VoiceClient(this, config)
voiceClientManager = VoiceClientManager(client, this)
}
companion object {
lateinit var instance: App
private set
}
}FCM Integration
FirebaseMessagingService
Keep the service thin — delegate all logic to your manager class.
class PushNotificationService : FirebaseMessagingService() {
override fun onMessageReceived(remoteMessage: RemoteMessage) {
super.onMessageReceived(remoteMessage)
// Delegate immediately — keep the service class thin
App.instance.voiceClientManager.processVoipPush(remoteMessage)
}
override fun onNewToken(token: String) {
super.onNewToken(token)
// Store and re-register if session is active
App.instance.coreContext.pushToken = token
App.instance.voiceClientManager.onPushTokenRefreshed(token)
}
companion object {
fun requestToken(callback: (String) -> Unit) {
FirebaseMessaging.getInstance().token
.addOnSuccessListener { callback(it) }
.addOnFailureListener { println("FCM token request failed: $it") }
}
}
}Token Handling
FCM can rotate tokens at any time. onNewToken is the only reliable notification. When it fires:
- Store the new token locally
- If a session is already active, call
registerDevicePushTokenimmediately
A stale token means Vonage cannot reach the device — all future pushes will be silently undeliverable.
Registering the Token with Vonage
client.registerDevicePushToken(token) { error, deviceId ->
if (error != null) {
println("Failed to register push token: $error")
return@registerDevicePushToken
}
println("Push token registered successfully, deviceId: $deviceId")
}
Register the FCM token with client.registerDevicePushToken(token) after a session is created. The server generates a unique deviceId for each registration, so avoid calling this when the token has not changed — otherwise duplicate registrations accumulate, causing multiple push deliveries per call.
private fun registerDevicePushTokenIfNeeded() {
val registerCallback: (String) -> Unit = { token ->
// Skip registration if the token is the same as the one we last registered
if (token == coreContext.lastRegisteredPushToken && coreContext.deviceId != null) {
println("Push token unchanged — skipping re-registration")
return@registerCallback
}
client.registerDevicePushToken(token) { err, deviceId ->
err?.let {
println("Failed to register push token: $err")
} ?: deviceId?.let {
coreContext.deviceId = deviceId
coreContext.lastRegisteredPushToken = token
println("Push token registered, deviceId: $deviceId")
}
}
}
coreContext.pushToken?.let { registerCallback(it) }
?: PushNotificationService.requestToken { registerCallback(it) }
}
fun unregisterDeviceToken(completion: () -> Unit) {
val deviceId = coreContext.deviceId ?: run { completion(); return }
client.unregisterDevicePushToken(deviceId) { error ->
error?.let { println("Unregister error: $it") }
coreContext.deviceId = null
completion()
}
}High-priority messages and Doze mode
Vonage's backend sends FCM messages at high priority. High-priority messages bypass Android Doze mode and immediately wake the app. You do not need to configure this in your app — it is a server-side setting in the Vonage platform.
Session Management
A Vonage session (client.createSession(jwt)) must be active before the SDK can process call invites or perform call actions. When a push arrives and the app is killed or the session has expired, restoration must happen first.
Unlike iOS — Chaining is Safe on Android
Android FCM has no PushKit-style deadline for showing system UI. You can safely await session restoration before calling client.processPushCallInvite. This eliminates the race condition where the user answers before a session is ready.
Recommended approach
Restore the session first. When confirmed active, call processPushCallInvite. The session is guaranteed to exist when answer() / reject() are called.
Avoid
Processing the push immediately and running session restoration concurrently. The user might answer before the session is ready, causing client.answer() to fail silently.
Session Creation
try {
val sessionId = client.createSession(jwt)
println("Session created for push context: $sessionId")
} catch (e: Exception) {
println("Failed to create session: ${e.message}")
}
fun login(
token: String,
isUserInitiated: Boolean = true,
onError: ((Exception) -> Unit)? = null,
onSuccess: ((String) -> Unit)? = null
) {
if (isUserInitiated) {
// On user-initiated login: clean up any stale device registration first,
// then create session and register the push token (always needed after unregister)
unregisterExistingDeviceIfNeeded {
createSession(token, registerPush = true, onError, onSuccess)
}
} else {
// Session restore: skip cleanup (same user, just reconnecting).
// Only re-register push token if the FCM token has changed since last registration.
createSession(token, registerPush = false, onError, onSuccess)
}
}
private fun createSession(
token: String,
registerPush: Boolean = true,
onError: ((Exception) -> Unit)? = null,
onSuccess: ((String) -> Unit)? = null
) {
client.createSession(token) { error, sessionId ->
sessionId?.let { sid ->
coreContext.authToken = token // persist for future restoration
if (registerPush) {
registerDevicePushTokenIfNeeded()
}
onSuccess?.invoke(sid)
_sessionId.value = sid
} ?: error?.let { onError?.invoke(it) }
}
}Session Restoration Priority Chain
| Priority | Condition | Action | Typical Latency |
|---|---|---|---|
| 1st | Active session exists (sessionId != null) | Return immediately | 0 ms |
| 2nd | Stored JWT available (authToken) | client.createSession(storedJWT) | 100-400 ms |
| 3rd | JWT expired, refresh token available | Refresh endpoint -> new JWT -> createSession | 300-1000 ms |
| 4th | No credentials | Return null — push cannot be processed | 0 ms |
private fun restoreSessionIfNeeded(completion: (String?) -> Unit) {
_sessionId.value?.let { completion(it); return } // already active
coreContext.authToken?.let { token ->
restoreSessionWithToken(token, completion)
} ?: coreContext.refreshToken?.let { refresh ->
restoreSessionWithRefreshToken(refresh, completion)
} ?: completion(null) // no credentials
}DANGER
Persist the JWT at login time. If createSession requires a live backend call to obtain a JWT every time a push arrives, you are adding an extra network hop before the session is ready. On slow connections this compounds the restoration latency and degrades call answer reliability.
Preventing Token Accumulation on Re-login
On a fresh user-initiated login, always unregister the previously stored device before creating a new session. Skipping this causes Vonage to accumulate multiple device registrations, leading to duplicate push notifications for the same call.
private fun unregisterExistingDeviceIfNeeded(completion: () -> Unit) {
val existingDeviceId = coreContext.deviceId ?: run { completion(); return }
val authToken = coreContext.authToken ?: run {
coreContext.deviceId = null; completion(); return
}
// Create a temporary session just to perform the unregistration
client.createSession(authToken) { _, sessionId ->
sessionId ?: run { coreContext.deviceId = null; completion(); return@createSession }
unregisterDeviceToken {
client.deleteSession { completion() }
}
}
}Processing Push Payloads
The full path from FCM delivery to the system incoming-call UI:
Implementation
fun processVoipPush(remoteMessage: RemoteMessage) {
// Step 1: ensure a session exists before touching the push payload
restoreSessionIfNeeded { sessionId ->
sessionId ?: run {
println("Session restoration failed — cannot process push")
return@restoreSessionIfNeeded
}
// Step 2: now safe to process — session is guaranteed active
processIncomingPush(remoteMessage)
}
}
fun processIncomingPush(remoteMessage: RemoteMessage) {
val dataString = remoteMessage.data.toString()
val type = VoiceClient.getPushNotificationType(dataString)
if (type == PushType.INCOMING_CALL) {
// Triggers setCallInviteListener callback
client.processPushCallInvite(dataString)
}
}Handling the Call Invite
When the SDK fires setCallInviteListener, record the call as your app state and start the CallService — which is what registers the call with Telecom (see Telecom Integration for why it must be the service that does this).
// In setClientListeners()
client.setCallInviteListener { callId, from, type ->
// One call at a time — ignore a second invite
coreContext.activeCall.value?.let { return@setCallInviteListener }
coreContext.setActiveCall(
ActiveCall(callId = callId, displayName = from, isIncoming = true, state = CallState.RINGING)
)
CallService.register(context) // starts the service, which runs callsManager.addCall
}Telecom Integration with CallsManager
Registering the call with the OS goes through Core-Telecom's CallsManager. You register your app once, then call addCall per call: it hands Telecom a description of the call (CallAttributesCompat) and suspends for the call's whole lifetime, giving you a CallControlScope to drive it (answer / hold / disconnect / route audio) and observe it (mute state, audio endpoints).
The condensed shape (see CallManager.kt for the full wrapper):
private val callsManager = CallsManager(context).apply {
registerAppWithTelecom(CallsManager.CAPABILITY_BASELINE)
}
suspend fun registerCall(displayName: String, isIncoming: Boolean) {
val attributes = CallAttributesCompat(
displayName = displayName,
address = Uri.fromParts("sip", displayName, null),
direction = if (isIncoming) CallAttributesCompat.DIRECTION_INCOMING
else CallAttributesCompat.DIRECTION_OUTGOING,
)
callsManager.addCall(
attributes,
onAnswer = { onTelecomAnswer() }, // answered from system UI / watch / Auto
onDisconnect = { onTelecomDisconnect() }, // ended from system UI / watch / Auto
onSetActive = {},
onSetInactive = {},
) {
// CallControlScope — drive + observe the OS call for its whole lifetime
launch { answer(CallAttributesCompat.CALL_TYPE_AUDIO_CALL) } // or setActive() / disconnect(...)
launch { currentCallEndpoint.collect { /* active audio route */ } }
launch { availableEndpoints.collect { /* route options */ } }
launch { isMuted.collect { muted -> onMuteChanged(muted) } }
}
}Host the registration in a started Service
Call addCall from a started service's scope, not an app-singleton scope. The started service gives the process the standing for Telecom to keep the call; a call added from a cold-started background process with no service anchor is added then immediately destroyed (CallException.ERROR_CALL_IS_NOT_BEING_TRACKED), after which every control op fails. This is why the invite handler starts CallService, and the service runs the registration. See CallService.kt.
Clamp the disconnect cause
Core-Telecom's disconnect accepts only LOCAL, REMOTE, MISSED, and REJECTED — any other DisconnectCause throws and tears the session down. Map at the boundary:
private val VALID_CAUSES = setOf(
DisconnectCause.LOCAL, DisconnectCause.REMOTE,
DisconnectCause.MISSED, DisconnectCause.REJECTED,
)
fun disconnect(cause: Int) {
val safe = if (cause in VALID_CAUSES) cause else DisconnectCause.LOCAL
onControl { disconnect(DisconnectCause(safe)) }
}One source of truth: ActiveCall
Instead of coupling app state to a Connection subclass, keep an immutable ActiveCall that the UI and notification observe; the SDK and Telecom callbacks both update it. The CallManager wrapper is fire-and-forget OS integration and never writes call state back. See ActiveCall.kt.
Foreground Execution and the CallStyle Notification
You do not create a microphone foreground service. While a call is registered with CallsManager and a CallStyle notification is posted, Core-Telecom treats the call as a phoneCall|microphone foreground service for you — that is what grants live microphone audio and foreground execution priority even from a killed or backgrounded start. A manual foreground service is redundant and can conflict.
The CallStyle notification is therefore mandatory, not cosmetic. Two things to get right (see CallNotifier.kt):
- Full-screen intent for incoming calls wakes your call Activity over the keyguard when a push arrives on a locked device.
- Answer must be an Activity
PendingIntent, not a broadcast that then callsstartActivity— the latter is a notification trampoline, banned since Android 12. Decline / Hang up are broadcasts to aBroadcastReceiver.
Pin the ongoing notification's chronometer to the real connect time and only re-post the notification when its inputs change, so mute/hold toggles don't reset the call-duration timer.
SDK Listeners
Set all listeners once, immediately after initializing VoiceClient, before creating any session. Map each SDK event onto ActiveCall state, and route every terminal event through a single cleanup path so the Telecom call is always disconnected and the notification always cancelled. (Full set in VoiceClientManager.kt.)
Active / Reconnecting
// Outbound leg answered, or media (re)connected -> active
client.setOnLegStatusUpdate { callId, _, status ->
if (currentIfMatches(callId) != null && status == LegStatus.answered) markActive()
}
client.setOnCallMediaReconnectingListener { callId ->
if (currentIfMatches(callId) != null) updateActiveCall { copy(state = CallState.RECONNECTING) }
}
client.setOnCallMediaReconnectionListener { callId ->
if (currentIfMatches(callId) != null) markActive()
}markActive() sets ActiveCall.state = ACTIVE (pinning the connect time for the notification chronometer) and calls telecom.setActive().
Terminal Events
Map the SDK reason to a DisconnectCause, then run one cleanUp path (which calls telecom.disconnect(cause), shows the disconnected state briefly, then clears ActiveCall).
client.setOnCallHangupListener { callId, _, reason ->
if (currentIfMatches(callId) != null) {
val cause = when (reason) {
HangupReason.remoteReject -> DisconnectCause.REJECTED
HangupReason.remoteHangup -> DisconnectCause.REMOTE
HangupReason.localHangup -> DisconnectCause.LOCAL
HangupReason.mediaTimeout -> DisconnectCause.BUSY
HangupReason.remoteNoAnswerTimeout -> DisconnectCause.CANCELED
}
cleanUp(cause)
}
}
client.setCallInviteCancelListener { callId, reason ->
if (currentIfMatches(callId) != null) {
val cause = when (reason) {
VoiceInviteCancelReason.AnsweredElsewhere -> DisconnectCause.ANSWERED_ELSEWHERE
VoiceInviteCancelReason.RejectedElsewhere -> DisconnectCause.REJECTED
VoiceInviteCancelReason.RemoteCancel -> DisconnectCause.CANCELED
VoiceInviteCancelReason.RemoteTimeout -> DisconnectCause.MISSED
}
cleanUp(cause)
}
}
client.setOnCallMediaDisconnectListener { callId, _ ->
if (currentIfMatches(callId) != null) cleanUp(DisconnectCause.ERROR)
}Because cleanUp runs telecom.disconnect, the DisconnectCause codes above are clamped to the four Core-Telecom accepts (see Clamp the disconnect cause).
Outbound Calls
Simpler than with ConnectionService — no placeCall / onCreateOutgoingConnection dance and no OEM fallback timer. Start the SDK call, record it as the active call, and start the service to register it with Telecom as DIRECTION_OUTGOING:
fun startOutboundCall(callContext: Map<String, String>? = null) {
client.serverCall(callContext) { err, callId ->
callId ?: run { err?.let { println("Outbound call error: $it") }; return@serverCall }
coreContext.setActiveCall(
ActiveCall(callId = callId, displayName = callee, isIncoming = false, state = CallState.DIALING)
)
CallService.register(context) // registers the outgoing call with Telecom
}
}Error Handling
The SDK fires setSessionErrorListener when the WebSocket drops during an active session. Attempt restoration automatically using the error reason to choose the right strategy.
client.setSessionErrorListener { reason ->
when (reason) {
SessionErrorReason.TokenExpired ->
// Stored JWT is invalid — skip it, go straight to refresh token
attemptSessionRestoration(skipAuthToken = true)
SessionErrorReason.TransportClosed,
SessionErrorReason.PingTimeout ->
// Transient network issue — try stored JWT first
attemptSessionRestoration(skipAuthToken = false)
}
}
private fun attemptSessionRestoration(skipAuthToken: Boolean = false) {
val handleFailure = {
clearSession()
// Prompt user to log in again
}
val fallbackToRefresh = {
coreContext.refreshToken?.let { token ->
restoreSessionWithRefreshToken(token) { it ?: handleFailure() }
} ?: handleFailure()
}
if (!skipAuthToken) {
coreContext.authToken?.let { token ->
restoreSessionWithToken(token) { it ?: fallbackToRefresh() }
} ?: fallbackToRefresh()
} else {
fallbackToRefresh()
}
}Common Error Scenarios
| Error | Cause | Recovery |
|---|---|---|
TokenExpired | JWT TTL exceeded | Use refresh token to obtain new JWT |
TransportClosed | Network interruption | Retry with stored JWT |
PingTimeout | Server unreachable | Retry with exponential backoff |
registerDevicePushToken fails | Session not active | Ensure createSession completed before registering |
processPushCallInvite silent | No session | Always restore session first |
answer() fails | Session expired between push and answer | Re-restore session, then answer |
Legacy: ConnectionService
Before Jetpack Core-Telecom, self-managed VoIP calls used android.telecom.ConnectionService directly: you registered a PhoneAccount, subclassed ConnectionService, returned Connection objects from onCreateIncomingConnection / onCreateOutgoingConnection, and ran your own microphone foreground service. It still works and is not deprecated, so existing integrations don't need to migrate urgently.
For new integrations, CallsManager is preferred:
CallsManager (Core-Telecom) | ConnectionService (legacy) | |
|---|---|---|
| Android 14 FGS type + permission | Handled for you | You declare and start the FGS yourself |
| Foreground service | Provided by the library | You write and manage it |
| Cross-surface (Wear / Auto / Bluetooth) | Built in | Manual |
| API shape | One addCall + a CallControlScope | PhoneAccount + Connection subclass + system callbacks |
| OEM outgoing-call quirks | Handled by the library | Manual fallback timers (Samsung/Xiaomi) |
| Min API | 26 | 23 |
The Android 14 point is the practical driver: on API 34+ the OS checks the foreground-service type and matching permission (e.g. RECORD_AUDIO) at the moment the service starts. CallsManager satisfies this for you; a hand-rolled ConnectionService integration has to get it exactly right or calls fail on modern devices.
See the ConnectionService reference if you maintain an existing integration.
Pitfalls and Anti-patterns
1. Processing push before session restoration
Don't
Call processPushCallInvite immediately on push receipt without ensuring a session exists. The call invite listener may not fire, resulting in a missed call with no UI.
Do
Chain session restoration before processing: restoreSessionIfNeeded { processPushCallInvite(data) }.
2. Fetching JWT from backend on every push
Don't
Require a live backend call before createSession on every push. This doubles latency on cold-start pushes.
Do
Persist the JWT at login time so restoration needs only one createSession call.
3. Not registering push token after session creation
Don't
Register the push token once at app launch and never again. The token must be registered with Vonage after each user-initiated login (since the previous device registration was unregistered on logout).
Do
Call registerDevicePushToken inside the createSession success callback for user-initiated logins. For session restorations, skip registration unless the FCM token has changed (see pitfall #5).
4. Not unregistering on logout
Don't
Skip unregisterDevicePushToken at logout. The old user's calls will still arrive on this device.
Do
Always call unregisterDevicePushToken before deleteSession during logout.
5. Registering duplicate device tokens
Don't
Call registerDevicePushToken on every createSession regardless of whether the token changed. The Vonage server issues a new deviceId on each registration call — repeated calls with the same token accumulate duplicate registrations, causing multiple push deliveries per call.
Do
Track the last successfully registered token (e.g., coreContext.lastRegisteredPushToken). Only call registerDevicePushToken when:
- This is a fresh user-initiated login (previous device was unregistered)
- The FCM token has changed (
onNewTokenfired) coreContext.deviceIdis null (no prior registration exists)
Skip registration during session restoration if the token matches the previously registered one.
6. Stale FCM token not re-registered
Don't
Ignore onNewToken. FCM rotates tokens without warning — all future pushes become undeliverable.
Do
In onNewToken, store the new token and call registerDevicePushToken if a session is active.
7. Registering the call outside a started service
Don't
Call callsManager.addCall from an app-singleton or one-off coroutine scope. From a cold background start, Telecom adds then immediately destroys the call (ERROR_CALL_IS_NOT_BEING_TRACKED).
Do
Start a Service and run addCall from its scope, so the process has the standing for Telecom to keep the call.
8. Not posting a CallStyle notification
Don't
Register the call but skip the CallStyle notification. Core-Telecom only grants foreground/microphone priority while such a notification is posted — without it the call has no reliable mic access in the background.
Do
Post a CallStyle notification for the whole call, with a full-screen intent for incoming calls.
9. Adding your own microphone foreground service
Don't
Declare and start a manual foregroundServiceType="microphone" service. With Core-Telecom it is redundant and can conflict.
Do
Let Core-Telecom provide the foreground service; your own service is an ordinary started service for the notification and process anchoring.
10. Passing an unsupported DisconnectCause
Don't
Forward arbitrary DisconnectCause codes to Core-Telecom's disconnect — anything outside LOCAL/REMOTE/MISSED/REJECTED throws and tears down the session.
Do
Clamp the cause at the boundary before calling disconnect.
11. Recreating VoiceClient on each push
Don't
Instantiate VoiceClient in your FirebaseMessagingService or per-push handler. This clears all listeners and loses session state.
Do
Initialize VoiceClient once in Application.onCreate() or a singleton. Reuse it throughout the app lifecycle.
Production Checklist
Before releasing your app, verify each item:
- [ ]
google-services.jsonpresent inapp/and excluded from version control - [ ]
androidx.core:core-telecomdependency added - [ ] Required permissions declared in AndroidManifest.xml:
RECORD_AUDIO,MANAGE_OWN_CALLS,FOREGROUND_SERVICE,FOREGROUND_SERVICE_PHONE_CALL,FOREGROUND_SERVICE_MICROPHONE,POST_NOTIFICATIONS,USE_FULL_SCREEN_INTENT - [ ]
RECORD_AUDIOruntime permission requested and granted before any call attempt - [ ]
PushNotificationService,CallService, andCallActionReceiverdeclared in manifest (no manual microphone foreground service) - [ ]
VoiceClientinitialized once (Application.onCreate or singleton) with all listeners set before first session - [ ] Session restoration awaited before
processPushCallInvite— chaining is safe on Android - [ ] JWT (and refresh token) persisted locally at login time — restoration should not require a live backend call
- [ ]
registerDevicePushTokencalled on user-initiated login aftercreateSessionsucceeds; skipped on session restoration unless FCM token has changed since last registration - [ ]
unregisterDevicePushTokencalled on explicit user logout - [ ] FCM token rotation handled in
onNewToken— store and re-register if session active - [ ]
addCallhosted inside a startedService(not an app singleton) - [ ]
CallStylenotification posted for the whole call, with a full-screen intent for incoming calls - [ ]
DisconnectCauseclamped toLOCAL/REMOTE/MISSED/REJECTEDbeforedisconnect - [ ] Every hangup/cancel/media-disconnect path cancels the notification and disconnects the Telecom call
- [ ] Session error handling restores session automatically;
TokenExpiredskips stored JWT - [ ] Tested on a physical device with app fully killed before the inbound call
- [ ] Tested on at least one Samsung or other OEM device (not just Pixel/emulator)