Push-Benachrichtigungen auf Android

Übersicht

Für Android-VoIP-Push-Benachrichtigungen müssen zwei Systemintegrationen zusammenwirken:

  1. Firebase Cloud Messaging (FCM) — übermittelt Datenmeldungen mit hoher Priorität, um die App zu aktivieren, selbst wenn sie beendet wurde
  2. Jetpack Core-Telecom (CallsManager) — meldet Ihren Anruf beim Android-Telekom-Stack zur nativen Anrufbearbeitung an: Systemaufruf-Benutzeroberfläche, Audio-Routing und Priorität bei der Ausführung im Vordergrund

Zuständigkeiten der Komponenten

Komponente Rolle
FirebaseMessagingService Empfängt FCM-Nachrichten und leitet diese an Ihren Call-Manager weiter
VoiceClient Vonage SDK – Sitzungsverwaltung, Anrufsignalisierung, Medien
CallsManager Jetpack Core-Telecom – registriert den Anruf über das Betriebssystem mittels addCall
CallControlScope Der Griff addCall Rückmeldungen – Steuerung (Antworten / Halten / Trennen / Audio weiterleiten) und den OS-Aufruf beobachten
Gestartet Service Ist Eigentümer von CallStyle Benachrichtigung, übernimmt die Registrierung und hält die Verbindung aufrecht, wenn die App in den Hintergrund wechselt oder weggewischt wird

In dieser Anleitung werden CallsManager (der androidx.core.telecom (Jetpack-Bibliothek), Googles empfohlene API für VoIP-Anrufe und was die Beispiel-App Verwendungszwecke. Das alte android.telecom.ConnectionService funktioniert immer noch — siehe Altes System: ConnectionService warum CallsManager wird bevorzugt.

Wesentlicher Unterschied zu iOS

Im Gegensatz zu iOS PushKit verfügt Android FCM über keine feste Frist zum Anzeigen der Anruf-Benutzeroberfläche. Sie können asynchrone Vorgänge (Wiederherstellung der Sitzung, Aktualisierung des Tokens) sicher ausführen, bevor der eingehende Anruf angezeigt wird. Dadurch werden die Race-Conditions vermieden, die bei iOS-Implementierungen häufig auftreten.

Projekteinrichtung

Gradle-Abhängigkeiten

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

Aus der Firebase-Konsole herunterladen und in folgenden Ordner verschieben: app/google-services.json. Füge es hinzu zu .gitignore — Es enthält Ihre Anmeldedaten für das Firebase-Projekt.

Manifest-Einträge

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

Laufzeitberechtigungen

Fordern Sie diese zur Laufzeit an, bevor Sie einen Aufruf versuchen:

private val requiredPermissions = arrayOf(
    Manifest.permission.RECORD_AUDIO,
    Manifest.permission.READ_PHONE_STATE,
)

// For API 33+
private val api33Permissions = arrayOf(
    Manifest.permission.POST_NOTIFICATIONS,
)

MANAGE_OWN_CALLS und CALL_PHONE sind normale Berechtigungen – sie werden bei der Installation automatisch erteilt und erfordern keine Dialoge zur Laufzeit.

Warnung: RECORD_AUDIO muss vor jedem Anrufversuch gewährt werden. Wird dies verweigert, stellt das SDK zwar die Verbindung her, der Gesprächspartner hört jedoch nichts.

SDK-Initialisierung

Initialisieren VoiceClient einmal in deinem Application.onCreate() oder ein Singleton. Erstellen Sie es niemals neu – dadurch werden alle Listener gelöscht und der Sitzungsstatus geht verloren.

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

Halten Sie den Service schlank – delegieren Sie die gesamte Logik an Ihre Manager-Klasse.

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

Umgang mit Token

FCM kann Token jederzeit austauschen. onNewToken ist die nur Zuverlässige Benachrichtigung. Wenn sie ausgelöst wird:

  1. Speichere das neue Token lokal
  2. Wenn bereits eine Sitzung aktiv ist, rufen Sie Folgendes auf: registerDevicePushToken sofort

Ein abgelaufenes Token bedeutet, dass Vonage das Gerät nicht erreichen kann – alle zukünftigen Push-Benachrichtigungen können nicht zugestellt werden, ohne dass eine entsprechende Meldung angezeigt wird.

Registrierung des Tokens bei 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")
 }
 client.registerDevicePushToken(token, (error, deviceId) -> {
     if (error != null) {
         System.out.println("Failed to register push token: " + error);
         return null;
     }

     System.out.println("Push token registered successfully, deviceId: " + deviceId);
     return null;
 });

Registrieren Sie das FCM-Token bei client.registerDevicePushToken(token) nachdem eine Sitzung erstellt wurde. Der Server generiert eine eindeutige deviceId für jede Registrierung; vermeiden Sie daher den Aufruf dieser Funktion, wenn sich das Token nicht geändert hat – andernfalls sammeln sich doppelte Registrierungen an, was zu mehreren Push-Benachrichtigungen pro Aufruf führt.

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()
    }
}

Nachrichten mit hoher Priorität und Doze-Modus Das Backend von Vonage versendet FCM-Nachrichten um hohe Priorität. Nachrichten mit hoher Priorität umgehen den Android-Doze-Modus und wecken die App sofort auf. Sie müssen dies in Ihrer App nicht konfigurieren – es handelt sich um eine serverseitige Einstellung in der Vonage-Plattform.

Sitzungsmanagement

Eine Vonage-Sitzung (client.createSession(jwt)) muss aktiv sein, bevor das SDK Anrufeinladungen verarbeiten oder Anrufaktionen ausführen kann. Wenn eine Push-Benachrichtigung eintrifft und die App beendet wurde oder die Sitzung abgelaufen ist, muss zunächst eine Wiederherstellung erfolgen.

Im Gegensatz zu iOS – ist das Verkettung auf Android sicher

Android FCM kennt keine Frist im Stil von PushKit für die Anzeige der System-Benutzeroberfläche. Sie können getrost auf die Wiederherstellung der Sitzung warten, bevor Sie den Aufruf ausführen. client.processPushCallInvite. Dadurch wird die Race Condition vermieden, bei der der Benutzer antwortet, bevor eine Sitzung bereit ist.

Empfohlenes Vorgehen: Stellen Sie zunächst die Sitzung wieder her. Sobald bestätigt ist, dass sie aktiv ist, rufen Sie processPushCallInvite. Die Sitzung ist garantiert vorhanden, wenn answer() / reject() werden aufgerufen.

Zu vermeiden: Die sofortige Verarbeitung des Pushs und die gleichzeitige Wiederherstellung der Sitzung. Der Benutzer könnte antworten, bevor die Sitzung bereit ist, was dazu führt, dass client.answer() stillschweigend zu scheitern.

Erstellung von Sitzungen

 do {
     let sessionId = try await client.createSession(jwt)
     print("Session created: \(sessionId)")
 } catch {
     print("Failed to create session: \(error.localizedDescription)")
 }
 [client createSession:jwt callback:^(NSError * _Nullable error, NSString * _Nullable sessionId) {
     if (error != nil || sessionId == nil) {
         NSLog(@"Failed to create session: %@", error);
         return;
     }
     NSLog(@"Session created: %@", sessionId);
 }];
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) }
    }
}

Prioritätskette für die Wiederherstellung von Sitzungen

Priorität Zustand Aktion Typische Latenz
1. Es besteht eine aktive Sitzung (sessionId != null) Sofort zurückkehren 0 ms
2. Gespeichertes JWT verfügbar (authToken) client.createSession(storedJWT) 100–400 ms
3. JWT abgelaufen, Refresh-Token verfügbar Endpunkt aktualisieren -> neues JWT -> createSession 300–1000 ms
4. Keine Anmeldedaten Rückkehr null — Der Push kann nicht verarbeitet werden 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
}

Das JWT bei der Anmeldung speichern. Wenn createSession Da bei jedem eingehenden Push ein Live-Backend-Aufruf erforderlich ist, um ein JWT abzurufen, fügen Sie einen zusätzlichen Netzwerk-Hop hinzu, bevor die Sitzung bereit ist. Bei langsamen Verbindungen erhöht dies die Latenz bei der Wiederherstellung und beeinträchtigt die Zuverlässigkeit der Antwort auf den Aufruf.

Verhindern der Token-Anhäufung bei erneuter Anmeldung

Bei einer neuen, vom Benutzer initiierten Anmeldung muss das zuvor gespeicherte Gerät immer erst abgemeldet werden, bevor eine neue Sitzung erstellt wird. Wird dieser Schritt übersprungen, sammelt sich bei Vonage eine Vielzahl von Geräteregistrierungen an, was zu doppelten Push-Benachrichtigungen für denselben Anruf führt.

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() }
        }
    }
}

Verarbeitung von Push-Nutzdaten

Der gesamte Weg von der FCM-Übermittlung bis zur Benutzeroberfläche für eingehende Anrufe im System:

Umsetzung

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

Bearbeitung der Anrufeinladung

Wenn das SDK ausgelöst wird setCallInviteListener, speichern Sie den Aufruf als App-Zustand und starten Sie die CallService — wodurch der Anruf bei Telecom registriert wird (siehe Telekommunikationsintegration (warum dies nämlich vom Dienst übernommen werden muss).

// 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
}

Telekommunikationsintegration mit CallsManager

Die Registrierung des Anrufs beim Betriebssystem erfolgt über Core-Telecom's CallsManager. Sie registrieren Ihre App einmal und rufen dann addCall pro Anruf: Es übermittelt Telecom eine Beschreibung des Anrufs (CallAttributesCompat) und wird für die gesamte Laufzeit des Aufrufs ausgesetzt, wodurch Sie ein CallControlScope um es zu bedienen (Annehmen / Halten / Auflegen / Audio weiterleiten) und zu überwachen (Stummschaltungsstatus, Audio-Endpunkte).

Die komprimierte Form (siehe CallManager.kt (für den vollständigen 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) } }
    }
}

Die Registrierung in einem bereits gestarteten Dienst hosten

Rufen Sie an. addCall von einer in Betrieb genommen Gültigkeitsbereich, kein App-Singleton-Gültigkeitsbereich. Der gestartete Dienst verleiht dem Prozess die Berechtigung für Telecom, behalten der Aufruf; ein Aufruf, der von einem im Kaltstart-Modus gestarteten Hintergrundprozess ohne Service-Anker hinzugefügt wurde, wird hinzugefügt und anschließend sofort gelöscht (CallException.ERROR_CALL_IS_NOT_BEING_TRACKED), woraufhin jeder Kontrollvorgang fehlschlägt. Aus diesem Grund wird der Einladungshandler gestartet CallService, und der Dienst führt die Registrierung durch. Siehe CallService.kt.

Die Ursache für die Unterbrechung festklemmen

Core-Telecoms disconnect akzeptiert nur LOCAL, REMOTE, MISSEDund REJECTED — sonstige DisconnectCause bringt die Sitzung zum Absturz und reißt sie auseinander. Karte an der Grenze:

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

Eine einzige Quelle der Wahrheit: ActiveCall

Anstatt den App-Status an einen Connection Unterklasse, eine unveränderliche ActiveCall das von der Benutzeroberfläche und den Benachrichtigungen beachtet wird; sowohl das SDK als auch die Telecom-Callbacks aktualisieren es. Das CallManager Der Wrapper ist eine „Fire-and-Forget“-Integration in das Betriebssystem und schreibt den Aufrufstatus niemals zurück. Siehe ActiveCall.kt.

Ausführung im Vordergrund und die „CallStyle“-Benachrichtigung

Das tust du nicht Erstellen Sie einen Mikrofon-Vordergrunddienst. Während ein Anruf bei CallsManager und a CallStyle Sobald die Benachrichtigung veröffentlicht wird, behandelt Core-Telecom den Anruf als einen phoneCall|microphone Ein Vordergrunddienst für Sie – dieser sorgt dafür, dass Live-Mikrofon-Audio und die Ausführung im Vordergrund auch dann Priorität haben, wenn der Dienst beendet oder in den Hintergrund verschoben wurde. Ein manuell gestarteter Vordergrunddienst ist überflüssig und kann zu Konflikten führen.

Die CallStyle Die Benachrichtigung ist daher obligatorisch und keine reine Formsache. Zwei Dinge, die beachtet werden müssen (siehe CallNotifier.kt):

  • Vollbild-Intent für eingehende Anrufe Aktiviert die „Wakes Your Call“-Funktion über die Tastensperre, wenn auf einem gesperrten Gerät eine Benachrichtigung eingeht.
  • Die Antwort muss eine Aktivität sein. PendingIntent, keine Übertragung, die dann aufruft startActivity — Letzteres ist ein Benachrichtigungs-Trampolin, das seit Android 12 verboten ist. „Ablehnen“ / „Auflegen“ sind Broadcasts an ein BroadcastReceiver.

Pinnen Sie den Timer der laufenden Benachrichtigung an die tatsächliche Verbindungszeit und veröffentlichen Sie die Benachrichtigung erst dann erneut, wenn sich ihre Eingaben ändern, damit das Umschalten zwischen Stummschaltung und Halten den Timer für die Gesprächsdauer nicht zurücksetzt.

SDK-Listener

Alle Listener einmalig direkt nach der Initialisierung einrichten VoiceClient, bevor eine Sitzung erstellt wird. Ordnen Sie jedes SDK-Ereignis dem ActiveCall Festlegen und jedes Endgeräteereignis über einen einzigen Aufräumpfad leiten, sodass die Telekommunikationsverbindung stets getrennt und die Benachrichtigung stets abgebrochen wird. (Vollständiger Satz in VoiceClientManager.kt.)

Aktiv / Wiederverbindung

// 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() Sätze ActiveCall.state = ACTIVE (die Verbindungszeit für den Benachrichtigungs-Chronometer festlegt) und ruft telecom.setActive().

Endereignisse

Ordne den SDK-Grund einem DisconnectCause, dann führe eine aus cleanUp Pfad (der telecom.disconnect(cause), zeigt kurz den getrennten Zustand an und löscht ihn anschließend 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)
}

Weil cleanUp Läufe telecom.disconnectdie DisconnectCause Die oben genannten Codes sind auf die vier von Core-Telecom akzeptierten Werte begrenzt (siehe Die Ursache für die Unterbrechung festklemmen).

Ausgehende Anrufe

Einfacher als mit ConnectionService — nein placeCall / onCreateOutgoingConnection Tanz und kein OEM-Fallback-Timer. Starten Sie den SDK-Aufruf, zeichnen Sie ihn als aktiven Aufruf auf und starten Sie den Dienst, um ihn bei Telecom als 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
    }
}

Fehlerbehandlung

Das SDK wird ausgelöst setSessionErrorListener wenn die WebSocket-Verbindung während einer aktiven Sitzung unterbrochen wird. Versuche automatisch, die Verbindung wiederherzustellen, und wähle dabei anhand des Fehlergrunds die passende Strategie aus.

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()
    }
}

Häufige Fehlerszenarien

Fehler Ursache Erholung
TokenExpired JWT-TTL überschritten Verwenden Sie das Refresh-Token, um ein neues JWT zu erhalten
TransportClosed Netzwerkunterbrechung Erneut versuchen mit gespeichertem JWT
PingTimeout Server nicht erreichbar Erneuter Versuch mit exponentiellem Backoff
registerDevicePushToken scheitert Sitzung nicht aktiv Sicherstellen createSession vor der Registrierung abgeschlossen
processPushCallInvite still Keine Sitzung Stellen Sie immer zuerst die Sitzung wieder her
answer() scheitert Die Sitzung ist zwischen dem Push und der Antwort abgelaufen Sitzung erneut wiederherstellen, dann antworten

Altes System: ConnectionService

Vor Jetpack Core-Telecom wurden selbstverwaltete VoIP-Anrufe genutzt android.telecom.ConnectionService direkt: Sie haben sich bei einem PhoneAccount, mit Unterklassen ConnectionService, zurückgegeben Connection Objekte aus onCreateIncomingConnection / onCreateOutgoingConnection, und hast dein eigenes microphone Vordergrunddienst. Er funktioniert nach wie vor und ist nicht veraltet, sodass bestehende Integrationen nicht dringend migriert werden müssen.

Für neu Integrationen, CallsManager wird bevorzugt:

CallsManager (Core-Telecom) ConnectionService (veraltet)
Android 14 FGS-Typ + Berechtigung Wir kümmern uns darum Sie melden das FGS selbst an und nehmen den Betrieb auf
Dienst im Vordergrund Zur Verfügung gestellt von der Bibliothek Du schreibst und verwaltest es
Plattformübergreifend (Wear / Auto / Bluetooth) Eingebaut Handbuch
API-Form Eins addCall + a CallControlScope PhoneAccount + Connection Unterklasse + System-Callbacks
Eigenheiten bei ausgehenden Anrufen bei OEMs Wird von der Bibliothek übernommen Manuelle Fallback-Timer (Samsung/Xiaomi)
Min-API 26 23

Der Punkt zu Android 14 ist der entscheidende Faktor: Ab API 34 prüft das Betriebssystem den Typ des Vordergrunddienstes und die entsprechende Berechtigung (z. B. RECORD_AUDIO) zum Zeitpunkt des Starts des Dienstes. CallsManager erfüllt diese Anforderung für dich; eine handgerollte ConnectionService Die Integration muss absolut korrekt funktionieren, sonst schlagen Anrufe auf modernen Geräten fehl.

Siehe die ConnectionService-Referenz wenn Sie eine bestehende Integration pflegen.

Fallstricke und Anti-Muster

1. Verarbeitung von Push-Nachrichten vor der Wiederherstellung der Sitzung

Nicht tun:

Rufen Sie an. processPushCallInvite unmittelbar nach Erhalt der Push-Benachrichtigung, ohne zu überprüfen, ob eine Sitzung besteht. Der Anruf-Invite-Listener wird möglicherweise nicht ausgelöst, was dazu führt, dass der Anruf verpasst wird und keine Benutzeroberfläche angezeigt wird.

Tun:

Wiederherstellung der Kettensitzung vor der Verarbeitung: restoreSessionIfNeeded { processPushCallInvite(data) }.

2. Bei jedem Push ein JWT vom Backend abrufen

Nicht tun:

Vorher muss ein Live-Backend-Aufruf erfolgen createSession bei jedem Push. Dies verdoppelt die Latenz bei Cold-Start-Pushes.

Tun:

Das JWT beim Anmelden speichern, damit die Wiederherstellung nur einen Schritt erfordert createSession anrufen.

3. Das Push-Token wird nach der Erstellung der Sitzung nicht registriert

Nicht tun:

Registrieren Sie das Push-Token einmalig beim Start der App und danach nie wieder. Das Token muss nach jeder vom Benutzer initiierten Anmeldung bei Vonage registriert werden (da die vorherige Geräteregistrierung beim Abmelden gelöscht wurde).

Tun:

Rufen Sie an. registerDevicePushToken innerhalb der createSession Erfolgs-Callback für vom Benutzer initiierte Anmeldungen. Bei der Wiederherstellung von Sitzungen sollte die Registrierung übersprungen werden, es sei denn, das FCM-Token hat sich geändert (siehe Fallstrick Nr. 5).

4. Die Abmeldung führt nicht zur Aufhebung der Registrierung

Nicht tun:

Überspringen unregisterDevicePushToken beim Abmelden. Die Anrufe des bisherigen Nutzers gehen weiterhin auf diesem Gerät ein.

Tun:

Rufen Sie immer an unregisterDevicePushToken vorher deleteSession beim Abmelden.

5. Registrierung doppelter Gerätetoken

Nicht tun:

Rufen Sie an. registerDevicePushToken auf jedem createSession unabhängig davon, ob sich das Token geändert hat. Der Vonage-Server gibt ein neues deviceId bei jedem Registrierungsaufruf – wiederholte Aufrufe mit demselben Token führen zu doppelten Registrierungen, was mehrere Push-Benachrichtigungen pro Aufruf zur Folge hat.

Tun:

Verfolgen Sie das zuletzt erfolgreich registrierte Token (z. B. coreContext.lastRegisteredPushToken). Rufen Sie nur registerDevicePushToken Wann:

  • Dies ist eine neue, vom Benutzer initiierte Anmeldung (das vorherige Gerät wurde abgemeldet).
  • Der FCM-Token hat sich geändert (onNewToken entlassen)
  • coreContext.deviceId ist null (es liegt keine vorherige Registrierung vor)

Die Registrierung bei der Wiederherstellung der Sitzung überspringen, wenn das Token mit dem zuvor registrierten übereinstimmt.

6. Abgelaufenes FCM-Token wurde nicht neu registriert

Nicht tun:

Ignorieren onNewToken. FCM wechselt die Tokens ohne Vorwarnung – alle zukünftigen Push-Benachrichtigungen können dann nicht mehr zugestellt werden.

Tun:

Unter onNewToken, speichern Sie das neue Token und rufen Sie registerDevicePushToken wenn eine Sitzung aktiv ist.

7. Registrierung des Anrufs außerhalb eines gestarteten Dienstes

Nicht tun:

Rufen Sie an. callsManager.addCall aus einem App-Singleton oder einem einmaligen Coroutine-Gültigkeitsbereich. Bei einem Kaltstart im Hintergrund fügt Telecom die Verbindung hinzu und löst sie anschließend sofort wieder auf (ERROR_CALL_IS_NOT_BEING_TRACKED).

Tun:

Starte ein Service und laufen addCall aus seinem Geltungsbereich heraus, sodass Telecom im Rahmen dieses Verfahrens berechtigt ist, den Anruf beizubehalten.

8. Keine CallStyle-Benachrichtigung versenden

Nicht tun:

Den Anruf protokollieren, aber das CallStyle Benachrichtigung. Core-Telecom gewährt nur solange eine Vordergrund-/Mikrofonpriorität, wie eine solche Benachrichtigung angezeigt wird – ohne diese Benachrichtigung hat der Anruf im Hintergrund keinen zuverlässigen Zugriff auf das Mikrofon.

Tun:

Einen Beitrag veröffentlichen CallStyle Benachrichtigung für den gesamten Anruf mit Vollbild-Intent bei eingehenden Anrufen.

9. Einen eigenen Mikrofon-Foreground-Dienst hinzufügen

Nicht tun:

Ein Handbuch definieren und starten foregroundServiceType="microphone" Dienst. Bei Core-Telecom ist dieser redundant und kann zu Konflikten führen.

Tun:

Überlassen Sie Core-Telecom die Bereitstellung des Vordergrunddienstes; Ihr eigener Dienst ist ein gewöhnlicher gestartet Dienst für die Benachrichtigung und die Verankerung von Prozessen.

10. Übergabe einer nicht unterstützten „DisconnectCause“

Nicht tun:

Beliebig weiterleiten DisconnectCause Codes an Core-Telecom disconnect — alles, was draußen ist LOCAL/REMOTE/MISSED/REJECTED bringt die Sitzung zum Absturz und beendet sie.

Tun:

Die Ursache an der Grenze abgrenzen, bevor der Aufruf erfolgt disconnect.

11. VoiceClient bei jedem Push neu erstellen

Nicht tun:

Instanziieren VoiceClient in Ihrem FirebaseMessagingService oder als „Per-Push“-Handler. Dadurch werden alle Listener gelöscht und der Sitzungsstatus geht verloren.

Tun:

Initialisieren VoiceClient einmal in Application.onCreate() oder als Singleton. Verwenden Sie es während des gesamten Lebenszyklus der App wieder.

Checkliste für die Produktion

Bevor Sie Ihre App veröffentlichen, Verify bitte jeden Punkt:

  • [ ] google-services.json vorhanden in app/ und aus der Versionskontrolle ausgeschlossen
  • [ ] androidx.core:core-telecom Abhängigkeit hinzugefügt
  • [ ] In der Datei „AndroidManifest.xml“ deklarierte erforderliche Berechtigungen: RECORD_AUDIO, MANAGE_OWN_CALLS, FOREGROUND_SERVICE, FOREGROUND_SERVICE_PHONE_CALL, FOREGROUND_SERVICE_MICROPHONE, POST_NOTIFICATIONS, USE_FULL_SCREEN_INTENT
  • [ ] RECORD_AUDIO Laufzeitberechtigung wird vor jedem Aufrufversuch angefordert und erteilt
  • [ ] PushNotificationService, CallServiceund CallActionReceiver im Manifest deklariert (kein manueller Mikrofon-Foreground-Dienst)
  • [ ] VoiceClient wird einmal initialisiert (in Applications.onCreate oder als Singleton), wobei alle Listener vor der ersten Sitzung festgelegt werden
  • [ ] Warten auf die Wiederherstellung der Sitzung processPushCallInvite — Die Verkettung ist unter Android sicher
  • [ ] JWT (und Refresh-Token) werden bei der Anmeldung lokal gespeichert – die Wiederherstellung sollte keinen Live-Backend-Aufruf erfordern
  • [ ] registerDevicePushToken wurde nach der benutzerseitig initiierten Anmeldung aufgerufen createSession gelingt; wird bei der Wiederherstellung der Sitzung übersprungen, es sei denn, das FCM-Token hat sich seit der letzten Registrierung geändert
  • [ ] unregisterDevicePushToken wird bei expliziter Abmeldung des Benutzers aufgerufen
  • [ ] Die FCM-Token-Rotation wird abgewickelt in onNewToken — speichern und erneut registrieren, falls die Sitzung aktiv ist
  • [ ] addCall innerhalb eines gestarteten Prozesses gehostet Service (kein App-Singleton)
  • [ ] CallStyle Benachrichtigung für den gesamten Anruf, mit Vollbild-Modus bei eingehenden Anrufen
  • [ ] DisconnectCause festgeklemmt an LOCAL/REMOTE/MISSED/REJECTED vorher disconnect
  • [ ] Jeder Aufleg-, Abbruch- oder Medien-Trennvorgang bricht die Benachrichtigung ab und beendet die Telekommunikationsverbindung.
  • [ ] Die Fehlerbehandlung für Sitzungen stellt die Sitzung automatisch wieder her; TokenExpired überspringt gespeicherte JWTs
  • [ ] Getestet auf einem physischen Gerät, wobei die App vor dem eingehenden Anruf vollständig beendet wurde
  • [ ] Auf mindestens einem Gerät von Samsung oder einem anderen Hersteller getestet (nicht nur auf Pixel-Geräten oder im Emulator)

Weitere Lektüre