Push-Benachrichtigungen – iOS

Übersicht

iOS schreibt vor, dass VoIP-Apps Folgendes verwenden müssen: PushKit für Benachrichtigungen über eingehende Anrufe und CallKit zur Darstellung der Benutzeroberfläche für Systemaufrufe. Diese beiden Frameworks sind eng miteinander verknüpft – Apple schreibt vor, dass jeder VoIP-Push zu einem reportNewIncomingCall an CallKit. Bei Nichteinhaltung wird die Push-Zustellung an das Gerät von iOS dauerhaft gesperrt.

Das Vonage Client SDK lässt sich in beide Frameworks integrieren. Ihre App ist für Folgendes zuständig:

  • Registrierung für VoIP-Push-Benachrichtigungen und Weiterleitung des Tokens an Vonage
  • Empfangen von Push-Nutzdaten und Weiterleiten an das SDK zur Verarbeitung
  • Anzeige eingehender Anrufe über CallKit
  • Verwaltung des Lebenszyklus einer Audiositzung ausschließlich über CallKit-Callbacks
  • Aufrechterhaltung einer gültigen SDK-Sitzung, damit Anrufe entgegengenommen werden können

Zuständigkeiten der Komponenten

Komponente Verantwortung
PushKit Weckt die App aus dem beendeten/ausgesetzten Zustand auf und übermittelt die Push-Nachricht
Vonage SDK Dekodiert die Push-Nutzdaten, verwaltet die WebRTC-Sitzung und wickelt die Anrufsignalisierung ab
CallKit Stellt die Benutzeroberfläche für Systemaufrufe bereit und verwaltet die Aktivierung von Audiositzungen
Deine App Koordiniert die oben genannten Aufgaben und verwaltet den Lebenszyklus von JWT und Sitzungen

Projekteinrichtung

1 – Funktionen von Xcode

Aktivieren Sie Folgendes in der Datei Ihres Ziels: Signieren & Fähigkeiten tab:

Push Notifications             → enabled
Background Modes               → Voice over IP (checked)
                               → Background processing (checked)

Ihre Anspruchsdatei sollte Folgendes enthalten:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>aps-environment</key>
    <string>development</string>
    <key>com.apple.developer.pushkit.unrestricted-voip</key>
    <true/>
</dict>
</plist>

Warnung: Die aps-environment Der Wert wird von Xcode automatisch auf Grundlage der Provisionierung festgelegt. Ändern Sie ihn nicht manuell.

Eingeschränkte VoIP-Berechtigung

Ab iOS 16 können Apps ohne die com.apple.developer.pushkit.unrestricted-voip Die Berechtigung ist auf eine geringe Anzahl von PushKit-Benachrichtigungen pro Tag beschränkt. Wird das Systembudget überschritten, wird die Zustellung der Push-Benachrichtigungen stillschweigend gedrosselt, und eingehende Anrufe wecken die App nicht mehr auf.

Diese Berechtigung muss bei Apple über die Antragsformular für Leistungsansprüche. Bis zur Bewilligung funktionieren Entwicklungsversionen möglicherweise normal (Debug-Versionen verfügen über ein höheres Budget), aber Produktionsversionen werden gedrosselt.

Stellen Sie sicher, dass diese Berechtigung in Ihrer .entitlements vor dem Versand an den App Store bei Apple eingereicht und von Apple genehmigt werden.

2 – Push-Zertifikate

Sie benötigen ein APNs-Zertifikat für Ihre App-ID, das als .p12, und in die Vonage-Anwendung hochgeladen, mit der sich Ihre App authentifiziert. Ein modernes APNs-SSL-Zertifikat mit App-Gültigkeitsbereich ist gültig für die .voip Thema, daher wird ein separates, altes „VoIP-Dienstzertifikat“ nicht erforderlich.

CSR generieren

Unter Schlüsselbund → Zertifikatsassistent → Zertifikat bei einer Zertifizierungsstelle anfordern, geben Sie Ihre E-Mail-Adresse und einen allgemeinen Namen ein, lassen Sie das Feld „CA-E-Mail“ leer und wählen Sie Auf Festplatte gespeichert, und speichern Sie die .certSigningRequest.

Zertifikat erstellen

  1. In der Apple-Entwicklerportal → Zertifikate, Identifikatoren und Profile, stellen Sie sicher, dass Ihre App-ID hat die Push-Benachrichtigungen Funktion aktiviert.
  2. Unter Zertifikate, füge ein Apple Push-Benachrichtigungsdienst SSL (Sandbox & Produktion) Zertifikat, wählen Sie Ihre App-ID aus und laden Sie die CSR hoch.
  3. Laden Sie das Ergebnis herunter .cer.

Exportieren Sie die .p12

Doppelklicken Sie auf die .cer um ihn in Ihren Anmelde-Schlüsselbund zu importieren. In Meine Zertifikatefinden Apple Push Services: <your-bundle-id> — der private Schlüssel muss darunter verschachtelt sein — dann mit der rechten Maustaste klicken → Exportieren → Austausch persönlicher Daten (.p12). Lassen Sie das Exportkennwort nach Möglichkeit leer (siehe den Hinweis zum Hochladen weiter unten).

Über das Vonage-Dashboard hochladen

  1. Melden Sie sich bei der Vonage Dashboard.
  2. Gehe zu Applications → Ihre Anwendung (der application_id (an dem das JWT Ihrer App die Authentifizierung vornimmt).
  3. Öffnen Sie die Push-Benachrichtigungen aktivieren Registerkarte und laden Sie die .p12.
  4. Das Passwortfeld ist optional und mit einer Beschriftung versehen nicht empfehlenswert (Vonage speichert es zusammen mit Ihrem privaten Schlüssel), daher ist ein passwortloses Zertifikat vorzuziehen.

Siehe die Anleitungen zur Einrichtung von Vonage-Push-Benachrichtigungen für iOS Zur Information.

Zertifikate verfallen jährlich. Der Ausfallmodus ist vollständig und still — Im gesamten System wird kein Fehler angezeigt. Behalten Sie das Ablaufdatum im Auge und verlängern Sie die Laufzeit proaktiv.

Warnung: Sandbox vs. Produktion Xcode-Debug-Builds nutzen die APNs-Sandbox-Umgebung; TestFlight- und App-Store-Builds nutzen die Produktionsumgebung. Hochladen separate Zertifikate für jede Umgebung und ermitteln Sie stets die isSandbox Flag zur Kompilierungszeit mithilfe von #if DEBUG — Man sollte es niemals fest einprogrammieren.

3 – Frameworks

hinzufügen CallKit unter Rahmenwerke, Bibliotheken und eingebettete Inhalte auf der Registerkarte „Allgemein“ Ihres Ziels. Importieren Sie bei Bedarf:

import PushKit
import CallKit
import VonageClientSDKVoice
import AVFoundation

4 – SDK-Initialisierung

Initialisieren VGVoiceClient einmal, zu Beginn des App-Lebenszyklus (z. B. in Ihrem AppDelegate oder einem Singleton-Manager). Legen Sie dessen Delegate fest, bevor Sie sich für Push-Benachrichtigungen registrieren oder Sitzungen erstellen:

let config = VGClientInitConfig(loggingLevel: .info)

#if targetEnvironment(simulator)
config.enableWebsocketInvites = true
#endif

let client = VGVoiceClient(config)
client.delegate = self

Fordern Sie die Berechtigung für das Mikrofon frühzeitig im Lebenszyklus der App an – idealerweise in application(_:didFinishLaunchingWithOptions:):

AVAudioSession.sharedInstance().requestRecordPermission { granted in
    print("Microphone permission: \(granted)")
}

Wenn der Benutzer den Zugriff auf das Mikrofon verweigert, werden Anrufe zwar verbunden, der Gesprächspartner hört jedoch nur Stille.

PushKit-Integration

Für VoIP-Benachrichtigungen registrieren

Erstellen einer PKPushRegistry und die gewünschten Push-Typen beim Start der App festlegen, bevor überhaupt eine Benachrichtigung eingehen kann.

 #if DEBUG
 let isSandbox = true
 #else
 let isSandbox = false
 #endif

 client.registerVoipToken(pushToken, withDeviceToken: nil, isSandbox: isSandbox) { error, deviceId in
     if let error = error {
         print("Failed to register push token: \(error.localizedDescription)")
         return
     }
     // Store deviceId for unregistration on logout
     print("Push token registered, deviceId: \(deviceId ?? "")")
 }
 PKPushRegistry *pushRegistry = [[PKPushRegistry alloc] initWithQueue:dispatch_get_main_queue()];
 pushRegistry.delegate = self;
 pushRegistry.desiredPushTypes = [NSSet setWithObject:PKPushTypeVoIP];

PKPushRegistryDelegate

Implementieren Sie die drei Delegate-Methoden. Die dritte — didReceiveIncomingPushWith — ist der entscheidende Punkt:

extension AppDelegate: PKPushRegistryDelegate {

    // Called when a new VoIP token is issued or rotated
    func pushRegistry(_ registry: PKPushRegistry,
                      didUpdate pushCredentials: PKPushCredentials,
                      for type: PKPushType) {
        guard type == .voIP else { return }
        CallManager.shared.voipTokenDidUpdate(pushCredentials.token)
    }

    // Called when the token is invalidated (e.g. app reinstall)
    func pushRegistry(_ registry: PKPushRegistry,
                      didInvalidatePushTokenFor type: PKPushType) {
        CallManager.shared.unregisterDeviceToken()
    }

    // Called when an inbound VoIP push is received
    func pushRegistry(_ registry: PKPushRegistry,
                      didReceiveIncomingPushWith payload: PKPushPayload,
                      for type: PKPushType,
                      completion: @escaping () -> Void) {
        guard type == .voIP else { completion(); return }
        CallManager.shared.handleIncomingPush(payload: payload, completion: completion)
    }
}

Registrierung des Tokens bei Vonage

Registrieren Sie sich bei client.registerVoipToken(_:isSandbox:) nur, wenn eine Vonage-Sitzung besteht. Speichere den zurückgegebenen deviceId — Sie benötigen es, um sich beim Abmelden abzumelden.

 import PushKit

 let pushRegistry = PKPushRegistry(queue: .main)
 pushRegistry.delegate = self
 pushRegistry.desiredPushTypes = [.voIP]
#if DEBUG
BOOL isSandbox = YES;
#else
BOOL isSandbox = NO;
#endif

[client registerVoipToken:pushToken withDeviceToken:nil isSandbox:isSandbox callback:^(NSError * _Nullable error, NSString * _Nullable deviceId) {
    if (error != nil) {
        NSLog(@"Failed to register push token: %@", error);
        return;
    }
    // Store deviceId for unregistration on logout
    NSLog(@"Push token registered, deviceId: %@", deviceId);
}];

Bitte nicht anrufen registerVoipToken bei jeder Wiederherstellung der Sitzung. Nach der Registrierung bleibt das Token gültig, bis es erneuert wird oder sich der Benutzer abmeldet. Eine erneute Registrierung bei jeder createSession sammelt Geräteeinträge und kann zu doppelten Übertragungen führen.

Sitzungsmanagement

Eine Vonage-Sitzung (client.createSession(jwt)) ist erforderlich, bevor das SDK Anrufeinladungen verarbeiten oder Anrufaktionen ausführen kann. Wenn ein VoIP-Push-Signal eintrifft und die App beendet wurde oder die Sitzung abgelaufen ist, muss die Sitzung neu erstellt werden. Wie schnell dies geschieht, entscheidet direkt darüber, ob Nutzer Anrufe erfolgreich annehmen können.

Die häufigste Produktionsstörung

Nicht tun: Bei Empfang einer Push-Benachrichtigung: Authentifizierungsserver aufrufen → neues JWT abrufen → aufrufen createSession. Dadurch kommen in einem Hintergrundausführungsfenster mit schlechtem Signal zwei serielle Netzwerk-Hops hinzu. Der Benutzer sieht den CallKit-Bildschirm, tippt auf „Annehmen“, bevor die Verbindung hergestellt ist, und der Anruf schlägt unbemerkt fehl.

Zu tun: Bei der Benutzeranmeldung: Das JWT (und ein Aktualisierungstoken) lokal speichern. Beim Empfang einer Push-Benachrichtigung: Aufrufen createSession direkt mit dem gespeicherten JWT. Auf dem Fast-Path finden keinerlei Backend-Aufrufe statt.

JWT-Strategie

Befolgen Sie eine Prioritätskette – nutzen Sie den schnellsten verfügbaren Pfad:

Priorität Zustand Aktion Typische Latenz
1. Es ist eine aktive Sitzung vorhanden Sofort zurückgeben – keine Bearbeitung erforderlich 0 ms
2. Das gespeicherte JWT ist gültig client.createSession(storedJWT) 100–400 ms
3. JWT abgelaufen, Refresh-Token verfügbar Endpunkt aktualisieren -> neues JWT -> createSession 300–1000 ms
4. Keine Anmeldedaten Warnmeldung im Protokoll; Anruf kann nicht entgegengenommen werden 0 ms

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

Wiederherstellung der Sitzung beim Empfang einer Push-Benachrichtigung

func restoreSessionIfNeeded() {
    guard activeSessionId == nil else {
        print("Session already active")
        return
    }

    if let jwt = UserDefaults.standard.string(forKey: "vonage_jwt") {
        // Fast path — use stored JWT directly
        createSession(jwt: jwt)
    } else if let refreshToken = storedRefreshToken {
        // Slow path — exchange refresh token for a new JWT first
        refreshJWT(refreshToken) { [weak self] newJWT in
            self?.createSession(jwt: newJWT)
        }
    } else {
        print("No credentials available for session restoration")
    }
}

Verarbeitung von Push-Nutzdaten

Dies ist der zeitkritischste Teil der Integration. Zwei Vorgänge müssen von handleIncomingPush, und die Reihenfolge zwischen ihnen ist nicht offensichtlich.

Das Concurrent-Muster (erforderlich)

Warten Sie niemals auf die Wiederherstellung der Sitzung, bevor Sie den Aufruf ausführen processCallInvitePushData. Gating-Push-Verarbeitung im Hintergrund createSession Verzögerungen reportNewIncomingCall durch einen vollständigen Netzwerk-Roundtrip. Apple wertet dies als Fehler bei der Verarbeitung des Push-Befehls aus – PushKit stellt die Zustellung weiterer Push-Befehle an das Gerät stillschweigend ein.

Umsetzung

 client.processCallInvitePushData(payload.dictionaryPayload)
 [client processCallInvitePushData:payload.dictionaryPayload];

Das vollständige Implementierungsmuster:

private var pendingPushCompletion: (() -> Void)?

func handleIncomingPush(payload: PKPushPayload, completion: @escaping () -> Void) {

    // 1. Validate: only handle Vonage incoming-call pushes
    let pushType = VGVoiceClient.vonagePushType(payload.dictionaryPayload)
    guard pushType == .incomingCall else {
        // Must still report to CallKit — failing to do so breaks future push delivery.
        let fakeUUID = UUID()
        callProvider.reportNewIncomingCall(with: fakeUUID, update: CXCallUpdate()) { _ in
            self.callProvider.reportCall(with: fakeUUID, endedAt: .now, reason: .failed)
            completion()
        }
        return
    }

    // 2. Store completion — will be called after reportNewIncomingCall
    pendingPushCompletion = completion

    // 3a. Restore session concurrently — non-blocking
    restoreSessionIfNeeded()

    // 3b. Process the push immediately — triggers didReceiveInviteForCall
    client.processCallInvitePushData(payload.dictionaryPayload)
}

Warnung: Selbst wenn der Push fehlerhaft, abgelaufen oder ein Duplikat ist, können Sie muss noch anrufen reportNewIncomingCall und den Anruf sofort beenden. Wird dieser Schritt übersprungen, wird die Push-Übermittlung dauerhaft unterbrochen.

CallKit-Integration

Einrichtung von CXProvider

erstellen. CXProvider und CXCallController Einmal erstellen und für die gesamte Laufzeit Ihres Managers beibehalten. Erstellen Sie sie nicht bei jedem Aufruf oder Push neu.

class CallManager: NSObject {

    static let shared = CallManager()

    private let callProvider: CXProvider
    private let callController = CXCallController()

    private override init() {
        let config = CXProviderConfiguration()
        config.supportsVideo            = false
        config.maximumCallGroups        = 1
        config.maximumCallsPerCallGroup = 1
        config.supportedHandleTypes     = [.generic]
        config.supportsDTMF             = true
        config.supportsHolding          = true

        callProvider = CXProvider(configuration: config)
        super.init()
        callProvider.setDelegate(self, queue: nil)
    }
}

Einen eingehenden Anruf melden

Aufgerufen von didReceiveInviteForCall (siehe Abschnitt „SDK-Delegate“). Die PushKit-Vollendung muss innerhalb des reportNewIncomingCall Callback – niemals davor.

 let update = CXCallUpdate()
 let handleType: CXHandle.HandleType = channelType == .phone ? .phoneNumber : .generic
 update.remoteHandle = CXHandle(type: handleType, value: caller)
 update.hasVideo = false

 provider.reportNewIncomingCall(with: callId, update: update) { error in
     if let error = error {
         print("Failed to report incoming call: \(error.localizedDescription)")
     }
     // Always call the PushKit completion inside this callback
     pushCompletion()
 }
 CXCallUpdate *update = [[CXCallUpdate alloc] init];
 CXHandleType handleType = (channelType == VGVoiceChannelTypePhone) ? CXHandleTypePhoneNumber : CXHandleTypeGeneric;
 update.remoteHandle = [[CXHandle alloc] initWithType:handleType value:caller];
 update.hasVideo = NO;

 [provider reportNewIncomingCallWithUUID:callId update:update completion:^(NSError * _Nullable error) {
     if (error != nil) {
         NSLog(@"Failed to report incoming call: %@", error);
     }
     // Always call the PushKit completion inside this callback
     pushCompletion();
 }];

CXHandle-Typ Verwendung .phoneNumber für den Typ „CXHandle“, wenn VGVoiceChannelType ist .phone (Anrufer aus dem Festnetz mit E.164-Nummern). Verwenden Sie .generic für Anrufer, die von einer App aus anrufen. Dies wirkt sich darauf aus, wie iOS den Anrufer in der nativen Anruf-Benutzeroberfläche und in der Liste der letzten Anrufe anzeigt.

CXProviderDelegate – Benutzeraktionen

extension CallManager: CXProviderDelegate {

    // Called when CallKit resets (e.g. during system errors or when the
    // provider is invalidated). Clean up all active call state.
    func providerDidReset(_ provider: CXProvider) {
        activeCall = nil
    }

    // User tapped Answer
    func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
        guard let callId = activeCallId else { action.fail(); return }

        client.answer(callId) { error in
            if error == nil {
                action.fulfill()
            } else {
                action.fail()
            }
        }
    }

    // User tapped End (covers both Reject while ringing and Hang up during call)
    func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
        guard let callId = activeCallId else { action.fail(); return }

        let complete: (Error?) -> Void = { error in
            error == nil ? action.fulfill() : action.fail()
        }

        if isCallRinging {
            client.reject(callId, callback: complete)
        } else {
            client.hangup(callId, callback: complete)
        }
    }

    // Mute toggle
    func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) {
        guard let callId = activeCallId else { action.fail(); return }

        if action.isMuted {
            client.mute(callId) { _ in action.fulfill() }
        } else {
            client.unmute(callId) { _ in action.fulfill() }
        }
    }

    // Hold toggle
    func provider(_ provider: CXProvider, perform action: CXSetHeldCallAction) {
        guard let callId = activeCallId else { action.fail(); return }

        if action.isOnHold {
            client.enableEarmuff(callId) { _ in action.fulfill() }
        } else {
            client.disableEarmuff(callId) { _ in action.fulfill() }
        }
    }

    // DTMF
    func provider(_ provider: CXProvider, perform action: CXPlayDTMFCallAction) {
        guard let callId = activeCallId else { action.fail(); return }
        client.sendDTMF(callId, withDigits: action.digits) { _ in action.fulfill() }
    }
}

Übergabe von Audiositzungen

 // In CXProviderDelegate - provider(_:didActivate:)
 func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
     VGVoiceClient.enableAudio(audioSession)
 }

 // In CXProviderDelegate - provider(_:didDeactivate:)
 func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
     VGVoiceClient.disableAudio(audioSession)
 }
 // In CXProviderDelegate - provider:didActivateAudioSession:
 [VGVoiceClient enableAudio:audioSession];

 // In CXProviderDelegate - provider:didDeactivateAudioSession:
 [VGVoiceClient disableAudio:audioSession];

Immer Vonage-Audio ausschließlich innerhalb von aktivieren und deaktivieren didActivate / didDeactivate. Wenn die Audiofunktion aktiviert wird, bevor CallKit die Sitzung aktiviert, oder über einen UI-Button-Handler, führt dies zu Fehlern bei der Audioweiterleitung über Bluetooth, AirPods und CarPlay.

SDK-Beauftragter

VGVoiceClientDelegate – Ereignisse bei eingehenden Anrufen

extension CallManager: VGVoiceClientDelegate {

    func voiceClient(_ client: VGVoiceClient,
                     didReceiveInviteForCall callId: VGCallId,
                     from caller: String,
                     with type: VGVoiceChannelType) {

        guard let callUUID = UUID(uuidString: callId) else { return }

        activeCallId   = callId
        activeCallUUID = callUUID
        isCallRinging  = true

        // Report to CallKit, then call the PushKit completion in the callback
        reportIncomingCall(callUUID: callUUID, caller: caller, type: type,
                           pushCompletion: pendingPushCompletion ?? {})
        pendingPushCompletion = nil
    }

    // Remote party hung up or the call timed out
    func voiceClient(_ client: VGVoiceClient,
                     didReceiveHangupForCall callId: VGCallId,
                     withQuality callQuality: VGRTCQuality,
                     reason: VGHangupReason) {

        guard let uuid = activeCallUUID else { return }

        let cxReason: CXCallEndedReason = switch reason {
            case .remoteReject:           .declinedElsewhere
            case .remoteHangup, .localHangup: .remoteEnded
            case .remoteNoAnswerTimeout:  .unanswered
            default:                      .failed
        }

        callProvider.reportCall(with: uuid, endedAt: .now, reason: cxReason)
        cleanUpCallState()
    }

    // Caller cancelled the invite before it was answered
    func voiceClient(_ client: VGVoiceClient,
                     didReceiveInviteCancelForCall callId: VGCallId,
                     with reason: VGVoiceInviteCancelReason) {

        guard let uuid = activeCallUUID else { return }

        let cxReason: CXCallEndedReason = switch reason {
            case .answeredElsewhere: .answeredElsewhere
            case .rejectedElsewhere: .declinedElsewhere
            case .remoteCancel:      .remoteEnded
            case .remoteTimeout:     .unanswered
            default:                 .failed
        }

        callProvider.reportCall(with: uuid, endedAt: .now, reason: cxReason)
        cleanUpCallState()
    }

    private func cleanUpCallState() {
        activeCallId   = nil
        activeCallUUID = nil
        isCallRinging  = false
    }
}

Rufen Sie immer an callProvider.reportCall(with:endedAt:reason:) für jeder Beendigungsweg – Auflegen durch den Anrufer, Abbruch durch den Anrufer, Medien-Timeout, lokale Ablehnung. Fehlt einer dieser Einträge, gerät CallKit in einen inkonsistenten Zustand, was zu Fehlern im Anrufprotokoll und bei der zukünftigen Anrufauswertung führen kann.

Ausgehende Anrufe

Bei ausgehenden Anrufen ist der Ablauf umgekehrt: Ihre App initiiert den Anruf über das SDK und nutzt dann CXCallController um CallKit zu informieren.

// 1. Initiate the call via the SDK
func startCall(to callee: String) {
    let callContext = ["callee": callee]

    client.serverCall(callContext) { [weak self] error, callId in
        guard let callId,
              let callUUID = UUID(uuidString: callId) else { return }

        self?.activeCallId   = callId
        self?.activeCallUUID = callUUID

        // 2. Notify CallKit so the in-call UI appears
        let handle = CXHandle(type: .generic, value: callee)
        let action = CXStartCallAction(call: callUUID, handle: handle)
        let transaction = CXTransaction(action: action)

        self?.callController.request(transaction) { [weak self] error in
            guard error == nil else { return }
            self?.callProvider.reportOutgoingCall(with: callUUID, startedConnectingAt: .now)
        }
    }
}

// 3. CXStartCallAction — CallKit confirming the transaction
func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
    action.fulfill()
}

// 4. When the remote side answers
func voiceClient(_ client: VGVoiceClient,
                 didReceiveLegStatusUpdateForCall callId: VGCallId,
                 withLegId legId: String,
                 andStatus status: VGLegStatus) {
    guard status == .answered, let uuid = activeCallUUID else { return }
    callProvider.reportOutgoingCall(with: uuid, connectedAt: .now)
}

Fehlerbehandlung

Das SDK wird ausgelöst didReceiveSessionErrorWith(reason:) wenn die zugrunde liegende WebSocket-Verbindung während einer aktiven Sitzung unterbrochen wird. Versuchen Sie stets, die Sitzung automatisch wiederherzustellen.

func client(_ client: VGBaseClient,
            didReceiveSessionErrorWith reason: VGSessionErrorReason) {
    print("Session error: \(reason)")

    switch reason {
    case .tokenExpired:
        // The stored JWT is now invalid — skip it, use refresh token
        refreshAndRestoreSession()

    case .pingTimeout, .transportClosed:
        // Transient network issue — try the stored JWT first
        restoreSessionIfNeeded()

    default:
        restoreSessionIfNeeded()
    }
}

private func refreshAndRestoreSession() {
    guard let refreshToken = storedRefreshToken else {
        clearSession()
        return
    }
    refreshJWT(refreshToken) { [weak self] result in
        switch result {
        case .success(let newJWT):
            self?.createSession(jwt: newJWT)
        case .failure:
            self?.clearSession()
        }
    }
}

Einschränkungen des Simulators

PushKit und CallKit stehen im iOS-Simulator nicht zur Verfügung. Das SDK bietet für Entwicklungszwecke einen WebSocket-Einladungsmodus:

let config = VGClientInitConfig(loggingLevel: .info)

#if targetEnvironment(simulator)
config.enableWebsocketInvites = true   // receive invites over WebSocket — no push needed
#endif

let client = VGVoiceClient(config)
func answerCall(callId: String) {
#if targetEnvironment(simulator)
    client.answer(callId) { error in
        print("Answer: \(error?.localizedDescription ?? "OK")")
    }
#else
    guard let uuid = activeCallUUID else { return }
    let action = CXAnswerCallAction(call: uuid)
    callController.request(CXTransaction(action: action)) { _ in }
#endif
}

Warnung: Testen Sie vor der Veröffentlichung stets den gesamten Push-Pfad auf einem physischen Gerät. Der Simulator kann das Aufwachen nach dem Beenden der App, die Wiederherstellung der Sitzung unter realistischen Hintergrund-Budgetbedingungen sowie die CallKit-Benutzeroberfläche auf dem Sperrbildschirm nicht simulieren.

Fallstricke und Anti-Muster

1 – Aufruf von „PushKit Completion“ vor reportNewIncomingCall

Nicht tun:

func pushRegistry(_ registry: PKPushRegistry,
                  didReceiveIncomingPushWith payload: PKPushPayload,
                  for type: PKPushType,
                  completion: @escaping () -> Void) {
    client.processCallInvitePushData(payload.dictionaryPayload)
    completion() // WRONG — called before reportNewIncomingCall
}

Speichern Sie die Fertigstellung und rufen Sie sie ausschließlich innerhalb von reportNewIncomingCallder asynchrone Callback.

2 – Seilspringen reportNewIncomingCall bei ungültigen Pushes

Nicht tun:

guard pushType == .incomingCall else {
    completion() // WRONG — no call reported to CallKit
    return
}

Vorgehensweise: Melden Sie einen Scheinanruf und beenden Sie ihn sofort; rufen Sie anschließend die Abschlussfunktion auf:

guard pushType == .incomingCall else {
    let fakeUUID = UUID()
    callProvider.reportNewIncomingCall(with: fakeUUID, update: CXCallUpdate()) { _ in
        self.callProvider.reportCall(with: fakeUUID, endedAt: .now, reason: .failed)
        completion()
    }
    return
}

3 – Warten auf die Wiederherstellung der Sitzung vor der Verarbeitung des Pushs

Nicht tun:

restoreSessionIfNeeded { // Waits for network...
    self.client.processCallInvitePushData(payload) // Too late
}

Vorgehensweise: Beide gleichzeitig auslösen:

restoreSessionIfNeeded()                          // Non-blocking
client.processCallInvitePushData(payload.dictionaryPayload) // Immediate

4 – Abrufen eines JWT aus Ihrem Backend bei Erhalt einer Push-Benachrichtigung

Nicht tun:

func handleIncomingPush(payload: PKPushPayload, completion: @escaping () -> Void) {
    fetchJWTFromServer { jwt in        // 500ms - 3s network call
        self.client.createSession(jwt) // Another network call
        self.client.processCallInvitePushData(payload.dictionaryPayload)
    }
}

Vorgehensweise: Speichern Sie das JWT bei der Anmeldung. Verwenden Sie es direkt beim Empfang einer Push-Benachrichtigung, ohne Backend-Aufrufe.

5 – Hardcoding isSandbox: true

Nicht tun:

client.registerVoipToken(token, isSandbox: true)  // Breaks TestFlight

Tun:

#if DEBUG
let isSandbox = true
#else
let isSandbox = false
#endif
client.registerVoipToken(token, isSandbox: isSandbox)

6 – Neuregistrierung des Push-Tokens bei jeder Wiederherstellung der Sitzung

Nicht tun:

func createSession(jwt: String) {
    client.createSession(jwt) { _, _ in
        self.registerVoipToken(self.storedToken) // Accumulates device entries
    }
}

Zu beachten: Die Registrierung darf nur bei der Benutzeranmeldung oder wenn didUpdate pushCredentials Feuer (Token-Rotation).

7 – Vergessen, sich beim Abmelden abzumelden

Wenn Sie diesen Schritt überspringen unregisterDeviceTokens(byDeviceId:) Nach der Abmeldung empfängt das Gerät weiterhin auf unbestimmte Zeit VoIP-Push-Benachrichtigungen für diesen Benutzer – auch nachdem sich ein anderer Benutzer angemeldet hat.

8 – Nicht alle Gründe für das Beenden eines Anrufs werden an CallKit übermittelt

Jeder Anrufabschlusspfad muss callProvider.reportCall(with:endedAt:reason:) mit dem richtigen CXCallEndedReason. Fehlt ein Pfad, wird das Protokoll der Systemaufrufe beschädigt.

9 – Audio außerhalb von CallKit-Callbacks aktivieren

Nicht tun:

func answerButtonTapped() {
    VGVoiceClient.enableAudio(AVAudioSession.sharedInstance()) // WRONG
    client.answer(callId) { _ in }
}

So geht’s: Verarbeiten Sie Audio ausschließlich im CallKit-Delegaten:

func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
    VGVoiceClient.enableAudio(audioSession)
}
func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
    VGVoiceClient.disableAudio(audioSession)
}

10 – Abgelaufene Push-Zertifikate

VoIP-Zertifikate laufen nach einem Jahr ab. Der Ausfall erfolgt vollständig und unbemerkt. Erneuern Sie die Zertifikate proaktiv und Verify die Push-Zustellung unmittelbar nach dem Hochladen eines neuen Zertifikats.

Checkliste für die Produktion

Verwenden Sie diese Checkliste vor dem Versand:

  • [ ] Xcode-Funktionen konfiguriert — Push-Benachrichtigungen aktiviert; Hintergrundmodi: Voice-over-IP + Hintergrundverarbeitung aktiviert
  • [ ] VoIP-Push-Zertifikat für beide Umgebungen hochgeladen — Sandbox für die Fehlersuche, Production für TestFlight/App Store
  • [ ] isSandbox Verwendungszwecke von Flaggen #if DEBUG — niemals fest codiert
  • [ ] JWT und Refresh-Token werden bei der Anmeldung lokal gespeichert — Die Wiederherstellung der Sitzung darf keinen Backend-Aufruf erfordern.
  • [ ] reportNewIncomingCall forderte, dass ausnahmslos jeder VoIP-Push berücksichtigt wird — einschließlich ungültiger, doppelter oder nicht von Vonage stammender Push-Benachrichtigungen
  • [ ] PushKit-Vervollständigung wird nur intern aufgerufen reportNewIncomingCall's Callback — noch nie zuvor, noch nie ausgelassen
  • [ ] Die Wiederherstellung von Sitzungen und die Push-Verarbeitung erfolgen parallel - restoreSessionIfNeeded() und processCallInvitePushData() selbstständig schießen
  • [ ] Token-Registrierung nur bei Benutzeranmeldung oder Token-Rotation — nicht bei jedem createSession
  • [ ] unregisterDeviceTokens bei explizitem Abmelden aufgerufen
  • [ ] Alle Anrufabschlusswege melden sich bei CallKit — Fernauflegung, Abbruch, Zeitüberschreitung, lokale Ablehnung, Medienausfall
  • [ ] Audio nur in didActivate / didDeactivate
  • [ ] CallKit-Code, geschützt durch #if !targetEnvironment(simulator) - enableWebsocketInvites = true für den Simulator
  • [ ] Auf einem physischen Gerät getestet, wobei die App vollständig beendet wurde — Übungen zum PushKit-Wakeup- und Session-Restoration-Race
  • [ ] Getestet auf einem physischen Gerät, wobei die App im Hintergrund lief (angestoppt)
  • [ ] Verfolgung des Datums für die Erneuerung des Push-Zertifikats — Zertifikate laufen nach einem Jahr ab

Weitere Lektüre