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.
- package/LICENSE +21 -0
- package/README.md +197 -0
- package/android/build.gradle +32 -0
- package/android/src/main/AndroidManifest.xml +33 -0
- package/android/src/main/java/expo/modules/callkittelecom/ExpoCallKitTelecomModule.kt +384 -0
- package/android/src/main/java/expo/modules/callkittelecom/IncomingCallActivity.kt +275 -0
- package/android/src/main/java/expo/modules/callkittelecom/events/CallEventEmitter.kt +151 -0
- package/android/src/main/java/expo/modules/callkittelecom/events/CallEvents.kt +59 -0
- package/android/src/main/java/expo/modules/callkittelecom/managers/CallAudioManager.kt +361 -0
- package/android/src/main/java/expo/modules/callkittelecom/managers/CallManager.kt +891 -0
- package/android/src/main/java/expo/modules/callkittelecom/managers/CallNotificationManager.kt +445 -0
- package/android/src/main/java/expo/modules/callkittelecom/managers/CaptureSessionManager.kt +27 -0
- package/android/src/main/java/expo/modules/callkittelecom/managers/DialtonePlayer.kt +171 -0
- package/android/src/main/java/expo/modules/callkittelecom/managers/FulfillRequestManager.kt +150 -0
- package/android/src/main/java/expo/modules/callkittelecom/managers/VoIPPushManager.kt +54 -0
- package/android/src/main/java/expo/modules/callkittelecom/models/CallModels.kt +269 -0
- package/android/src/main/java/expo/modules/callkittelecom/services/CallNotificationReceiver.kt +54 -0
- package/android/src/main/java/expo/modules/callkittelecom/services/ExpoCallKitTelecomMessagingService.kt +161 -0
- package/android/src/main/java/expo/modules/callkittelecom/store/CallStore.kt +181 -0
- package/android/src/main/java/expo/modules/callkittelecom/utils/CallKitTelecomLog.kt +52 -0
- package/android/src/main/java/expo/modules/callkittelecom/utils/PermissionUtils.kt +28 -0
- package/android/src/main/res/drawable/expo_callkit_telecom_bg_answer.xml +9 -0
- package/android/src/main/res/drawable/expo_callkit_telecom_bg_avatar.xml +5 -0
- package/android/src/main/res/drawable/expo_callkit_telecom_bg_decline.xml +9 -0
- package/android/src/main/res/drawable/expo_callkit_telecom_ic_answer.xml +9 -0
- package/android/src/main/res/drawable/expo_callkit_telecom_ic_decline.xml +9 -0
- package/android/src/main/res/drawable/expo_callkit_telecom_ic_videocam.xml +9 -0
- package/android/src/main/res/layout/activity_incoming_call.xml +169 -0
- package/app.json +8 -0
- package/app.plugin.js +1 -0
- package/build/Calls.d.ts +577 -0
- package/build/Calls.d.ts.map +1 -0
- package/build/Calls.js +715 -0
- package/build/Calls.js.map +1 -0
- package/build/Calls.types.d.ts +203 -0
- package/build/Calls.types.d.ts.map +1 -0
- package/build/Calls.types.js +2 -0
- package/build/Calls.types.js.map +1 -0
- package/build/ExpoCallKitTelecomModule.d.ts +3 -0
- package/build/ExpoCallKitTelecomModule.d.ts.map +1 -0
- package/build/ExpoCallKitTelecomModule.js +4 -0
- package/build/ExpoCallKitTelecomModule.js.map +1 -0
- package/build/hooks/index.d.ts +2 -0
- package/build/hooks/index.d.ts.map +1 -0
- package/build/hooks/index.js +2 -0
- package/build/hooks/index.js.map +1 -0
- package/build/hooks/useVoIPPushToken.d.ts +14 -0
- package/build/hooks/useVoIPPushToken.d.ts.map +1 -0
- package/build/hooks/useVoIPPushToken.js +26 -0
- package/build/hooks/useVoIPPushToken.js.map +1 -0
- package/build/index.d.ts +4 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +4 -0
- package/build/index.js.map +1 -0
- package/expo-module.config.json +10 -0
- package/ios/AppDelegateSubscriber.swift +93 -0
- package/ios/ExpoCallKitTelecom.podspec +31 -0
- package/ios/ExpoCallKitTelecomLogger.swift +55 -0
- package/ios/ExpoCallKitTelecomModule.swift +503 -0
- package/ios/Managers/AudioManager.swift +363 -0
- package/ios/Managers/CallEventEmitter.swift +199 -0
- package/ios/Managers/CallManager+CXProviderDelegate.swift +195 -0
- package/ios/Managers/CallManager.swift +714 -0
- package/ios/Managers/CaptureSessionManager.swift +54 -0
- package/ios/Managers/DialtonePlayer.swift +126 -0
- package/ios/Managers/FulfillRequestManager.swift +154 -0
- package/ios/Managers/VoIPPushManager+PKPushRegistryDelegate.swift +123 -0
- package/ios/Managers/VoIPPushManager.swift +58 -0
- package/ios/Models/CallEvents.swift +263 -0
- package/ios/Models/CallOptions.swift +15 -0
- package/ios/Models/CallParticipant.swift +37 -0
- package/ios/Models/CallSession.swift +80 -0
- package/ios/Models/IncomingCallEvent.swift +196 -0
- package/ios/Stores/CallStore.swift +149 -0
- package/package.json +56 -0
- package/plugin/build/constants.d.ts +3 -0
- package/plugin/build/constants.js +7 -0
- package/plugin/build/withExpoCallKitTelecom.d.ts +67 -0
- package/plugin/build/withExpoCallKitTelecom.js +16 -0
- package/plugin/build/withExpoCallKitTelecomAndroid.d.ts +3 -0
- package/plugin/build/withExpoCallKitTelecomAndroid.js +177 -0
- package/plugin/build/withExpoCallKitTelecomIos.d.ts +3 -0
- package/plugin/build/withExpoCallKitTelecomIos.js +195 -0
- package/plugin/src/constants.ts +4 -0
- package/plugin/src/withExpoCallKitTelecom.ts +83 -0
- package/plugin/src/withExpoCallKitTelecomAndroid.ts +293 -0
- package/plugin/src/withExpoCallKitTelecomIos.ts +276 -0
- package/src/Calls.ts +848 -0
- package/src/Calls.types.ts +275 -0
- package/src/ExpoCallKitTelecomModule.ts +4 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useVoIPPushToken.ts +34 -0
- 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
|
+
}
|