Skip to content

Push Notifications on Android

Overview

Android VoIP push notifications require two system integrations working together:

  1. Firebase Cloud Messaging (FCM) — delivers high-priority data messages to wake the app, even when killed
  2. 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

ComponentRole
FirebaseMessagingServiceReceives FCM messages, delegates to your call manager
VoiceClientVonage SDK — session management, call signaling, media
CallsManagerJetpack Core-Telecom — registers the call with the OS via addCall
CallControlScopeThe handle addCall returns — drive (answer / hold / disconnect / route audio) and observe the OS call
Started ServiceOwns 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

kotlin
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")
}
kotlin
plugins {
    id("com.google.gms.google-services") version "4.4.2" apply false
}
kotlin
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

xml
<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:

kotlin
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.

kotlin
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
}
kotlin
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.

kotlin
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:

  1. Store the new token locally
  2. If a session is already active, call registerDevicePushToken immediately

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.

kotlin
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}")
 }
kotlin
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

PriorityConditionActionTypical Latency
1stActive session exists (sessionId != null)Return immediately0 ms
2ndStored JWT available (authToken)client.createSession(storedJWT)100-400 ms
3rdJWT expired, refresh token availableRefresh endpoint -> new JWT -> createSession300-1000 ms
4thNo credentialsReturn null — push cannot be processed0 ms
kotlin
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.

kotlin
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

kotlin
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).

kotlin
// 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):

kotlin
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:

kotlin
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 calls startActivity — the latter is a notification trampoline, banned since Android 12. Decline / Hang up are broadcasts to a BroadcastReceiver.

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

kotlin
// 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).

kotlin
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:

kotlin
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.

kotlin
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

ErrorCauseRecovery
TokenExpiredJWT TTL exceededUse refresh token to obtain new JWT
TransportClosedNetwork interruptionRetry with stored JWT
PingTimeoutServer unreachableRetry with exponential backoff
registerDevicePushToken failsSession not activeEnsure createSession completed before registering
processPushCallInvite silentNo sessionAlways restore session first
answer() failsSession expired between push and answerRe-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 + permissionHandled for youYou declare and start the FGS yourself
Foreground serviceProvided by the libraryYou write and manage it
Cross-surface (Wear / Auto / Bluetooth)Built inManual
API shapeOne addCall + a CallControlScopePhoneAccount + Connection subclass + system callbacks
OEM outgoing-call quirksHandled by the libraryManual fallback timers (Samsung/Xiaomi)
Min API2623

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 (onNewToken fired)
  • coreContext.deviceId is 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.json present in app/ and excluded from version control
  • [ ] androidx.core:core-telecom dependency 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_AUDIO runtime permission requested and granted before any call attempt
  • [ ] PushNotificationService, CallService, and CallActionReceiver declared in manifest (no manual microphone foreground service)
  • [ ] VoiceClient initialized 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
  • [ ] registerDevicePushToken called on user-initiated login after createSession succeeds; skipped on session restoration unless FCM token has changed since last registration
  • [ ] unregisterDevicePushToken called on explicit user logout
  • [ ] FCM token rotation handled in onNewToken — store and re-register if session active
  • [ ] addCall hosted inside a started Service (not an app singleton)
  • [ ] CallStyle notification posted for the whole call, with a full-screen intent for incoming calls
  • [ ] DisconnectCause clamped to LOCAL/REMOTE/MISSED/REJECTED before disconnect
  • [ ] Every hangup/cancel/media-disconnect path cancels the notification and disconnects the Telecom call
  • [ ] Session error handling restores session automatically; TokenExpired skips 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)

Further Reading

Built with VitePress.