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
- In der Apple-Entwicklerportal → Zertifikate, Identifikatoren und Profile, stellen Sie sicher, dass Ihre App-ID hat die Push-Benachrichtigungen Funktion aktiviert.
- 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.
- 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
- Melden Sie sich bei der Vonage Dashboard.
- Gehe zu Applications → Ihre Anwendung (der
application_id(an dem das JWT Ihrer App die Authentifizierung vornimmt). - Öffnen Sie die Push-Benachrichtigungen aktivieren Registerkarte und laden Sie die
.p12. - 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
- [ ]
isSandboxVerwendungszwecke 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.
- [ ]
reportNewIncomingCallforderte, 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()undprocessCallInvitePushData()selbstständig schießen - [ ] Token-Registrierung nur bei Benutzeranmeldung oder Token-Rotation — nicht bei jedem
createSession - [ ]
unregisterDeviceTokensbei 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 = truefü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