expo-callkit-telecom 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +197 -0
  3. package/android/build.gradle +32 -0
  4. package/android/src/main/AndroidManifest.xml +33 -0
  5. package/android/src/main/java/expo/modules/callkittelecom/ExpoCallKitTelecomModule.kt +384 -0
  6. package/android/src/main/java/expo/modules/callkittelecom/IncomingCallActivity.kt +275 -0
  7. package/android/src/main/java/expo/modules/callkittelecom/events/CallEventEmitter.kt +151 -0
  8. package/android/src/main/java/expo/modules/callkittelecom/events/CallEvents.kt +59 -0
  9. package/android/src/main/java/expo/modules/callkittelecom/managers/CallAudioManager.kt +361 -0
  10. package/android/src/main/java/expo/modules/callkittelecom/managers/CallManager.kt +891 -0
  11. package/android/src/main/java/expo/modules/callkittelecom/managers/CallNotificationManager.kt +445 -0
  12. package/android/src/main/java/expo/modules/callkittelecom/managers/CaptureSessionManager.kt +27 -0
  13. package/android/src/main/java/expo/modules/callkittelecom/managers/DialtonePlayer.kt +171 -0
  14. package/android/src/main/java/expo/modules/callkittelecom/managers/FulfillRequestManager.kt +150 -0
  15. package/android/src/main/java/expo/modules/callkittelecom/managers/VoIPPushManager.kt +54 -0
  16. package/android/src/main/java/expo/modules/callkittelecom/models/CallModels.kt +269 -0
  17. package/android/src/main/java/expo/modules/callkittelecom/services/CallNotificationReceiver.kt +54 -0
  18. package/android/src/main/java/expo/modules/callkittelecom/services/ExpoCallKitTelecomMessagingService.kt +161 -0
  19. package/android/src/main/java/expo/modules/callkittelecom/store/CallStore.kt +181 -0
  20. package/android/src/main/java/expo/modules/callkittelecom/utils/CallKitTelecomLog.kt +52 -0
  21. package/android/src/main/java/expo/modules/callkittelecom/utils/PermissionUtils.kt +28 -0
  22. package/android/src/main/res/drawable/expo_callkit_telecom_bg_answer.xml +9 -0
  23. package/android/src/main/res/drawable/expo_callkit_telecom_bg_avatar.xml +5 -0
  24. package/android/src/main/res/drawable/expo_callkit_telecom_bg_decline.xml +9 -0
  25. package/android/src/main/res/drawable/expo_callkit_telecom_ic_answer.xml +9 -0
  26. package/android/src/main/res/drawable/expo_callkit_telecom_ic_decline.xml +9 -0
  27. package/android/src/main/res/drawable/expo_callkit_telecom_ic_videocam.xml +9 -0
  28. package/android/src/main/res/layout/activity_incoming_call.xml +169 -0
  29. package/app.json +8 -0
  30. package/app.plugin.js +1 -0
  31. package/build/Calls.d.ts +577 -0
  32. package/build/Calls.d.ts.map +1 -0
  33. package/build/Calls.js +715 -0
  34. package/build/Calls.js.map +1 -0
  35. package/build/Calls.types.d.ts +203 -0
  36. package/build/Calls.types.d.ts.map +1 -0
  37. package/build/Calls.types.js +2 -0
  38. package/build/Calls.types.js.map +1 -0
  39. package/build/ExpoCallKitTelecomModule.d.ts +3 -0
  40. package/build/ExpoCallKitTelecomModule.d.ts.map +1 -0
  41. package/build/ExpoCallKitTelecomModule.js +4 -0
  42. package/build/ExpoCallKitTelecomModule.js.map +1 -0
  43. package/build/hooks/index.d.ts +2 -0
  44. package/build/hooks/index.d.ts.map +1 -0
  45. package/build/hooks/index.js +2 -0
  46. package/build/hooks/index.js.map +1 -0
  47. package/build/hooks/useVoIPPushToken.d.ts +14 -0
  48. package/build/hooks/useVoIPPushToken.d.ts.map +1 -0
  49. package/build/hooks/useVoIPPushToken.js +26 -0
  50. package/build/hooks/useVoIPPushToken.js.map +1 -0
  51. package/build/index.d.ts +4 -0
  52. package/build/index.d.ts.map +1 -0
  53. package/build/index.js +4 -0
  54. package/build/index.js.map +1 -0
  55. package/expo-module.config.json +10 -0
  56. package/ios/AppDelegateSubscriber.swift +93 -0
  57. package/ios/ExpoCallKitTelecom.podspec +31 -0
  58. package/ios/ExpoCallKitTelecomLogger.swift +55 -0
  59. package/ios/ExpoCallKitTelecomModule.swift +503 -0
  60. package/ios/Managers/AudioManager.swift +363 -0
  61. package/ios/Managers/CallEventEmitter.swift +199 -0
  62. package/ios/Managers/CallManager+CXProviderDelegate.swift +195 -0
  63. package/ios/Managers/CallManager.swift +714 -0
  64. package/ios/Managers/CaptureSessionManager.swift +54 -0
  65. package/ios/Managers/DialtonePlayer.swift +126 -0
  66. package/ios/Managers/FulfillRequestManager.swift +154 -0
  67. package/ios/Managers/VoIPPushManager+PKPushRegistryDelegate.swift +123 -0
  68. package/ios/Managers/VoIPPushManager.swift +58 -0
  69. package/ios/Models/CallEvents.swift +263 -0
  70. package/ios/Models/CallOptions.swift +15 -0
  71. package/ios/Models/CallParticipant.swift +37 -0
  72. package/ios/Models/CallSession.swift +80 -0
  73. package/ios/Models/IncomingCallEvent.swift +196 -0
  74. package/ios/Stores/CallStore.swift +149 -0
  75. package/package.json +56 -0
  76. package/plugin/build/constants.d.ts +3 -0
  77. package/plugin/build/constants.js +7 -0
  78. package/plugin/build/withExpoCallKitTelecom.d.ts +67 -0
  79. package/plugin/build/withExpoCallKitTelecom.js +16 -0
  80. package/plugin/build/withExpoCallKitTelecomAndroid.d.ts +3 -0
  81. package/plugin/build/withExpoCallKitTelecomAndroid.js +177 -0
  82. package/plugin/build/withExpoCallKitTelecomIos.d.ts +3 -0
  83. package/plugin/build/withExpoCallKitTelecomIos.js +195 -0
  84. package/plugin/src/constants.ts +4 -0
  85. package/plugin/src/withExpoCallKitTelecom.ts +83 -0
  86. package/plugin/src/withExpoCallKitTelecomAndroid.ts +293 -0
  87. package/plugin/src/withExpoCallKitTelecomIos.ts +276 -0
  88. package/src/Calls.ts +848 -0
  89. package/src/Calls.types.ts +275 -0
  90. package/src/ExpoCallKitTelecomModule.ts +4 -0
  91. package/src/hooks/index.ts +1 -0
  92. package/src/hooks/useVoIPPushToken.ts +34 -0
  93. package/src/index.ts +3 -0
@@ -0,0 +1,54 @@
1
+ import AVFoundation
2
+ import livekit_react_native_webrtc
3
+
4
+ private func permissionStatusString(_ status: AVAuthorizationStatus) -> String {
5
+ switch status {
6
+ case .authorized:
7
+ return "granted"
8
+ case .denied:
9
+ return "denied"
10
+ case .notDetermined:
11
+ return "undetermined"
12
+ case .restricted:
13
+ return "restricted"
14
+ @unknown default:
15
+ return "unknown"
16
+ }
17
+ }
18
+
19
+ /// Manages video capture session state.
20
+ final class CaptureSessionManager {
21
+ static let shared = CaptureSessionManager()
22
+
23
+ private init() {
24
+ configureMultitaskingCameraAccess()
25
+ }
26
+
27
+ /// Configures WebRTC to enable multitasking camera access.
28
+ /// On iOS 18+ with voip background mode, this allows the camera to continue
29
+ /// capturing when the app enters PiP or slides over another app.
30
+ private func configureMultitaskingCameraAccess() {
31
+ let options = WebRTCModuleOptions.sharedInstance()
32
+ options.enableMultitaskingCameraAccess = true
33
+ Log.call.debug("Enabled multitasking camera access for WebRTC")
34
+ }
35
+
36
+ /// Returns the current capture session state.
37
+ func getCaptureSessionState() -> [String: Any] {
38
+ let cameraPermission = permissionStatusString(
39
+ AVCaptureDevice.authorizationStatus(for: .video)
40
+ )
41
+
42
+ var state: [String: Any] = [
43
+ "cameraPermission": cameraPermission,
44
+ ]
45
+
46
+ // Check multitasking camera access support (iOS 16+)
47
+ if #available(iOS 16.0, *) {
48
+ let session = AVCaptureSession()
49
+ state["isMultitaskingCameraAccessSupported"] = session.isMultitaskingCameraAccessSupported
50
+ }
51
+
52
+ return state
53
+ }
54
+ }
@@ -0,0 +1,126 @@
1
+ import AVFoundation
2
+ import os
3
+
4
+ /// Plays the dialtone sound during outgoing call connection.
5
+ ///
6
+ /// Reads the dialtone filename from Info.plist (ExpoCallKitTelecomDefaultDialtone) and plays
7
+ /// it in a loop until stopped.
8
+ final class DialtonePlayer {
9
+ static let shared = DialtonePlayer()
10
+
11
+ private var player: AVAudioPlayer?
12
+ private let dialtoneFilename: String?
13
+ private var fadeTimer: Timer?
14
+
15
+ /// Delay before starting playback to let audio session settle (in seconds)
16
+ private let startDelay: TimeInterval = 0.05
17
+ /// Duration of volume fade-in (in seconds)
18
+ private let fadeInDuration: TimeInterval = 0.1
19
+
20
+ private init() {
21
+ dialtoneFilename =
22
+ Bundle.main.object(
23
+ forInfoDictionaryKey: "ExpoCallKitTelecomDefaultDialtone"
24
+ ) as? String
25
+ }
26
+
27
+ /// Whether a dialtone is configured in Info.plist.
28
+ var hasDialtone: Bool {
29
+ dialtoneFilename != nil
30
+ }
31
+
32
+ /// Starts playing the dialtone sound in a loop.
33
+ ///
34
+ /// Does nothing if no dialtone is configured or if already playing.
35
+ func play() {
36
+ guard let filename = dialtoneFilename else {
37
+ Log.call.debug("No dialtone configured, skipping playback")
38
+ return
39
+ }
40
+
41
+ guard player == nil else {
42
+ Log.call.debug("Dialtone already playing")
43
+ return
44
+ }
45
+
46
+ guard
47
+ let url = Bundle.main.url(forResource: filename, withExtension: nil)
48
+ ?? Bundle.main.url(
49
+ forResource: (filename as NSString).deletingPathExtension,
50
+ withExtension: (filename as NSString).pathExtension
51
+ )
52
+ else {
53
+ Log.call.error("Dialtone sound file not found: \(filename)")
54
+ return
55
+ }
56
+
57
+ // Ensure audio session is active before playing
58
+ guard AudioManager.shared.isActive else {
59
+ Log.call.debug("Audio session not active, skipping dialtone")
60
+ return
61
+ }
62
+
63
+ do {
64
+ player = try AVAudioPlayer(contentsOf: url)
65
+ player?.numberOfLoops = -1 // Loop indefinitely
66
+ player?.volume = 0 // Start silent for fade-in
67
+
68
+ // Brief delay to let audio session settle, then play with fade-in
69
+ DispatchQueue.main.asyncAfter(deadline: .now() + startDelay) { [weak self] in
70
+ guard let self = self, let player = self.player else { return }
71
+
72
+ player.play()
73
+ self.fadeIn()
74
+ Log.call.debug("Started playing dialtone: \(filename)")
75
+ }
76
+ } catch {
77
+ Log.call.error("Failed to play dialtone: \(error.localizedDescription)")
78
+ player = nil
79
+ }
80
+ }
81
+
82
+ /// Fades in the volume from 0 to 1 over fadeInDuration.
83
+ private func fadeIn() {
84
+ fadeTimer?.invalidate()
85
+
86
+ let steps = 10
87
+ let stepDuration = fadeInDuration / Double(steps)
88
+ let volumeStep: Float = 1.0 / Float(steps)
89
+ var currentStep = 0
90
+
91
+ fadeTimer = Timer.scheduledTimer(withTimeInterval: stepDuration, repeats: true) {
92
+ [weak self] timer in
93
+ guard let self = self, let player = self.player else {
94
+ timer.invalidate()
95
+ return
96
+ }
97
+
98
+ currentStep += 1
99
+ player.volume = volumeStep * Float(currentStep)
100
+
101
+ if currentStep >= steps {
102
+ timer.invalidate()
103
+ self.fadeTimer = nil
104
+ }
105
+ }
106
+ }
107
+
108
+ /// Stops playing the dialtone sound.
109
+ func stop() {
110
+ fadeTimer?.invalidate()
111
+ fadeTimer = nil
112
+
113
+ guard player != nil else {
114
+ return
115
+ }
116
+
117
+ player?.stop()
118
+ player = nil
119
+ Log.call.debug("Stopped playing dialtone")
120
+ }
121
+
122
+ /// Whether the dialtone is currently playing.
123
+ var isPlaying: Bool {
124
+ player?.isPlaying ?? false
125
+ }
126
+ }
@@ -0,0 +1,154 @@
1
+ import Foundation
2
+ import os
3
+
4
+ /// Manages pending fulfill requests with automatic timeout handling.
5
+ ///
6
+ /// When a CallKit action requires asynchronous fulfillment (e.g., waiting for JS
7
+ /// to connect media), this manager tracks the pending request with a unique ID
8
+ /// and handles timeouts gracefully.
9
+ ///
10
+ /// Usage:
11
+ /// 1. Create a request with `createRequest(callId:timeout:)`
12
+ /// 2. Send the request ID to JS via an event
13
+ /// 3. When JS calls back, use `fulfill(requestId:)` to complete or no-op if timed out
14
+ /// 4. Await the result task to get the outcome
15
+ actor FulfillRequestManager {
16
+ /// Shared instance for global access.
17
+ static let shared = FulfillRequestManager()
18
+
19
+ /// Result of a fulfill request.
20
+ enum Result {
21
+ /// The request was successfully fulfilled, includes the associated call ID.
22
+ case fulfilled(callId: UUID)
23
+ /// The request was explicitly cancelled (e.g., JS-side failure before fulfilling).
24
+ case cancelled
25
+ /// The request timed out before being fulfilled.
26
+ case timedOut
27
+ }
28
+
29
+ /// A pending request awaiting fulfillment.
30
+ private struct PendingRequest {
31
+ let id: UUID
32
+ let callId: UUID
33
+ let createdAt: Date
34
+ let continuation: CheckedContinuation<Result, Never>
35
+ let timeoutTask: Task<Void, Never>
36
+ }
37
+
38
+ /// Active pending requests keyed by request ID.
39
+ private var pendingRequests: [UUID: PendingRequest] = [:]
40
+
41
+ private init() {}
42
+
43
+ /// Creates a new pending request with the specified call ID and timeout.
44
+ ///
45
+ /// - Parameters:
46
+ /// - callId: The UUID of the call associated with this request.
47
+ /// - timeout: Duration before the request automatically times out.
48
+ /// - Returns: A tuple containing the request ID and a task that resolves
49
+ /// when the request is fulfilled or times out.
50
+ func createRequest(callId: UUID, timeout: Duration) -> (
51
+ requestId: UUID, result: Task<Result, Never>
52
+ ) {
53
+ let requestId = UUID()
54
+
55
+ let resultTask = Task<Result, Never> { [weak self] in
56
+ await withCheckedContinuation { continuation in
57
+ guard let self = self else {
58
+ continuation.resume(returning: .timedOut)
59
+ return
60
+ }
61
+
62
+ let timeoutTask = Task { [weak self] in
63
+ try? await Task.sleep(for: timeout)
64
+
65
+ guard !Task.isCancelled, let self = self else { return }
66
+
67
+ // Timeout expired - remove and resume with timedOut
68
+ if let request = await self.removeRequest(for: requestId) {
69
+ Log.call.debug("Fulfill request timed out - requestId: \(requestId)")
70
+ request.continuation.resume(returning: .timedOut)
71
+ }
72
+ }
73
+
74
+ let request = PendingRequest(
75
+ id: requestId,
76
+ callId: callId,
77
+ createdAt: Date(),
78
+ continuation: continuation,
79
+ timeoutTask: timeoutTask
80
+ )
81
+
82
+ Task { [weak self] in
83
+ await self?.addRequest(request)
84
+ }
85
+
86
+ Log.call.debug(
87
+ "Created fulfill request - requestId: \(requestId), callId: \(callId), timeout: \(timeout)"
88
+ )
89
+ }
90
+ }
91
+
92
+ return (requestId, resultTask)
93
+ }
94
+
95
+ /// Attempts to fulfill a pending request.
96
+ ///
97
+ /// If the request exists, it will be fulfilled and removed. If the request
98
+ /// has already timed out or was never created, this is a no-op.
99
+ ///
100
+ /// - Parameter requestId: The unique ID of the request to fulfill.
101
+ /// - Returns: The associated call ID if fulfilled, nil if not found.
102
+ func fulfill(requestId: UUID) -> UUID? {
103
+ guard let request = pendingRequests.removeValue(forKey: requestId) else {
104
+ Log.call.debug("Fulfill request not found (likely timed out) - requestId: \(requestId)")
105
+ return nil
106
+ }
107
+
108
+ // Cancel the timeout task since we're fulfilling
109
+ request.timeoutTask.cancel()
110
+
111
+ Log.call.debug("Fulfill request succeeded - requestId: \(requestId), callId: \(request.callId)")
112
+ request.continuation.resume(returning: .fulfilled(callId: request.callId))
113
+ return request.callId
114
+ }
115
+
116
+ /// Cancels a pending request without fulfilling it.
117
+ ///
118
+ /// Use this when the request should be aborted (e.g., call ended before connection).
119
+ ///
120
+ /// - Parameter requestId: The unique ID of the request to cancel.
121
+ func cancel(requestId: UUID) {
122
+ guard let request = pendingRequests.removeValue(forKey: requestId) else {
123
+ return
124
+ }
125
+
126
+ request.timeoutTask.cancel()
127
+ Log.call.debug("Fulfill request cancelled - requestId: \(requestId)")
128
+ request.continuation.resume(returning: .cancelled)
129
+ }
130
+
131
+ /// Cancels all pending requests.
132
+ ///
133
+ /// Use this when cleaning up (e.g., provider reset).
134
+ func cancelAll() {
135
+ let requests = pendingRequests
136
+ pendingRequests.removeAll()
137
+
138
+ for (id, request) in requests {
139
+ request.timeoutTask.cancel()
140
+ Log.call.debug("Fulfill request cancelled (cancelAll) - requestId: \(id)")
141
+ request.continuation.resume(returning: .cancelled)
142
+ }
143
+ }
144
+
145
+ // MARK: - Private Helpers
146
+
147
+ private func addRequest(_ request: PendingRequest) {
148
+ pendingRequests[request.id] = request
149
+ }
150
+
151
+ private func removeRequest(for requestId: UUID) -> PendingRequest? {
152
+ pendingRequests.removeValue(forKey: requestId)
153
+ }
154
+ }
@@ -0,0 +1,123 @@
1
+ import Foundation
2
+ import PushKit
3
+
4
+ extension VoIPPushManager: PKPushRegistryDelegate {
5
+ /// Called when push credentials are updated.
6
+ ///
7
+ /// Converts the token data to a hex string and stores it for JS access.
8
+ nonisolated func pushRegistry(
9
+ _ registry: PKPushRegistry,
10
+ didUpdate pushCredentials: PKPushCredentials,
11
+ for type: PKPushType
12
+ ) {
13
+ guard type == .voIP else { return }
14
+
15
+ let tokenData = pushCredentials.token
16
+ let tokenString = tokenData.map { String(format: "%02x", $0) }.joined()
17
+
18
+ Task { @MainActor in
19
+ updateToken(tokenString)
20
+ }
21
+ }
22
+
23
+ /// Called when push credentials are invalidated.
24
+ ///
25
+ /// Clears the stored token and notifies JS.
26
+ nonisolated func pushRegistry(
27
+ _ registry: PKPushRegistry,
28
+ didInvalidatePushTokenFor type: PKPushType
29
+ ) {
30
+ guard type == .voIP else { return }
31
+
32
+ Log.voipPush.debug("VoIP push token invalidated")
33
+
34
+ Task { @MainActor in
35
+ updateToken(nil)
36
+ }
37
+ }
38
+
39
+ /// Called when a VoIP push notification is received.
40
+ ///
41
+ /// This method MUST report a call to CallKit before returning, per Apple's requirements.
42
+ /// The payload is expected to wrap an `IncomingCallEvent` under the top-level
43
+ /// key `"incoming_call"`, matching `IncomingCallEventParser`.
44
+ ///
45
+ /// - Parameters:
46
+ /// - registry: The push registry.
47
+ /// - payload: The push payload containing the incoming call event.
48
+ /// - type: The push type (should be .voIP).
49
+ /// - completion: Completion handler that must be called after processing.
50
+ nonisolated func pushRegistry(
51
+ _ registry: PKPushRegistry,
52
+ didReceiveIncomingPushWith payload: PKPushPayload,
53
+ for type: PKPushType,
54
+ completion: @escaping () -> Void
55
+ ) {
56
+ guard type == .voIP else {
57
+ completion()
58
+ return
59
+ }
60
+
61
+ let dictionaryPayload = payload.dictionaryPayload
62
+
63
+ Log.voipPush.debug("Received VoIP push - payload keys: \(dictionaryPayload.keys)")
64
+
65
+ guard let event = IncomingCallEventParser.parse(from: dictionaryPayload) else {
66
+ Log.voipPush.error("Failed to parse VoIP push payload as IncomingCallEvent")
67
+ // We must still report a call to CallKit even on parse failure, or the
68
+ // app will be terminated.
69
+ reportFailedIncomingCall(completion: completion)
70
+ return
71
+ }
72
+
73
+ Log.voipPush.debug(
74
+ "Parsed incoming call event - serverCallId: \(event.serverCallId)"
75
+ )
76
+
77
+ // Report the incoming call to CallKit using callback-based API
78
+ // (async/await may not work reliably when app is launched from terminated state)
79
+ CallManager.shared.reportIncomingCall(event: event) { error in
80
+ if let error = error {
81
+ Log.voipPush.error("Failed to report incoming call: \(error.localizedDescription)")
82
+ } else {
83
+ Log.voipPush.debug("Successfully reported incoming call from VoIP push")
84
+ }
85
+ completion()
86
+ }
87
+ }
88
+
89
+ /// Reports a failed incoming call to CallKit when we can't parse the push payload.
90
+ ///
91
+ /// Per Apple's requirements, we must report a call to CallKit when receiving a VoIP push.
92
+ /// If we can't parse the payload, we report a call and immediately end it.
93
+ private nonisolated func reportFailedIncomingCall(completion: @escaping () -> Void) {
94
+ let fallbackEvent = IncomingCallEvent(
95
+ eventId: UUID().uuidString.lowercased(),
96
+ serverCallId: UUID().uuidString.lowercased(),
97
+ caller: IncomingCallEvent.Caller(
98
+ id: UUID().uuidString,
99
+ displayName: "Invalid Call",
100
+ avatarUrl: nil,
101
+ phoneNumber: nil,
102
+ email: nil
103
+ ),
104
+ hasVideo: false,
105
+ startedAt: Date(),
106
+ metadata: nil
107
+ )
108
+
109
+ // Use callback-based API for reliability when app is launched from terminated state
110
+ CallManager.shared.reportIncomingCall(event: fallbackEvent) { error in
111
+ if let error = error {
112
+ Log.voipPush.error("Failed to report fallback incoming call: \(error.localizedDescription)")
113
+ }
114
+ // Immediately end the call since it's invalid
115
+ Task {
116
+ if let session = await CallManager.shared.store.firstSession {
117
+ await CallManager.shared.reportCallEnded(for: session.id, reason: .failed)
118
+ }
119
+ }
120
+ completion()
121
+ }
122
+ }
123
+ }
@@ -0,0 +1,58 @@
1
+ import Foundation
2
+ import PushKit
3
+
4
+ /// Manages VoIP push notification registration and token handling.
5
+ ///
6
+ /// This singleton handles:
7
+ /// - Registering for VoIP push notifications via PushKit
8
+ /// - Storing and exposing the current push token
9
+ /// - Emitting events when the token updates or is invalidated
10
+ ///
11
+ /// VoIP pushes are used to receive incoming call notifications and must report
12
+ /// a call to CallKit immediately upon receipt.
13
+ final class VoIPPushManager: NSObject {
14
+ static let shared = VoIPPushManager()
15
+
16
+ /// The PushKit registry for VoIP notifications.
17
+ private var pushRegistry: PKPushRegistry?
18
+
19
+ /// The current VoIP push token, if available.
20
+ private(set) var token: String?
21
+
22
+ private override init() {
23
+ super.init()
24
+ }
25
+
26
+ /// Registers for VoIP push notifications.
27
+ ///
28
+ /// This creates a PKPushRegistry and requests VoIP push credentials.
29
+ /// The token will be available via the `token` property once received,
30
+ /// and a `VoIPPushTokenUpdatedEvent` will be emitted.
31
+ func register() {
32
+ guard pushRegistry == nil else {
33
+ Log.voipPush.debug("VoIP push already registered")
34
+ return
35
+ }
36
+
37
+ Log.voipPush.debug("Registering for VoIP push notifications")
38
+
39
+ let registry = PKPushRegistry(queue: .main)
40
+ registry.delegate = self
41
+ registry.desiredPushTypes = [.voIP]
42
+ pushRegistry = registry
43
+ }
44
+
45
+ /// Updates the stored token and emits an event to JS.
46
+ ///
47
+ /// - Parameter newToken: The new token string, or nil if invalidated.
48
+ @MainActor
49
+ func updateToken(_ newToken: String?) {
50
+ let oldToken = token
51
+ token = newToken
52
+
53
+ if oldToken != newToken {
54
+ Log.voipPush.debug("VoIP token updated - hasToken: \(newToken != nil)")
55
+ CallEventEmitter.shared.send(VoIPPushTokenUpdatedEvent(token: newToken))
56
+ }
57
+ }
58
+ }