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,714 @@
1
+ import CallKit
2
+ import os
3
+
4
+ /// Errors that can occur during call operations.
5
+ enum CallError: LocalizedError {
6
+ case sessionAlreadyExists
7
+
8
+ var errorDescription: String? {
9
+ switch self {
10
+ case .sessionAlreadyExists:
11
+ return "A call session already exists"
12
+ }
13
+ }
14
+ }
15
+
16
+ /// Manages CallKit integration for VoIP calls.
17
+ ///
18
+ /// Handles the lifecycle of calls including starting outgoing calls,
19
+ /// reporting call state changes, and responding to CallKit actions.
20
+ class CallManager: NSObject {
21
+ static let shared = CallManager()
22
+ let store = CallStore()
23
+
24
+ private let callController = CXCallController()
25
+ private let provider: CXProvider
26
+
27
+ private let supportsHolding = false
28
+ private let supportsGrouping = false
29
+ private let supportsUngrouping = false
30
+ private let supportsDTMF = false
31
+
32
+ /// Timeout duration for outgoing calls to connect.
33
+ static let outgoingCallTimeout: Duration = {
34
+ let seconds =
35
+ Bundle.main.object(
36
+ forInfoDictionaryKey: "ExpoCallKitTelecomOutgoingCallTimeout"
37
+ ) as? Int ?? 60
38
+ return .seconds(seconds)
39
+ }()
40
+
41
+ /// Timeout duration for incoming calls to be answered.
42
+ static let incomingCallTimeout: Duration = {
43
+ let seconds =
44
+ Bundle.main.object(
45
+ forInfoDictionaryKey: "ExpoCallKitTelecomIncomingCallTimeout"
46
+ ) as? Int ?? 45
47
+ return .seconds(seconds)
48
+ }()
49
+
50
+ /// Tasks tracking call timeouts, keyed by call ID.
51
+ private var callTimeoutTasks: [UUID: Task<Void, Never>] = [:]
52
+
53
+ /// Lock for thread-safe access to call timeout tasks.
54
+ private let timeoutTasksLock = NSLock()
55
+
56
+ private override init() {
57
+ let configuration = CXProviderConfiguration()
58
+ configuration.supportsVideo = true
59
+ configuration.maximumCallGroups = 1
60
+ configuration.maximumCallsPerCallGroup = 1
61
+ configuration.supportedHandleTypes = [.phoneNumber, .generic]
62
+ configuration.includesCallsInRecents = true
63
+
64
+ if let ringtone = Bundle.main.object(
65
+ forInfoDictionaryKey: "ExpoCallKitTelecomDefaultRingtone"
66
+ ) as? String,
67
+ ringtone != "default"
68
+ {
69
+ configuration.ringtoneSound = ringtone
70
+ }
71
+
72
+ provider = CXProvider(configuration: configuration)
73
+
74
+ super.init()
75
+
76
+ provider.setDelegate(self, queue: nil)
77
+ }
78
+
79
+ // MARK: - Call Timeout
80
+
81
+ /// Starts a timeout timer for a call.
82
+ ///
83
+ /// For outgoing calls, the timeout is triggered if the call is not connected
84
+ /// within the timeout period. For incoming calls, it's triggered if the user
85
+ /// does not answer. When timeout occurs, the dialtone is stopped and the call
86
+ /// is ended with `.unanswered` reason.
87
+ ///
88
+ /// - Parameters:
89
+ /// - id: The UUID of the call.
90
+ /// - timeout: The duration before the call times out.
91
+ func startCallTimeout(for id: UUID, timeout: Duration) {
92
+ Log.call.debug("Starting call timeout - id: \(id), timeout: \(timeout)")
93
+
94
+ let task = Task {
95
+ try? await Task.sleep(for: timeout)
96
+
97
+ guard !Task.isCancelled else {
98
+ return
99
+ }
100
+
101
+ // Remove self from timeout tasks since we're handling the timeout
102
+ self.removeTimeoutTask(for: id)
103
+
104
+ Log.call.debug("Call timeout expired - id: \(id)")
105
+
106
+ // Stop the dialtone (for outgoing calls)
107
+ DialtonePlayer.shared.stop()
108
+
109
+ // End the call due to timeout
110
+ await reportCallEnded(for: id, reason: .unanswered)
111
+ }
112
+
113
+ timeoutTasksLock.lock()
114
+ callTimeoutTasks[id] = task
115
+ timeoutTasksLock.unlock()
116
+ }
117
+
118
+ /// Removes and returns the timeout task for a call (thread-safe).
119
+ ///
120
+ /// - Parameter id: The UUID of the call.
121
+ /// - Returns: The removed task, or nil if no task existed.
122
+ @discardableResult
123
+ private func removeTimeoutTask(for id: UUID) -> Task<Void, Never>? {
124
+ timeoutTasksLock.lock()
125
+ defer { timeoutTasksLock.unlock() }
126
+ return callTimeoutTasks.removeValue(forKey: id)
127
+ }
128
+
129
+ /// Cancels the timeout timer for a call.
130
+ ///
131
+ /// - Parameter id: The UUID of the call.
132
+ func cancelCallTimeout(for id: UUID) {
133
+ if let task = removeTimeoutTask(for: id) {
134
+ task.cancel()
135
+ Log.call.debug("Cancelled call timeout - id: \(id)")
136
+ }
137
+ }
138
+
139
+ /// Removes and returns all timeout tasks atomically (thread-safe).
140
+ ///
141
+ /// - Returns: Dictionary of all removed tasks keyed by call ID.
142
+ private func removeAllTimeoutTasks() -> [UUID: Task<Void, Never>] {
143
+ timeoutTasksLock.lock()
144
+ defer { timeoutTasksLock.unlock() }
145
+ let tasks = callTimeoutTasks
146
+ callTimeoutTasks.removeAll()
147
+ return tasks
148
+ }
149
+
150
+ /// Cancels all call timeout timers.
151
+ ///
152
+ /// Called when the provider resets to clean up all pending timeouts.
153
+ func cancelAllCallTimeouts() {
154
+ let tasks = removeAllTimeoutTasks()
155
+
156
+ Log.call.debug("Cancelling all call timeouts - count: \(tasks.count)")
157
+ for (id, task) in tasks {
158
+ task.cancel()
159
+ Log.call.debug("Cancelled call timeout - id: \(id)")
160
+ }
161
+ }
162
+
163
+ /// Creates a CallKit handle from a participant.
164
+ ///
165
+ /// Uses phone number if available, then email, otherwise falls back to a generic handle with the participant ID.
166
+ private func makeHandle(from participant: CallParticipant) -> CXHandle {
167
+ if let phoneNumber = participant.phoneNumber {
168
+ return CXHandle(type: .phoneNumber, value: phoneNumber)
169
+ }
170
+ if let email = participant.email {
171
+ return CXHandle(type: .emailAddress, value: email)
172
+ }
173
+ return CXHandle(type: .generic, value: participant.id)
174
+ }
175
+
176
+ /// Sets the localized caller name on a CXCallUpdate for participants using a generic handle.
177
+ ///
178
+ /// This ensures the display name appears in the CallKit UI when neither phone number nor email is available.
179
+ /// Only sets the name if the participant has no phone number or email (i.e., uses a generic handle).
180
+ ///
181
+ /// - Parameters:
182
+ /// - participant: The participant whose display name should be used.
183
+ /// - update: The CXCallUpdate to modify.
184
+ public func setLocalizedCallerNameIfNeeded(
185
+ for participant: CallParticipant,
186
+ on update: CXCallUpdate
187
+ ) {
188
+ guard participant.phoneNumber == nil,
189
+ participant.email == nil,
190
+ let displayName = participant.displayName
191
+ else {
192
+ return
193
+ }
194
+
195
+ update.localizedCallerName = displayName
196
+ }
197
+
198
+ // MARK: - Start Outgoing Call
199
+
200
+ /// Starts an outgoing call request to CallKit.
201
+ ///
202
+ /// Creates a new call session, requests CallKit to start the call, and returns the generated call ID.
203
+ /// If CallKit rejects the request, the session is removed and an error is thrown.
204
+ ///
205
+ /// - Parameters:
206
+ /// - recipient: The participant to call.
207
+ /// - options: Call configuration options.
208
+ /// - isAppInitiated: indicates if the call was started from the app itself or from outside (e.g. from Siri intent)
209
+ /// - Returns: The UUID assigned to this call.
210
+ /// - Throws: An error if CallKit rejects the call request.
211
+ func startOutgoingCall(
212
+ recipient: CallParticipant,
213
+ options: CallOptions,
214
+ isAppInitiated: Bool = true
215
+ ) async throws -> UUID {
216
+ // Guard against starting a new call while one is already active
217
+ if let existingSession = await store.firstSession {
218
+ Log.call.warning("Cannot start outgoing call - session already exists: \(existingSession.id)")
219
+ throw CallError.sessionAlreadyExists
220
+ }
221
+
222
+ // Prepare audio session before starting the call
223
+ AudioManager.shared.prepareAudioSessionForCall(hasVideo: options.hasVideo)
224
+
225
+ let id = UUID()
226
+ Log.call.debug("Starting outgoing call - id: \(id)")
227
+
228
+ let session = CallSession(
229
+ id: id,
230
+ options: options,
231
+ origin: isAppInitiated ? .outgoingApp : .outgoingSystem,
232
+ remoteParticipants: [recipient],
233
+ incomingCallEvent: nil,
234
+ status: .requesting,
235
+ connectedAt: nil,
236
+ isMuted: false,
237
+ isOnHold: false,
238
+ dtmfDigits: nil
239
+ )
240
+ await store.add(session)
241
+
242
+ let handle = makeHandle(from: recipient)
243
+ let startCallAction = CXStartCallAction(call: id, handle: handle)
244
+ startCallAction.isVideo = options.hasVideo
245
+
246
+ let transaction = CXTransaction(action: startCallAction)
247
+
248
+ do {
249
+ try await withCheckedThrowingContinuation {
250
+ (continuation: CheckedContinuation<Void, Error>) in
251
+ callController.request(transaction) { error in
252
+ if let error = error {
253
+ continuation.resume(throwing: error)
254
+ } else {
255
+ continuation.resume()
256
+ }
257
+ }
258
+ }
259
+ Log.call.debug("Outgoing call request accepted by CallKit - id: \(id)")
260
+ } catch {
261
+ Log.call.error(
262
+ "Outgoing call request rejected by CallKit - id: \(id), error: \(error.localizedDescription)"
263
+ )
264
+ await store.remove(for: id)
265
+ AudioManager.shared.restoreAudioSession()
266
+ throw error
267
+ }
268
+
269
+ return id
270
+ }
271
+
272
+ // MARK: - Report Incoming Call
273
+
274
+ /// Reports an incoming call to CallKit, displaying the native call UI.
275
+ ///
276
+ /// Creates a new call session with `.incoming` origin, reports the call to CallKit,
277
+ /// and stores the session. The user can then answer or decline via the native UI.
278
+ ///
279
+ /// - Parameter event: The incoming call event containing caller info.
280
+ /// - Throws: An error if CallKit rejects the incoming call report.
281
+ func reportIncomingCall(event: IncomingCallEvent) async throws {
282
+ let id = UUID()
283
+ Log.call.debug("Reporting incoming call - id: \(id)")
284
+
285
+ // Prepare audio session before reporting to CallKit
286
+ AudioManager.shared.prepareAudioSessionForCall(hasVideo: event.hasVideo)
287
+
288
+ let caller = CallParticipant(
289
+ id: event.caller.id,
290
+ phoneNumber: event.caller.phoneNumber,
291
+ email: event.caller.email,
292
+ displayName: event.caller.displayName,
293
+ avatarUrl: event.caller.avatarUrl
294
+ )
295
+
296
+ let session = CallSession(
297
+ id: id,
298
+ options: CallOptions(hasVideo: event.hasVideo),
299
+ origin: .incoming,
300
+ remoteParticipants: [caller],
301
+ incomingCallEvent: event,
302
+ status: .ringing,
303
+ connectedAt: nil,
304
+ isMuted: false,
305
+ isOnHold: false,
306
+ dtmfDigits: nil
307
+ )
308
+
309
+ let update = CXCallUpdate()
310
+ let handle = makeHandle(from: caller)
311
+ update.hasVideo = event.hasVideo
312
+ update.remoteHandle = handle
313
+ setLocalizedCallerNameIfNeeded(for: caller, on: update)
314
+ update.supportsHolding = supportsHolding
315
+ update.supportsGrouping = supportsGrouping
316
+ update.supportsUngrouping = supportsUngrouping
317
+ update.supportsDTMF = supportsDTMF
318
+
319
+ do {
320
+ try await withCheckedThrowingContinuation {
321
+ (continuation: CheckedContinuation<Void, Error>) in
322
+ provider.reportNewIncomingCall(with: id, update: update) { error in
323
+ if let error = error {
324
+ continuation.resume(throwing: error)
325
+ } else {
326
+ continuation.resume()
327
+ }
328
+ }
329
+ }
330
+ Log.call.debug("Incoming call reported to CallKit - id: \(id)")
331
+
332
+ await store.add(session)
333
+
334
+ // Notify JS that an incoming call has been reported to CallKit
335
+ await MainActor.run {
336
+ CallEventEmitter.shared.send(IncomingCallReportedEvent(id: id))
337
+ }
338
+
339
+ // Start timeout for incoming call
340
+ startCallTimeout(for: id, timeout: Self.incomingCallTimeout)
341
+ } catch {
342
+ Log.call.error(
343
+ "Failed to report incoming call to CallKit - id: \(id), error: \(error.localizedDescription)"
344
+ )
345
+ AudioManager.shared.restoreAudioSession()
346
+ throw error
347
+ }
348
+ }
349
+
350
+ /// Reports an incoming call to CallKit using callbacks instead of async/await.
351
+ ///
352
+ /// This is used by VoIP push handling where Swift concurrency may not be fully
353
+ /// initialized when the app is launched from a terminated state.
354
+ ///
355
+ /// - Parameters:
356
+ /// - event: The incoming call event containing caller info.
357
+ /// - completion: Called when the CallKit report completes (success or failure).
358
+ func reportIncomingCall(event: IncomingCallEvent, completion: @escaping (Error?) -> Void) {
359
+ let id = UUID()
360
+ Log.call.debug("Reporting incoming call (sync) - id: \(id)")
361
+
362
+ // Pre-heat audio session before reporting to CallKit
363
+ AudioManager.shared.prepareAudioSessionForCall(hasVideo: event.hasVideo)
364
+
365
+ let caller = CallParticipant(
366
+ id: event.caller.id,
367
+ phoneNumber: event.caller.phoneNumber,
368
+ email: event.caller.email,
369
+ displayName: event.caller.displayName,
370
+ avatarUrl: event.caller.avatarUrl
371
+ )
372
+
373
+ let session = CallSession(
374
+ id: id,
375
+ options: CallOptions(hasVideo: event.hasVideo),
376
+ origin: .incoming,
377
+ remoteParticipants: [caller],
378
+ incomingCallEvent: event,
379
+ status: .ringing,
380
+ connectedAt: nil,
381
+ isMuted: false,
382
+ isOnHold: false,
383
+ dtmfDigits: nil
384
+ )
385
+
386
+ let update = CXCallUpdate()
387
+ let handle = makeHandle(from: caller)
388
+ update.hasVideo = event.hasVideo
389
+ update.remoteHandle = handle
390
+ setLocalizedCallerNameIfNeeded(for: caller, on: update)
391
+ update.supportsHolding = supportsHolding
392
+ update.supportsGrouping = supportsGrouping
393
+ update.supportsUngrouping = supportsUngrouping
394
+ update.supportsDTMF = supportsDTMF
395
+
396
+ // Report to CallKit - this is the critical part that must happen immediately
397
+ provider.reportNewIncomingCall(with: id, update: update) { [weak self] error in
398
+ if let error = error {
399
+ Log.call.error(
400
+ "Failed to report incoming call to CallKit - id: \(id), error: \(error.localizedDescription)"
401
+ )
402
+ AudioManager.shared.restoreAudioSession()
403
+ completion(error)
404
+ return
405
+ }
406
+
407
+ Log.call.debug("Incoming call reported to CallKit - id: \(id)")
408
+
409
+ // Now do the async work (store session, emit events, start timeout)
410
+ Task {
411
+ await self?.store.add(session)
412
+
413
+ await MainActor.run {
414
+ CallEventEmitter.shared.send(IncomingCallReportedEvent(id: id))
415
+ }
416
+
417
+ self?.startCallTimeout(for: id, timeout: Self.incomingCallTimeout)
418
+ }
419
+
420
+ completion(nil)
421
+ }
422
+ }
423
+
424
+ // MARK: - Answer Call
425
+
426
+ /// Answers an incoming call via CallKit.
427
+ ///
428
+ /// Use this when the user answers from the app's custom UI rather than
429
+ /// the native CallKit UI. This triggers the CXAnswerCallAction delegate.
430
+ ///
431
+ /// - Parameter id: The UUID of the call to answer.
432
+ /// - Throws: An error if CallKit rejects the answer request.
433
+ func answerCall(for id: UUID) async throws {
434
+ Log.call.debug("Answering call - id: \(id)")
435
+ let action = CXAnswerCallAction(call: id)
436
+ let transaction = CXTransaction(action: action)
437
+
438
+ do {
439
+ try await withCheckedThrowingContinuation {
440
+ (continuation: CheckedContinuation<Void, Error>) in
441
+ callController.request(transaction) { error in
442
+ if let error = error {
443
+ continuation.resume(throwing: error)
444
+ } else {
445
+ continuation.resume()
446
+ }
447
+ }
448
+ }
449
+ Log.call.debug("Answer call request accepted by CallKit - id: \(id)")
450
+ } catch {
451
+ Log.call.error(
452
+ "Answer call request rejected by CallKit - id: \(id), error: \(error.localizedDescription)")
453
+ throw error
454
+ }
455
+ }
456
+
457
+ /// Fulfills an incoming call connection request.
458
+ ///
459
+ /// Call this from JS after connecting the media. Updates the call status
460
+ /// to connected and signals the CXAnswerCallAction to complete.
461
+ /// If the request has already timed out, this is a no-op.
462
+ ///
463
+ /// - Parameter requestId: The unique request ID from the CallAnsweredEvent.
464
+ /// - Returns: Whether the request was successfully fulfilled.
465
+ @discardableResult
466
+ func fulfillIncomingCall(requestId: UUID) async -> Bool {
467
+ guard let callId = await FulfillRequestManager.shared.fulfill(requestId: requestId) else {
468
+ Log.call.debug("Incoming call request not found (timed out) - requestId: \(requestId)")
469
+ return false
470
+ }
471
+
472
+ // Update status and connectedAt in a single event
473
+ await store.update(for: callId) { session in
474
+ session.status = .connected
475
+ session.connectedAt = Date()
476
+ }
477
+ Log.call.debug("Fulfilled incoming call - callId: \(callId), requestId: \(requestId)")
478
+ return true
479
+ }
480
+
481
+ /// Fails a pending incoming call connection request.
482
+ ///
483
+ /// Call this from JS when the answer flow fails before fulfilling
484
+ /// (e.g., API error). Cancels the pending fulfill request, which causes
485
+ /// the CXAnswerCallAction to fail. CallKit then ends the call via
486
+ /// CXEndCallAction, triggering normal cleanup.
487
+ ///
488
+ /// - Parameter requestId: The unique request ID from the CallAnsweredEvent.
489
+ func failIncomingCallConnected(requestId: UUID) async {
490
+ await FulfillRequestManager.shared.cancel(requestId: requestId)
491
+ Log.call.debug("Failed incoming call connection - requestId: \(requestId)")
492
+ }
493
+
494
+ /// Reports to CallKit that an outgoing call has connected.
495
+ ///
496
+ /// Call this when the media connection (e.g., LiveKit room) is established
497
+ /// and the other party has answered the call.
498
+ /// Updates the call state to connected in both CallKit and the local store.
499
+ /// Stops the dialtone and cancels the outgoing call timeout.
500
+ ///
501
+ /// - Parameter id: The UUID of the call to report as connected.
502
+ func reportOutgoingCallConnected(for id: UUID) async {
503
+ Log.call.debug("Reporting outgoing call connected - id: \(id)")
504
+
505
+ // Stop dialtone and cancel timeout
506
+ DialtonePlayer.shared.stop()
507
+ cancelCallTimeout(for: id)
508
+
509
+ let now = Date()
510
+ provider.reportOutgoingCall(with: id, connectedAt: now)
511
+ await store.update(for: id) { session in
512
+ session.status = .connected
513
+ session.connectedAt = now
514
+ }
515
+ }
516
+
517
+ // MARK: - End Call
518
+
519
+ /// Ends a call by requesting CallKit to terminate it.
520
+ ///
521
+ /// This sends a CXEndCallAction to CallKit, which will trigger the
522
+ /// provider delegate's end call handler to clean up the session.
523
+ ///
524
+ /// - Parameter id: The UUID of the call to end.
525
+ /// - Throws: An error if CallKit rejects the end call request.
526
+ func endCall(for id: UUID) async throws {
527
+ Log.call.debug("Ending call - id: \(id)")
528
+ let endCallAction = CXEndCallAction(call: id)
529
+ let transaction = CXTransaction(action: endCallAction)
530
+
531
+ do {
532
+ try await withCheckedThrowingContinuation {
533
+ (continuation: CheckedContinuation<Void, Error>) in
534
+ callController.request(transaction) { error in
535
+ if let error = error {
536
+ continuation.resume(throwing: error)
537
+ } else {
538
+ continuation.resume()
539
+ }
540
+ }
541
+ }
542
+ Log.call.debug("End call request accepted by CallKit - id: \(id)")
543
+ } catch {
544
+ Log.call.error(
545
+ "End call request rejected by CallKit - id: \(id), error: \(error.localizedDescription)")
546
+ throw error
547
+ }
548
+ }
549
+
550
+ /// Reports to CallKit that a call has ended.
551
+ ///
552
+ /// Call this when a call ends for any reason not initiated by the local user
553
+ /// (e.g., remote hangup, network error, timeout, answered elsewhere).
554
+ /// This removes the call from CallKit and cleans up the local session.
555
+ /// Stops the dialtone and cancels any outgoing call timeout.
556
+ ///
557
+ /// - Parameters:
558
+ /// - id: The UUID of the call that ended.
559
+ /// - reason: The reason the call ended.
560
+ func reportCallEnded(for id: UUID, reason: CXCallEndedReason) async {
561
+ Log.call.debug("Reporting call ended - id: \(id), reason: \(reason.rawValue)")
562
+
563
+ // Stop dialtone and cancel timeout (in case call ended before connecting)
564
+ DialtonePlayer.shared.stop()
565
+ cancelCallTimeout(for: id)
566
+
567
+ provider.reportCall(with: id, endedAt: Date(), reason: reason)
568
+
569
+ await MainActor.run {
570
+ CallEventEmitter.shared.send(CallReportedEnded(id: id, reason: reason))
571
+ }
572
+
573
+ await store.remove(for: id)
574
+ }
575
+
576
+ // MARK: - Mute Support
577
+
578
+ /// Sets the mute state for a call via CallKit.
579
+ ///
580
+ /// This sends a CXSetMutedCallAction to CallKit, which will handle
581
+ /// the actual audio muting and trigger the provider delegate's handler.
582
+ ///
583
+ /// - Parameters:
584
+ /// - id: The UUID of the call to mute/unmute.
585
+ /// - muted: Whether the call should be muted.
586
+ /// - Throws: An error if CallKit rejects the mute request.
587
+ func setMuted(for id: UUID, muted: Bool) async throws {
588
+ Log.call.debug("Setting mute state - id: \(id), muted: \(muted)")
589
+
590
+ // Check if state already matches
591
+ if let session = await store.session(for: id), session.isMuted == muted {
592
+ Log.call.debug("Mute state already matches - id: \(id)")
593
+ return
594
+ }
595
+
596
+ let action = CXSetMutedCallAction(call: id, muted: muted)
597
+ let transaction = CXTransaction(action: action)
598
+
599
+ do {
600
+ try await withCheckedThrowingContinuation {
601
+ (continuation: CheckedContinuation<Void, Error>) in
602
+ callController.request(transaction) { error in
603
+ if let error = error {
604
+ continuation.resume(throwing: error)
605
+ } else {
606
+ continuation.resume()
607
+ }
608
+ }
609
+ }
610
+ Log.call.debug("Set mute request accepted by CallKit - id: \(id), muted: \(muted)")
611
+ } catch {
612
+ Log.call.error(
613
+ "Set mute request rejected by CallKit - id: \(id), error: \(error.localizedDescription)")
614
+ throw error
615
+ }
616
+ }
617
+
618
+ // MARK: - Video Support
619
+
620
+ /// Updates the video state for a call.
621
+ ///
622
+ /// This updates the local session and reports the change to CallKit.
623
+ ///
624
+ /// - Parameters:
625
+ /// - id: The UUID of the call to update.
626
+ /// - enabled: Whether video should be enabled.
627
+ func reportVideo(for id: UUID, enabled: Bool) async {
628
+ Log.call.debug("Setting video state - id: \(id), enabled: \(enabled)")
629
+
630
+ await store.update(for: id) { session in
631
+ session.options.hasVideo = enabled
632
+ }
633
+
634
+ AudioManager.shared.prepareAudioSessionForCall(hasVideo: enabled)
635
+
636
+ let update = CXCallUpdate()
637
+ update.hasVideo = enabled
638
+ provider.reportCall(with: id, updated: update)
639
+
640
+ await MainActor.run {
641
+ CallEventEmitter.shared.send(VideoChangedEvent(id: id, hasVideo: enabled))
642
+ }
643
+ }
644
+
645
+ // MARK: - Hold Support
646
+
647
+ /// Sets the hold state for a call via CallKit.
648
+ ///
649
+ /// This sends a CXSetHeldCallAction to CallKit, which will handle
650
+ /// the hold state change and trigger the provider delegate's handler.
651
+ ///
652
+ /// - Parameters:
653
+ /// - id: The UUID of the call to hold/unhold.
654
+ /// - onHold: Whether the call should be on hold.
655
+ /// - Throws: An error if CallKit rejects the hold request.
656
+ func setHeld(for id: UUID, onHold: Bool) async throws {
657
+ Log.call.debug("Setting hold state - id: \(id), onHold: \(onHold)")
658
+ let action = CXSetHeldCallAction(call: id, onHold: onHold)
659
+ let transaction = CXTransaction(action: action)
660
+
661
+ do {
662
+ try await withCheckedThrowingContinuation {
663
+ (continuation: CheckedContinuation<Void, Error>) in
664
+ callController.request(transaction) { error in
665
+ if let error = error {
666
+ continuation.resume(throwing: error)
667
+ } else {
668
+ continuation.resume()
669
+ }
670
+ }
671
+ }
672
+ Log.call.debug("Set hold request accepted by CallKit - id: \(id), onHold: \(onHold)")
673
+ } catch {
674
+ Log.call.error(
675
+ "Set hold request rejected by CallKit - id: \(id), error: \(error.localizedDescription)")
676
+ throw error
677
+ }
678
+ }
679
+
680
+ // MARK: - DTMF Support
681
+
682
+ /// Sends DTMF tones for a call via CallKit.
683
+ ///
684
+ /// This sends a CXPlayDTMFCallAction to CallKit with the specified digits.
685
+ ///
686
+ /// - Parameters:
687
+ /// - id: The UUID of the call.
688
+ /// - digits: The DTMF digits to play (0-9, *, #).
689
+ /// - Throws: An error if CallKit rejects the DTMF request.
690
+ func playDTMF(for id: UUID, digits: String) async throws {
691
+ Log.call.debug("Playing DTMF - id: \(id), length: \(digits.count)")
692
+ let action = CXPlayDTMFCallAction(call: id, digits: digits, type: .singleTone)
693
+ let transaction = CXTransaction(action: action)
694
+
695
+ do {
696
+ try await withCheckedThrowingContinuation {
697
+ (continuation: CheckedContinuation<Void, Error>) in
698
+ callController.request(transaction) { error in
699
+ if let error = error {
700
+ continuation.resume(throwing: error)
701
+ } else {
702
+ continuation.resume()
703
+ }
704
+ }
705
+ }
706
+ Log.call.debug("Play DTMF request accepted by CallKit - id: \(id)")
707
+ } catch {
708
+ Log.call.error(
709
+ "Play DTMF request rejected by CallKit - id: \(id), error: \(error.localizedDescription)")
710
+ throw error
711
+ }
712
+ }
713
+
714
+ }