@telnyx/react-voice-commons-sdk 1.0.0 → 1.1.1

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 (43) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +28 -7
  3. package/android/src/main/java/com/telnyx/react_voice_commons/CallForegroundService.kt +16 -2
  4. package/android/src/main/java/com/telnyx/react_voice_commons/TelnyxNotificationHelper.kt +345 -24
  5. package/android/src/main/java/com/telnyx/react_voice_commons/VoicePnBridgeModule.kt +19 -0
  6. package/android/src/main/java/com/telnyx/react_voice_commons/VoicePnManager.kt +19 -0
  7. package/ios/CallKitBridge.m +27 -1
  8. package/ios/CallKitBridge.swift +640 -125
  9. package/ios/README.md +2 -2
  10. package/ios/VoicePnBridge.m +6 -0
  11. package/ios/VoicePnBridge.swift +15 -0
  12. package/lib/callkit/callkit-coordinator.d.ts +39 -3
  13. package/lib/callkit/callkit-coordinator.js +561 -148
  14. package/lib/callkit/callkit.d.ts +7 -0
  15. package/lib/callkit/callkit.js +83 -11
  16. package/lib/hooks/useAppStateHandler.js +6 -2
  17. package/lib/internal/CallKitHandler.d.ts +3 -0
  18. package/lib/internal/CallKitHandler.js +52 -46
  19. package/lib/internal/calls/call-state-controller.d.ts +33 -1
  20. package/lib/internal/calls/call-state-controller.js +100 -28
  21. package/lib/internal/session/session-manager.d.ts +15 -1
  22. package/lib/internal/session/session-manager.js +214 -62
  23. package/lib/internal/voice-pn-bridge.d.ts +18 -0
  24. package/lib/internal/voice-pn-bridge.js +39 -0
  25. package/lib/models/call.js +35 -0
  26. package/lib/models/config.d.ts +10 -0
  27. package/lib/models/config.js +2 -0
  28. package/lib/telnyx-voice-app.js +85 -43
  29. package/lib/telnyx-voip-client.d.ts +46 -5
  30. package/lib/telnyx-voip-client.js +238 -22
  31. package/package.json +7 -5
  32. package/src/callkit/callkit-coordinator.ts +642 -160
  33. package/src/callkit/callkit.ts +110 -12
  34. package/src/hooks/useAppStateHandler.ts +7 -2
  35. package/src/internal/CallKitHandler.tsx +60 -55
  36. package/src/internal/calls/call-state-controller.ts +112 -29
  37. package/src/internal/session/session-manager.ts +260 -65
  38. package/src/internal/voice-pn-bridge.ts +47 -0
  39. package/src/models/call.ts +34 -0
  40. package/src/models/config.ts +10 -0
  41. package/src/telnyx-voice-app.tsx +96 -45
  42. package/src/telnyx-voip-client.ts +216 -24
  43. package/src/types/telnyx-sdk.d.ts +2 -1
@@ -9,6 +9,49 @@ import React
9
9
  import UserNotifications
10
10
  import WebRTC
11
11
 
12
+ /// Small synchronized dictionary used because React Native module methods
13
+ /// and CXProvider delegate callbacks are not guaranteed to share a queue.
14
+ public final class LockedDictionary<Key: Hashable, Value> {
15
+ private var storage: [Key: Value] = [:]
16
+ private let lock: NSRecursiveLock
17
+
18
+ init(lock: NSRecursiveLock) {
19
+ self.lock = lock
20
+ }
21
+
22
+ public subscript(key: Key) -> Value? {
23
+ get {
24
+ lock.lock()
25
+ defer { lock.unlock() }
26
+ return storage[key]
27
+ }
28
+ set {
29
+ lock.lock()
30
+ defer { lock.unlock() }
31
+ storage[key] = newValue
32
+ }
33
+ }
34
+
35
+ public var values: [Value] {
36
+ lock.lock()
37
+ defer { lock.unlock() }
38
+ return Array(storage.values)
39
+ }
40
+
41
+ @discardableResult
42
+ public func removeValue(forKey key: Key) -> Value? {
43
+ lock.lock()
44
+ defer { lock.unlock() }
45
+ return storage.removeValue(forKey: key)
46
+ }
47
+
48
+ public func removeAll() {
49
+ lock.lock()
50
+ defer { lock.unlock() }
51
+ storage.removeAll()
52
+ }
53
+ }
54
+
12
55
  @objc(CallKitBridge)
13
56
  class CallKitBridge: RCTEventEmitter {
14
57
 
@@ -29,6 +72,7 @@ import React
29
72
  "CallKitDidReceiveStartCallAction",
30
73
  "CallKitDidPerformAnswerCallAction",
31
74
  "CallKitDidPerformEndCallAction",
75
+ "CallKitDidPerformHeldCallAction",
32
76
  "CallKitDidReceivePush",
33
77
  "AudioSessionActivated",
34
78
  "AudioSessionDeactivated",
@@ -50,8 +94,9 @@ import React
50
94
  }
51
95
 
52
96
  // Direct event emission methods called by TelnyxVoiceAppDelegate
53
- public func emitCallEvent(_ eventName: String, callUUID: UUID, callData: [String: Any]?) {
54
- guard hasListeners else { return }
97
+ @discardableResult
98
+ public func emitCallEvent(_ eventName: String, callUUID: UUID, callData: [String: Any]?) -> Bool {
99
+ guard hasListeners else { return false }
55
100
 
56
101
  let eventData: [String: Any] = [
57
102
  "callUUID": callUUID.uuidString,
@@ -59,6 +104,22 @@ import React
59
104
  ]
60
105
 
61
106
  sendEvent(withName: eventName, body: eventData)
107
+ return true
108
+ }
109
+
110
+ @discardableResult
111
+ public func emitHeldCallEvent(
112
+ callUUID: UUID, isOnHold: Bool, callData: [String: Any]?
113
+ ) -> Bool {
114
+ guard hasListeners else { return false }
115
+
116
+ let eventData: [String: Any] = [
117
+ "callUUID": callUUID.uuidString,
118
+ "isOnHold": isOnHold,
119
+ "callData": callData ?? [:],
120
+ ]
121
+ sendEvent(withName: "CallKitDidPerformHeldCallAction", body: eventData)
122
+ return true
62
123
  }
63
124
 
64
125
  // Event emission method for audio session changes
@@ -90,8 +151,17 @@ import React
90
151
  let startCallAction = CXStartCallAction(call: uuid, handle: callHandle)
91
152
  let transaction = CXTransaction(action: startCallAction)
92
153
 
154
+ manager.activeCalls[uuid] = [
155
+ "caller": displayName,
156
+ "handle": handle,
157
+ "uuid": uuid.uuidString,
158
+ "direction": "outgoing",
159
+ "source": "react_native",
160
+ ]
161
+
93
162
  callController.request(transaction) { error in
94
163
  if let error = error {
164
+ manager.activeCalls.removeValue(forKey: uuid)
95
165
  reject("START_CALL_ERROR", error.localizedDescription, error)
96
166
  } else {
97
167
  resolve(["success": true, "callUUID": callUUID])
@@ -120,11 +190,23 @@ import React
120
190
  callUpdate.remoteHandle = callHandle
121
191
  callUpdate.hasVideo = false
122
192
  callUpdate.localizedCallerName = displayName
193
+ callUpdate.supportsHolding = true
194
+
195
+ manager.activeCalls[uuid] = [
196
+ "caller": displayName,
197
+ "handle": handle,
198
+ "uuid": uuid.uuidString,
199
+ "direction": "incoming",
200
+ "source": "react_native",
201
+ "isCallKitRegistered": false,
202
+ ]
123
203
 
124
204
  provider.reportNewIncomingCall(with: uuid, update: callUpdate) { error in
125
205
  if let error = error {
206
+ manager.activeCalls.removeValue(forKey: uuid)
126
207
  reject("INCOMING_CALL_ERROR", error.localizedDescription, error)
127
208
  } else {
209
+ manager.markCallKitRegistered(uuid)
128
210
  resolve(["success": true, "callUUID": callUUID])
129
211
  }
130
212
  }
@@ -157,6 +239,127 @@ import React
157
239
  }
158
240
  }
159
241
 
242
+ @objc func completeHeldCallAction(
243
+ _ callUUID: String, success: Bool,
244
+ resolver resolve: @escaping RCTPromiseResolveBlock,
245
+ rejecter reject: @escaping RCTPromiseRejectBlock
246
+ ) {
247
+ guard let uuid = UUID(uuidString: callUUID) else {
248
+ reject("INVALID_UUID", "Invalid UUID format", nil)
249
+ return
250
+ }
251
+
252
+ let manager = getCallKitManager()
253
+ let completeAction = {
254
+ guard let action = manager.pendingHeldActions.removeValue(forKey: uuid) else {
255
+ reject("NO_PENDING_HELD_ACTION", "No pending held action for UUID", nil)
256
+ return
257
+ }
258
+
259
+ if success {
260
+ if var callData = manager.activeCalls[uuid] {
261
+ callData["isOnHold"] = action.isOnHold
262
+ manager.activeCalls[uuid] = callData
263
+ }
264
+ action.fulfill()
265
+ } else {
266
+ action.fail()
267
+ }
268
+ resolve(["success": success, "callUUID": callUUID])
269
+ }
270
+
271
+ if Thread.isMainThread {
272
+ completeAction()
273
+ } else {
274
+ DispatchQueue.main.async(execute: completeAction)
275
+ }
276
+ }
277
+
278
+ @objc func setCallHeld(
279
+ _ callUUID: String, isOnHold: Bool,
280
+ resolver resolve: @escaping RCTPromiseResolveBlock,
281
+ rejecter reject: @escaping RCTPromiseRejectBlock
282
+ ) {
283
+ guard let uuid = UUID(uuidString: callUUID) else {
284
+ reject("INVALID_UUID", "Invalid UUID format", nil)
285
+ return
286
+ }
287
+
288
+ let manager = getCallKitManager()
289
+ guard let callController = manager.callKitController else {
290
+ reject("NO_CALLKIT", "CallKit not available", nil)
291
+ return
292
+ }
293
+
294
+ guard manager.activeCalls[uuid] != nil else {
295
+ reject("UNKNOWN_CALL", "Call must be registered with CallKit", nil)
296
+ return
297
+ }
298
+
299
+ let action = CXSetHeldCallAction(call: uuid, onHold: isOnHold)
300
+ let transaction = CXTransaction(action: action)
301
+ callController.request(transaction) { error in
302
+ if let error = error {
303
+ reject("SET_HELD_ERROR", error.localizedDescription, error)
304
+ } else {
305
+ resolve([
306
+ "success": true,
307
+ "callUUID": callUUID,
308
+ "isOnHold": isOnHold,
309
+ ])
310
+ }
311
+ }
312
+ }
313
+
314
+ @objc func swapCalls(
315
+ _ activeCallUUID: String, heldCallUUID: String,
316
+ resolver resolve: @escaping RCTPromiseResolveBlock,
317
+ rejecter reject: @escaping RCTPromiseRejectBlock
318
+ ) {
319
+ guard let activeUUID = UUID(uuidString: activeCallUUID),
320
+ let heldUUID = UUID(uuidString: heldCallUUID)
321
+ else {
322
+ reject("INVALID_UUID", "Invalid UUID format", nil)
323
+ return
324
+ }
325
+
326
+ guard activeUUID != heldUUID else {
327
+ reject("INVALID_SWAP", "Active and held call UUIDs must be different", nil)
328
+ return
329
+ }
330
+
331
+ let manager = getCallKitManager()
332
+ guard let callController = manager.callKitController else {
333
+ reject("NO_CALLKIT", "CallKit not available", nil)
334
+ return
335
+ }
336
+
337
+ guard manager.activeCalls[activeUUID] != nil,
338
+ manager.activeCalls[heldUUID] != nil
339
+ else {
340
+ reject("UNKNOWN_CALL", "Both calls must be registered with CallKit", nil)
341
+ return
342
+ }
343
+
344
+ let actions: [CXAction] = [
345
+ CXSetHeldCallAction(call: activeUUID, onHold: true),
346
+ CXSetHeldCallAction(call: heldUUID, onHold: false),
347
+ ]
348
+ let transaction = CXTransaction(actions: actions)
349
+
350
+ callController.request(transaction) { error in
351
+ if let error = error {
352
+ reject("SWAP_CALLS_ERROR", error.localizedDescription, error)
353
+ } else {
354
+ resolve([
355
+ "success": true,
356
+ "activeCallUUID": activeCallUUID,
357
+ "heldCallUUID": heldCallUUID,
358
+ ])
359
+ }
360
+ }
361
+ }
362
+
160
363
  @objc func answerCall(
161
364
  _ callUUID: String, resolver resolve: @escaping RCTPromiseResolveBlock,
162
365
  rejecter reject: @escaping RCTPromiseRejectBlock
@@ -172,6 +375,15 @@ import React
172
375
  return
173
376
  }
174
377
 
378
+ guard manager.isCallKitRegistered(uuid) else {
379
+ reject(
380
+ "UNKNOWN_CALL",
381
+ "Call was not registered with CallKit or its registration was rejected",
382
+ nil
383
+ )
384
+ return
385
+ }
386
+
175
387
  // Mark this call as already answered to prevent duplicate emission
176
388
  if var callData = manager.activeCalls[uuid] {
177
389
  callData["isAlreadyAnswered"] = true
@@ -190,6 +402,22 @@ import React
190
402
  }
191
403
  }
192
404
 
405
+ @objc func isCallRegistered(
406
+ _ callUUID: String, resolver resolve: @escaping RCTPromiseResolveBlock,
407
+ rejecter reject: @escaping RCTPromiseRejectBlock
408
+ ) {
409
+ guard let uuid = UUID(uuidString: callUUID) else {
410
+ reject("INVALID_UUID", "Invalid UUID format", nil)
411
+ return
412
+ }
413
+
414
+ let registered = getCallKitManager().isCallKitRegistered(uuid)
415
+ resolve([
416
+ "registered": registered,
417
+ "callUUID": callUUID,
418
+ ])
419
+ }
420
+
193
421
  @objc func reportCallConnected(
194
422
  _ callUUID: String, resolver resolve: @escaping RCTPromiseResolveBlock,
195
423
  rejecter reject: @escaping RCTPromiseRejectBlock
@@ -208,34 +436,35 @@ import React
208
436
  provider.reportOutgoingCall(with: uuid, connectedAt: Date())
209
437
 
210
438
  // Fulfill deferred CXAnswerCallAction now that peer connection is ready
211
- if let pendingAction = manager.pendingAnswerAction {
439
+ if let pendingAction = manager.pendingAnswerActions.removeValue(forKey: uuid) {
212
440
  NSLog("TelnyxVoice: reportCallConnected - fulfilling deferred CXAnswerCallAction")
213
441
  pendingAction.fulfill()
214
- manager.pendingAnswerAction = nil
215
442
  } else {
216
- // Fallback: ensure audio is enabled for non-push or already-fulfilled cases
217
- let rtcAudioSession = RTCAudioSession.sharedInstance()
218
- rtcAudioSession.lockForConfiguration()
219
- let webRTCConfig = RTCAudioSessionConfiguration.webRTC()
220
- webRTCConfig.categoryOptions = [.duckOthers, .allowBluetooth]
221
- do {
222
- try rtcAudioSession.setConfiguration(webRTCConfig)
223
- } catch {
224
- NSLog("TelnyxVoice: reportCallConnected - setConfiguration error: \(error)")
225
- }
226
- do {
227
- try rtcAudioSession.setActive(true)
228
- } catch {
229
- NSLog("TelnyxVoice: reportCallConnected - setActive error: \(error)")
230
- }
231
- rtcAudioSession.isAudioEnabled = true
232
- rtcAudioSession.unlockForConfiguration()
233
- rtcAudioSession.audioSessionDidActivate(AVAudioSession.sharedInstance())
443
+ manager.verifyAudioAfterConnection(for: uuid)
234
444
  }
235
445
 
236
446
  resolve(["success": true])
237
447
  }
238
448
 
449
+ #if DEBUG
450
+ @objc func simulateAudioSetupRace(
451
+ _ callUUID: String,
452
+ delayMilliseconds: NSNumber,
453
+ resolver resolve: @escaping RCTPromiseResolveBlock,
454
+ rejecter reject: @escaping RCTPromiseRejectBlock
455
+ ) {
456
+ guard let uuid = UUID(uuidString: callUUID) else {
457
+ reject("INVALID_UUID", "Invalid UUID format", nil)
458
+ return
459
+ }
460
+ getCallKitManager().simulateAudioSetupRace(
461
+ for: uuid,
462
+ delay: delayMilliseconds.doubleValue / 1_000
463
+ )
464
+ resolve(["scheduled": true, "delayMilliseconds": delayMilliseconds])
465
+ }
466
+ #endif
467
+
239
468
  @objc func reportCallEnded(
240
469
  _ callUUID: String, reason: NSNumber,
241
470
  resolver resolve: @escaping RCTPromiseResolveBlock,
@@ -254,6 +483,9 @@ import React
254
483
 
255
484
  let endReason = CXCallEndedReason(rawValue: reason.intValue) ?? .remoteEnded
256
485
  provider.reportCall(with: uuid, endedAt: Date(), reason: endReason)
486
+ manager.activeCalls.removeValue(forKey: uuid)
487
+ manager.pendingAnswerActions.removeValue(forKey: uuid)?.fail()
488
+ manager.pendingHeldActions.removeValue(forKey: uuid)?.fail()
257
489
  resolve(["success": true])
258
490
  }
259
491
 
@@ -286,6 +518,7 @@ import React
286
518
  let callUpdate = CXCallUpdate()
287
519
  callUpdate.remoteHandle = callHandle
288
520
  callUpdate.localizedCallerName = displayName
521
+ callUpdate.supportsHolding = true
289
522
 
290
523
  provider.reportCall(with: uuid, updated: callUpdate)
291
524
  resolve(["success": true])
@@ -507,8 +740,47 @@ import React
507
740
  public var voipRegistry: PKPushRegistry?
508
741
  public var callKitProvider: CXProvider?
509
742
  public var callKitController: CXCallController?
510
- public var activeCalls: [UUID: [String: Any]] = [:]
511
- public var pendingAnswerAction: CXAnswerCallAction?
743
+ private let stateLock = NSRecursiveLock()
744
+ public lazy var activeCalls = LockedDictionary<UUID, [String: Any]>(lock: stateLock)
745
+ public lazy var pendingAnswerActions = LockedDictionary<UUID, CXAnswerCallAction>(
746
+ lock: stateLock)
747
+ public lazy var pendingHeldActions = LockedDictionary<UUID, CXSetHeldCallAction>(
748
+ lock: stateLock)
749
+ private var audioLifecycleGeneration = 0
750
+
751
+ private func advanceAudioLifecycleGeneration() -> Int {
752
+ stateLock.lock()
753
+ defer { stateLock.unlock() }
754
+ audioLifecycleGeneration += 1
755
+ return audioLifecycleGeneration
756
+ }
757
+
758
+ private func currentAudioLifecycleGeneration() -> Int {
759
+ stateLock.lock()
760
+ defer { stateLock.unlock() }
761
+ return audioLifecycleGeneration
762
+ }
763
+
764
+ fileprivate func markCallKitRegistered(_ callUUID: UUID) {
765
+ guard var callData = activeCalls[callUUID] else {
766
+ return
767
+ }
768
+
769
+ callData["isCallKitRegistered"] = true
770
+ activeCalls[callUUID] = callData
771
+ }
772
+
773
+ fileprivate func isCallKitRegistered(_ callUUID: UUID) -> Bool {
774
+ guard let callData = activeCalls[callUUID] else {
775
+ return false
776
+ }
777
+
778
+ if callData["direction"] as? String == "incoming" {
779
+ return callData["isCallKitRegistered"] as? Bool == true
780
+ }
781
+
782
+ return true
783
+ }
512
784
 
513
785
  private override init() {
514
786
  super.init()
@@ -529,18 +801,27 @@ import React
529
801
  }
530
802
  }
531
803
 
532
- public func setupSynchronously() {
804
+ @discardableResult
805
+ public func setupSynchronously() -> CXProvider {
533
806
  // Only initialize once
534
- guard callKitProvider == nil else {
807
+ if let callKitProvider = callKitProvider {
535
808
  NSLog("TelnyxVoice: CallKit already setup, skipping")
536
- return
809
+ return callKitProvider
537
810
  }
538
811
 
539
812
  // CRITICAL: Setup CallKit synchronously for terminated app VoIP push handling
540
813
  // This MUST happen in the same run loop as the VoIP push
541
814
  NSLog("TelnyxVoice: Synchronous CallKit setup for terminated app scenario...")
542
815
  setupCallKit()
816
+ if let callKitProvider = callKitProvider {
817
+ NSLog("TelnyxVoice: ✅ CallKit provider ready for terminated app handling")
818
+ return callKitProvider
819
+ }
820
+
821
+ NSLog("TelnyxVoice: CallKit setup did not create a provider; creating fallback provider")
822
+ let callKitProvider = installCallKitProvider()
543
823
  NSLog("TelnyxVoice: ✅ CallKit provider ready for terminated app handling")
824
+ return callKitProvider
544
825
  }
545
826
 
546
827
  private func setupAutomatically() {
@@ -560,10 +841,6 @@ import React
560
841
  }
561
842
 
562
843
  private func setupCallKit() {
563
- // ALWAYS configure WebRTC audio, even if provider already exists
564
- RTCAudioSession.sharedInstance().useManualAudio = true
565
- RTCAudioSession.sharedInstance().isAudioEnabled = false
566
-
567
844
  // Guard against duplicate provider creation (async setupAutomatically can overwrite
568
845
  // the provider created by setupSynchronously for VoIP push)
569
846
  guard callKitProvider == nil else {
@@ -571,6 +848,85 @@ import React
571
848
  return
572
849
  }
573
850
 
851
+ // Set the initial state only when installing the provider. Re-running setup after
852
+ // CallKit activation must never disable a live WebRTC audio device.
853
+ RTCAudioSession.sharedInstance().useManualAudio = true
854
+ RTCAudioSession.sharedInstance().isAudioEnabled = false
855
+
856
+ _ = installCallKitProvider()
857
+ }
858
+
859
+ fileprivate func activateWebRTCAudio(
860
+ _ audioSession: AVAudioSession = AVAudioSession.sharedInstance(),
861
+ reason: String
862
+ ) {
863
+ let rtcAudioSession = RTCAudioSession.sharedInstance()
864
+ rtcAudioSession.lockForConfiguration()
865
+ let wasAudioEnabled = rtcAudioSession.isAudioEnabled
866
+ NSLog("TelnyxVoice: activating WebRTC audio (\(reason)); enabled=\(wasAudioEnabled)")
867
+ let webRTCConfig = RTCAudioSessionConfiguration.webRTC()
868
+ webRTCConfig.categoryOptions = [.duckOthers, .allowBluetooth]
869
+ do {
870
+ try rtcAudioSession.setConfiguration(webRTCConfig)
871
+ } catch {
872
+ NSLog("TelnyxVoice: activateWebRTCAudio configuration (\(reason)) error: \(error)")
873
+ }
874
+
875
+ var activationSucceeded = false
876
+ do {
877
+ try rtcAudioSession.setActive(true)
878
+ activationSucceeded = true
879
+ } catch {
880
+ NSLog("TelnyxVoice: activateWebRTCAudio activation (\(reason)) error: \(error)")
881
+ }
882
+ if activationSucceeded {
883
+ rtcAudioSession.isAudioEnabled = true
884
+ } else if !wasAudioEnabled {
885
+ rtcAudioSession.isAudioEnabled = false
886
+ }
887
+ rtcAudioSession.unlockForConfiguration()
888
+ if activationSucceeded && !wasAudioEnabled {
889
+ rtcAudioSession.audioSessionDidActivate(audioSession)
890
+ }
891
+ }
892
+
893
+ fileprivate func verifyAudioAfterConnection(for callUUID: UUID) {
894
+ // didActivate is asynchronous after CXAnswerCallAction.fulfill(). Verify shortly
895
+ // afterwards so either callback ordering converges on the same idempotent state.
896
+ let verificationGeneration = currentAudioLifecycleGeneration()
897
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) { [weak self] in
898
+ guard let self,
899
+ self.currentAudioLifecycleGeneration() == verificationGeneration,
900
+ self.activeCalls[callUUID] != nil
901
+ else { return }
902
+ let rtcAudioSession = RTCAudioSession.sharedInstance()
903
+ rtcAudioSession.lockForConfiguration()
904
+ let isAudioEnabled = rtcAudioSession.isAudioEnabled
905
+ rtcAudioSession.unlockForConfiguration()
906
+ if isAudioEnabled {
907
+ NSLog("TelnyxVoice: audio verification passed for \(callUUID)")
908
+ } else if AVAudioSession.sharedInstance().category != .playAndRecord {
909
+ NSLog("TelnyxVoice: audio verification skipped for inactive CallKit session \(callUUID)")
910
+ } else {
911
+ NSLog("TelnyxVoice: audio verification recovering disabled audio for \(callUUID)")
912
+ self.activateWebRTCAudio(reason: "connected-call verification")
913
+ }
914
+ }
915
+ }
916
+
917
+ #if DEBUG
918
+ fileprivate func simulateAudioSetupRace(for callUUID: UUID, delay: TimeInterval) {
919
+ NSLog("TelnyxVoice: [VSUP-226] scheduling late audio reset in \(delay)s")
920
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
921
+ let rtcAudioSession = RTCAudioSession.sharedInstance()
922
+ rtcAudioSession.isAudioEnabled = false
923
+ NSLog("TelnyxVoice: [VSUP-226] injected late setup reset; enabled=false")
924
+ self?.verifyAudioAfterConnection(for: callUUID)
925
+ }
926
+ }
927
+ #endif
928
+
929
+ private func installCallKitProvider() -> CXProvider {
574
930
  // Use the localizedName from the app's bundle display name or fallback
575
931
  let appName =
576
932
  Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
@@ -579,19 +935,66 @@ import React
579
935
 
580
936
  let configuration = CXProviderConfiguration(localizedName: appName)
581
937
  configuration.supportsVideo = false
582
- configuration.maximumCallGroups = 1
938
+ configuration.maximumCallGroups = 2
583
939
  configuration.maximumCallsPerCallGroup = 1
584
940
  configuration.supportedHandleTypes = [.phoneNumber, .generic]
585
941
  configuration.includesCallsInRecents = true
586
942
 
587
- callKitProvider = CXProvider(configuration: configuration)
588
- callKitProvider?.setDelegate(self, queue: nil)
943
+ let provider = CXProvider(configuration: configuration)
944
+ provider.setDelegate(self, queue: nil)
945
+ callKitProvider = provider
589
946
  callKitController = CXCallController()
590
947
 
591
948
  NSLog("TelnyxVoice: CallKit provider and controller created")
949
+ return provider
592
950
  }
593
951
 
594
-
952
+ fileprivate func reportAndEndWatchdogCall(
953
+ callUUID: UUID,
954
+ callerName: String,
955
+ callerNumber: String,
956
+ payload: [AnyHashable: Any],
957
+ source: String,
958
+ endedReason: CXCallEndedReason,
959
+ completion: (() -> Void)? = nil
960
+ ) {
961
+ let callKitProvider = setupSynchronously()
962
+
963
+ activeCalls[callUUID] = [
964
+ "caller": callerName,
965
+ "handle": callerNumber,
966
+ "payload": payload,
967
+ "uuid": callUUID.uuidString,
968
+ "direction": "incoming",
969
+ "source": source,
970
+ "watchdogPlaceholder": true,
971
+ ]
972
+
973
+ let handle = CXHandle(type: .phoneNumber, value: callerNumber)
974
+ let callUpdate = CXCallUpdate()
975
+ callUpdate.remoteHandle = handle
976
+ callUpdate.hasVideo = false
977
+ callUpdate.localizedCallerName = callerName
978
+ callUpdate.supportsHolding = true
979
+
980
+ callKitProvider.reportNewIncomingCall(with: callUUID, update: callUpdate) { error in
981
+ if let error = error {
982
+ NSLog(
983
+ "TelnyxVoice: Watchdog CallKit report failed for \(source): \(error.localizedDescription)"
984
+ )
985
+ } else {
986
+ NSLog("TelnyxVoice: Watchdog CallKit call reported for \(source)")
987
+ }
988
+
989
+ callKitProvider.reportCall(with: callUUID, endedAt: Date(), reason: endedReason)
990
+ self.activeCalls.removeValue(forKey: callUUID)
991
+
992
+ self.pendingAnswerActions.removeValue(forKey: callUUID)?.fail()
993
+ self.pendingHeldActions.removeValue(forKey: callUUID)?.fail()
994
+
995
+ completion?()
996
+ }
997
+ }
595
998
 
596
999
  private func observeAppDelegate() {
597
1000
  // Automatically hook into app lifecycle if needed
@@ -620,28 +1023,34 @@ import React
620
1023
  }
621
1024
 
622
1025
  NSLog("TelnyxVoice: Missed call VoIP push received: \(payload)")
623
- setupSynchronously()
1026
+ let callKitProvider = setupSynchronously()
624
1027
 
625
1028
  let callId = TelnyxMissedCallPush.callId(from: payload)
626
1029
  let missedCallId = TelnyxMissedCallPush.stableIdentifier(from: payload)
627
- if hasProcessedMissedCall(id: missedCallId) {
628
- NSLog("TelnyxVoice: Ignoring duplicate missed call push: \(missedCallId)")
629
- completion?()
630
- return true
631
- }
632
-
633
1030
  let callUUID = TelnyxMissedCallPush.uuid(from: missedCallId)
634
1031
  let callerName = TelnyxMissedCallPush.callerName(from: payload)
635
1032
  let callerNumber = TelnyxMissedCallPush.callerNumber(from: payload)
636
1033
 
1034
+ if hasProcessedMissedCall(id: missedCallId) {
1035
+ NSLog("TelnyxVoice: Suppressing duplicate missed call notification: \(missedCallId)")
1036
+ reportAndEndWatchdogCall(
1037
+ callUUID: UUID(),
1038
+ callerName: callerName,
1039
+ callerNumber: callerNumber,
1040
+ payload: payload,
1041
+ source: "missed_call_duplicate_watchdog",
1042
+ endedReason: .remoteEnded,
1043
+ completion: completion
1044
+ )
1045
+ return true
1046
+ }
1047
+
637
1048
  let finishMissedCall = {
638
- self.callKitProvider?.reportCall(with: callUUID, endedAt: Date(), reason: .remoteEnded)
1049
+ callKitProvider.reportCall(with: callUUID, endedAt: Date(), reason: .remoteEnded)
639
1050
  self.activeCalls.removeValue(forKey: callUUID)
640
1051
 
641
- if self.pendingAnswerAction?.callUUID == callUUID {
642
- self.pendingAnswerAction?.fail()
643
- self.pendingAnswerAction = nil
644
- }
1052
+ self.pendingAnswerActions.removeValue(forKey: callUUID)?.fail()
1053
+ self.pendingHeldActions.removeValue(forKey: callUUID)?.fail()
645
1054
 
646
1055
  NSLog("TelnyxVoice: Ended stale CallKit call for missed call UUID: \(callUUID)")
647
1056
  self.clearPendingPushData(for: callId)
@@ -656,13 +1065,30 @@ import React
656
1065
 
657
1066
  if let activeCall = activeCalls[callUUID] {
658
1067
  guard activeCall["source"] as? String == "missed_call_push" else {
659
- NSLog("TelnyxVoice: Ignoring missed call push because UUID belongs to an active call: \(callUUID)")
1068
+ NSLog("TelnyxVoice: Suppressing missed call notification because UUID belongs to an active call: \(callUUID)")
660
1069
  markMissedCallProcessed(id: missedCallId)
661
- completion?()
1070
+ reportAndEndWatchdogCall(
1071
+ callUUID: UUID(),
1072
+ callerName: callerName,
1073
+ callerNumber: callerNumber,
1074
+ payload: payload,
1075
+ source: "missed_call_collision_watchdog",
1076
+ endedReason: .remoteEnded,
1077
+ completion: completion
1078
+ )
662
1079
  return true
663
1080
  }
664
1081
 
665
- finishMissedCall()
1082
+ reportAndEndWatchdogCall(
1083
+ callUUID: UUID(),
1084
+ callerName: callerName,
1085
+ callerNumber: callerNumber,
1086
+ payload: payload,
1087
+ source: "missed_call_active_watchdog",
1088
+ endedReason: .remoteEnded
1089
+ ) {
1090
+ finishMissedCall()
1091
+ }
666
1092
  return true
667
1093
  }
668
1094
 
@@ -680,10 +1106,12 @@ import React
680
1106
  callUpdate.remoteHandle = handle
681
1107
  callUpdate.hasVideo = false
682
1108
  callUpdate.localizedCallerName = callerName
1109
+ callUpdate.supportsHolding = true
683
1110
 
684
- callKitProvider?.reportNewIncomingCall(with: callUUID, update: callUpdate) { error in
1111
+ callKitProvider.reportNewIncomingCall(with: callUUID, update: callUpdate) { error in
685
1112
  if let error = error {
686
1113
  NSLog("TelnyxVoice: Missed call CallKit report failed: \(error.localizedDescription)")
1114
+ callKitProvider.reportCall(with: callUUID, endedAt: Date(), reason: .failed)
687
1115
  self.activeCalls.removeValue(forKey: callUUID)
688
1116
  self.clearPendingPushData(for: callId)
689
1117
  completion?()
@@ -696,7 +1124,7 @@ import React
696
1124
  return true
697
1125
  }
698
1126
 
699
- private func clearPendingPushData(for callId: String?) {
1127
+ fileprivate func clearPendingPushData(for callId: String?) {
700
1128
  if hasPendingPushData(forDifferentCallThan: callId) {
701
1129
  NSLog("TelnyxVoice: Skipping pending push cleanup for missed call; pending data belongs to a different call")
702
1130
  return
@@ -721,7 +1149,9 @@ import React
721
1149
  UserDefaults.standard.string(forKey: "@pending_callkit_uuid"),
722
1150
  ].compactMap { $0 }
723
1151
 
724
- return pendingIds.contains { !$0.isEmpty && $0 != missedCallId }
1152
+ return pendingIds.contains {
1153
+ !$0.isEmpty && $0.caseInsensitiveCompare(missedCallId) != .orderedSame
1154
+ }
725
1155
  }
726
1156
 
727
1157
  private func pendingCallId(fromJsonForKey key: String) -> String? {
@@ -918,6 +1348,7 @@ import React
918
1348
  "uuid": callUUID.uuidString,
919
1349
  "direction": "incoming",
920
1350
  "source": "push",
1351
+ "isCallKitRegistered": false,
921
1352
  ]
922
1353
 
923
1354
  let handle = CXHandle(type: .phoneNumber, value: caller)
@@ -925,6 +1356,7 @@ import React
925
1356
  callUpdate.remoteHandle = handle
926
1357
  callUpdate.hasVideo = false
927
1358
  callUpdate.localizedCallerName = caller
1359
+ callUpdate.supportsHolding = true
928
1360
 
929
1361
  callKitProvider?.reportNewIncomingCall(with: callUUID, update: callUpdate) {
930
1362
  [weak self] error in
@@ -932,6 +1364,7 @@ import React
932
1364
  NSLog("TelnyxVoice: CallKit error: \(error.localizedDescription)")
933
1365
  self?.activeCalls.removeValue(forKey: callUUID)
934
1366
  } else {
1367
+ self?.markCallKitRegistered(callUUID)
935
1368
  NSLog("TelnyxVoice: CallKit incoming call reported successfully")
936
1369
  }
937
1370
  completion()
@@ -946,12 +1379,35 @@ import React
946
1379
  NSLog("📞 TelnyxVoice: CALLKIT PROVIDER RESET - Provider: \(provider)")
947
1380
  NSLog("TelnyxVoice: CallKit provider reset - ending all active calls")
948
1381
  activeCalls.removeAll()
1382
+ pendingAnswerActions.values.forEach { $0.fail() }
1383
+ pendingAnswerActions.removeAll()
1384
+ pendingHeldActions.values.forEach { $0.fail() }
1385
+ pendingHeldActions.removeAll()
949
1386
  }
950
1387
 
951
1388
  public func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
952
1389
  NSLog("📞 TelnyxVoice: CALLKIT ANSWER ACTION - Provider: \(provider), Action: \(action)")
953
1390
  NSLog("TelnyxVoice: User answered call with UUID: \(action.callUUID)")
954
1391
 
1392
+ if activeCalls[action.callUUID]?["watchdogPlaceholder"] as? Bool == true {
1393
+ NSLog("TelnyxVoice: Failing answer for watchdog placeholder call: \(action.callUUID)")
1394
+ activeCalls.removeValue(forKey: action.callUUID)
1395
+ action.fail()
1396
+ return
1397
+ }
1398
+
1399
+ guard isCallKitRegistered(action.callUUID) else {
1400
+ NSLog("TelnyxVoice: Failing answer for unknown UUID: \(action.callUUID)")
1401
+ action.fail()
1402
+ return
1403
+ }
1404
+
1405
+ guard pendingAnswerActions[action.callUUID] == nil else {
1406
+ NSLog("TelnyxVoice: Failing duplicate answer for UUID: \(action.callUUID)")
1407
+ action.fail()
1408
+ return
1409
+ }
1410
+
955
1411
  // Check if this is a programmatic answer (call already answered in WebRTC)
956
1412
  // vs a user answer from CallKit UI
957
1413
  if let callData = activeCalls[action.callUUID],
@@ -961,25 +1417,84 @@ import React
961
1417
  NSLog("TelnyxVoice: Call already answered in WebRTC, skipping emission")
962
1418
  } else {
963
1419
  // Notify React Native via CallKit bridge (only for user-initiated answers)
964
- CallKitBridge.shared?.emitCallEvent(
1420
+ let eventDelivered = CallKitBridge.shared?.emitCallEvent(
965
1421
  "CallKitDidPerformAnswerCallAction", callUUID: action.callUUID,
966
- callData: activeCalls[action.callUUID])
1422
+ callData: activeCalls[action.callUUID]) ?? false
1423
+ if !eventDelivered {
1424
+ UserDefaults.standard.set(action.callUUID.uuidString, forKey: "pending_callkit_answer")
1425
+ UserDefaults.standard.synchronize()
1426
+ NSLog("TelnyxVoice: Stored pending CallKit answer for UUID: \(action.callUUID)")
1427
+ }
967
1428
  }
968
1429
 
969
1430
  // Defer action.fulfill() until reportCallConnected when peer connection is ready
970
1431
  NSLog("TelnyxVoice: Deferring CXAnswerCallAction.fulfill() until peer connection is ready")
971
- self.pendingAnswerAction = action
1432
+ self.pendingAnswerActions[action.callUUID] = action
1433
+ }
1434
+
1435
+ public func provider(_ provider: CXProvider, perform action: CXSetHeldCallAction) {
1436
+ NSLog(
1437
+ "TelnyxVoice: User requested held=\(action.isOnHold) for UUID: \(action.callUUID)"
1438
+ )
1439
+
1440
+ guard activeCalls[action.callUUID] != nil else {
1441
+ NSLog("TelnyxVoice: Failing held action for unknown UUID: \(action.callUUID)")
1442
+ action.fail()
1443
+ return
1444
+ }
1445
+
1446
+ guard pendingHeldActions[action.callUUID] == nil else {
1447
+ NSLog("TelnyxVoice: Failing duplicate held action for UUID: \(action.callUUID)")
1448
+ action.fail()
1449
+ return
1450
+ }
1451
+
1452
+ pendingHeldActions[action.callUUID] = action
1453
+ let emitted = CallKitBridge.shared?.emitHeldCallEvent(
1454
+ callUUID: action.callUUID,
1455
+ isOnHold: action.isOnHold,
1456
+ callData: activeCalls[action.callUUID]
1457
+ ) ?? false
1458
+
1459
+ guard emitted else {
1460
+ pendingHeldActions.removeValue(forKey: action.callUUID)
1461
+ action.fail()
1462
+ return
1463
+ }
1464
+
1465
+ DispatchQueue.main.asyncAfter(deadline: .now() + 10) { [weak self, weak action] in
1466
+ guard let self = self, let action = action,
1467
+ self.pendingHeldActions[action.callUUID] === action
1468
+ else { return }
1469
+
1470
+ NSLog("TelnyxVoice: Held action timed out for UUID: \(action.callUUID)")
1471
+ self.pendingHeldActions.removeValue(forKey: action.callUUID)
1472
+ action.fail()
1473
+ }
972
1474
  }
973
1475
 
974
1476
  public func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
975
1477
  NSLog("TelnyxVoice: User ended call with UUID: \(action.callUUID)")
976
1478
 
1479
+ guard let callData = activeCalls[action.callUUID] else {
1480
+ NSLog("TelnyxVoice: Failing end action for unknown UUID: \(action.callUUID)")
1481
+ action.fail()
1482
+ return
1483
+ }
1484
+ if UserDefaults.standard.string(forKey: "pending_callkit_answer") == action.callUUID.uuidString {
1485
+ UserDefaults.standard.removeObject(forKey: "pending_callkit_answer")
1486
+ UserDefaults.standard.synchronize()
1487
+ }
1488
+
977
1489
  // Notify React Native via CallKit bridge
978
1490
  CallKitBridge.shared?.emitCallEvent(
979
1491
  "CallKitDidPerformEndCallAction", callUUID: action.callUUID,
980
- callData: activeCalls[action.callUUID])
1492
+ callData: callData)
981
1493
 
1494
+ _ = advanceAudioLifecycleGeneration()
982
1495
  activeCalls.removeValue(forKey: action.callUUID)
1496
+ pendingAnswerActions.removeValue(forKey: action.callUUID)?.fail()
1497
+ pendingHeldActions.removeValue(forKey: action.callUUID)?.fail()
983
1498
  NSLog("📞 TelnyxVoice: Fulfilling CXEndCallAction for call UUID: \(action.callUUID)")
984
1499
  action.fulfill()
985
1500
  NSLog("📞 TelnyxVoice: ✅ CXEndCallAction fulfilled successfully")
@@ -993,38 +1508,30 @@ import React
993
1508
  "CallKitDidReceiveStartCallAction", callUUID: action.callUUID,
994
1509
  callData: activeCalls[action.callUUID])
995
1510
 
1511
+ let callUpdate = CXCallUpdate()
1512
+ callUpdate.remoteHandle = action.handle
1513
+ callUpdate.hasVideo = false
1514
+ callUpdate.supportsHolding = true
1515
+ provider.reportCall(with: action.callUUID, updated: callUpdate)
1516
+
996
1517
  NSLog("📞 TelnyxVoice: Fulfilling CXStartCallAction for call UUID: \(action.callUUID)")
997
1518
  action.fulfill()
998
1519
  NSLog("📞 TelnyxVoice: ✅ CXStartCallAction fulfilled successfully")
999
1520
  }
1000
1521
 
1001
- public func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
1002
- NSLog("TelnyxVoice: Audio session activated by CallKit")
1003
-
1004
- let rtcAudioSession = RTCAudioSession.sharedInstance()
1005
-
1006
- // Step 1: Configure (matches native iOS SDK setupCorrectAudioConfiguration)
1007
- rtcAudioSession.lockForConfiguration()
1008
- let webRTCConfig = RTCAudioSessionConfiguration.webRTC()
1009
- webRTCConfig.categoryOptions = [.duckOthers, .allowBluetooth]
1010
- do {
1011
- try rtcAudioSession.setConfiguration(webRTCConfig)
1012
- } catch {
1013
- NSLog("TelnyxVoice: didActivateAudioSession - setConfiguration error: \(error)")
1522
+ public func provider(_ provider: CXProvider, timedOutPerforming action: CXAction) {
1523
+ if let answerAction = action as? CXAnswerCallAction {
1524
+ pendingAnswerActions.removeValue(forKey: answerAction.callUUID)
1525
+ } else if let heldAction = action as? CXSetHeldCallAction {
1526
+ pendingHeldActions.removeValue(forKey: heldAction.callUUID)
1014
1527
  }
1015
- rtcAudioSession.unlockForConfiguration()
1016
-
1017
- // Step 2: Activate (matches native iOS SDK setAudioSessionActive)
1018
- rtcAudioSession.lockForConfiguration()
1019
- do {
1020
- try rtcAudioSession.setActive(true)
1021
- } catch {
1022
- NSLog("TelnyxVoice: didActivateAudioSession - setActive error: \(error)")
1023
- }
1024
- rtcAudioSession.isAudioEnabled = true
1025
- rtcAudioSession.unlockForConfiguration()
1528
+ action.fail()
1529
+ }
1026
1530
 
1027
- rtcAudioSession.audioSessionDidActivate(audioSession)
1531
+ public func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
1532
+ NSLog("TelnyxVoice: Audio session activated by CallKit")
1533
+ _ = advanceAudioLifecycleGeneration()
1534
+ activateWebRTCAudio(audioSession, reason: "CallKit didActivate")
1028
1535
 
1029
1536
  // Emit audio session activated event to React Native
1030
1537
  CallKitBridge.shared?.emitAudioSessionEvent(
@@ -1038,6 +1545,7 @@ import React
1038
1545
 
1039
1546
  public func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
1040
1547
  NSLog("TelnyxVoice: Audio session deactivated by CallKit")
1548
+ _ = advanceAudioLifecycleGeneration()
1041
1549
 
1042
1550
  let rtcAudioSession = RTCAudioSession.sharedInstance()
1043
1551
 
@@ -1084,6 +1592,33 @@ import React
1084
1592
  super.init()
1085
1593
  }
1086
1594
 
1595
+ private func storePendingVoipPush(_ payload: [AnyHashable: Any]) {
1596
+ do {
1597
+ let jsonData = try JSONSerialization.data(withJSONObject: payload)
1598
+ let jsonString = String(data: jsonData, encoding: .utf8) ?? ""
1599
+
1600
+ UserDefaults.standard.set("incoming_call", forKey: "pending_push_action")
1601
+ UserDefaults.standard.set(jsonString, forKey: "pending_push_metadata")
1602
+
1603
+ let voipPushData = [
1604
+ "payload": payload
1605
+ ]
1606
+ let voipJsonData = try JSONSerialization.data(withJSONObject: voipPushData)
1607
+ let voipJsonString = String(data: voipJsonData, encoding: .utf8) ?? ""
1608
+ UserDefaults.standard.set(voipJsonString, forKey: "pending_voip_push")
1609
+ UserDefaults.standard.synchronize()
1610
+ NSLog(
1611
+ "[TelnyxVoipPushHandler] Stored VoIP push data after CallKit registration succeeded"
1612
+ )
1613
+ } catch {
1614
+ NSLog("[TelnyxVoipPushHandler] Error converting VoIP payload to JSON: \(error)")
1615
+ UserDefaults.standard.set("incoming_call", forKey: "pending_push_action")
1616
+ UserDefaults.standard.set("{}", forKey: "pending_push_metadata")
1617
+ UserDefaults.standard.set("{\"payload\":{}}", forKey: "pending_voip_push")
1618
+ UserDefaults.standard.synchronize()
1619
+ }
1620
+ }
1621
+
1087
1622
  /**
1088
1623
  * Initialize VoIP push registration
1089
1624
  * Call this from the main app's AppDelegate didFinishLaunchingWithOptions
@@ -1132,35 +1667,6 @@ import React
1132
1667
  } catch {
1133
1668
  NSLog("Failed to activate audio session: \(error)")
1134
1669
  }
1135
- // Store the VoIP push data for VoicePnBridge
1136
- do {
1137
- let jsonData = try JSONSerialization.data(withJSONObject: payload.dictionaryPayload)
1138
- let jsonString = String(data: jsonData, encoding: .utf8) ?? ""
1139
-
1140
- // Store for TelnyxVoiceApp (push action flow)
1141
- UserDefaults.standard.set("incoming_call", forKey: "pending_push_action")
1142
- UserDefaults.standard.set(jsonString, forKey: "pending_push_metadata")
1143
-
1144
- // ALSO store for VoicePnBridge.getPendingVoipPush() (CallKit coordinator flow)
1145
- // This creates a structured object with payload property
1146
- let voipPushData = [
1147
- "payload": payload.dictionaryPayload
1148
- ]
1149
- let voipJsonData = try JSONSerialization.data(withJSONObject: voipPushData)
1150
- let voipJsonString = String(data: voipJsonData, encoding: .utf8) ?? ""
1151
- UserDefaults.standard.set(voipJsonString, forKey: "pending_voip_push")
1152
-
1153
- UserDefaults.standard.synchronize()
1154
- NSLog(
1155
- "[TelnyxVoipPushHandler] Stored VoIP push data for both TelnyxVoiceApp and VoicePnBridge"
1156
- )
1157
- } catch {
1158
- NSLog("[TelnyxVoipPushHandler] Error converting VoIP payload to JSON: \(error)")
1159
- UserDefaults.standard.set("incoming_call", forKey: "pending_push_action")
1160
- UserDefaults.standard.set("{}", forKey: "pending_push_metadata")
1161
- UserDefaults.standard.set("{\"payload\":{}}", forKey: "pending_voip_push")
1162
- UserDefaults.standard.synchronize()
1163
- }
1164
1670
 
1165
1671
  // Extract caller information and call_id from the payload
1166
1672
  var callerName = "Unknown Caller"
@@ -1181,12 +1687,20 @@ import React
1181
1687
  callId = payload.dictionaryPayload["call_id"] as? String
1182
1688
  }
1183
1689
 
1184
- // Use call_id as CallKit UUID to ensure matching with WebRTC
1690
+ // Use call_id as CallKit UUID to ensure matching with WebRTC.
1185
1691
  guard let callIdString = callId, let callUUID = UUID(uuidString: callIdString) else {
1186
1692
  NSLog(
1187
- "[TelnyxVoipPushHandler] ❌ No valid call_id found in payload, cannot process call"
1693
+ "[TelnyxVoipPushHandler] ⚠️ Invalid or missing call_id in payload (\(callId ?? "nil")); reporting failed watchdog placeholder"
1694
+ )
1695
+ TelnyxCallKitManager.shared.reportAndEndWatchdogCall(
1696
+ callUUID: UUID(),
1697
+ callerName: callerName,
1698
+ callerNumber: callerNumber,
1699
+ payload: payload.dictionaryPayload,
1700
+ source: "malformed_push_watchdog",
1701
+ endedReason: .failed,
1702
+ completion: completion
1188
1703
  )
1189
- completion()
1190
1704
  return
1191
1705
  }
1192
1706
 
@@ -1202,16 +1716,7 @@ import React
1202
1716
 
1203
1717
  // CRITICAL: Setup CallKit SYNCHRONOUSLY - no async dispatch allowed
1204
1718
  // This must happen in the same run loop as the VoIP push
1205
- callKitManager.setupSynchronously()
1206
-
1207
- // Ensure we have a valid CallKit provider after setup
1208
- guard let callKitProvider = callKitManager.callKitProvider else {
1209
- NSLog(
1210
- "[TelnyxVoipPushHandler] ❌ FATAL: CallKit provider not available after synchronous setup!"
1211
- )
1212
- completion()
1213
- return
1214
- }
1719
+ let callKitProvider = callKitManager.setupSynchronously()
1215
1720
 
1216
1721
  NSLog("[TelnyxVoipPushHandler] ✅ CallKit provider ready, reporting incoming call")
1217
1722
 
@@ -1225,6 +1730,7 @@ import React
1225
1730
  "uuid": callUUID.uuidString,
1226
1731
  "direction": "incoming",
1227
1732
  "source": isAppRunning ? "push_notification" : "terminated_app_push",
1733
+ "isCallKitRegistered": false,
1228
1734
  ]
1229
1735
 
1230
1736
  // Report to CallKit immediately
@@ -1233,18 +1739,27 @@ import React
1233
1739
  callUpdate.remoteHandle = handle
1234
1740
  callUpdate.hasVideo = false
1235
1741
  callUpdate.localizedCallerName = callerName
1742
+ callUpdate.supportsHolding = true
1236
1743
 
1237
1744
  callKitProvider.reportNewIncomingCall(with: callUUID, update: callUpdate) { error in
1238
1745
  if let error = error {
1746
+ let nsError = error as NSError
1747
+ let reason =
1748
+ nsError.domain == CXErrorDomainIncomingCall && nsError.code == 3
1749
+ ? "filteredByDoNotDisturb" : "registrationRejected"
1239
1750
  NSLog(
1240
- "[TelnyxVoipPushHandler] ❌ CallKit error during terminated app handling: \(error.localizedDescription)"
1751
+ "[TelnyxVoipPushHandler] CallKit incoming registration rejected: domain=\(nsError.domain), code=\(nsError.code), reason=\(reason)"
1241
1752
  )
1242
1753
  callKitManager.activeCalls.removeValue(forKey: callUUID)
1754
+ callKitManager.clearPendingPushData(for: callUUID.uuidString)
1243
1755
  } else {
1244
1756
  NSLog(
1245
1757
  "[TelnyxVoipPushHandler] ✅ CallKit call reported successfully via unified path"
1246
1758
  )
1247
1759
 
1760
+ callKitManager.markCallKitRegistered(callUUID)
1761
+ self.storePendingVoipPush(payload.dictionaryPayload)
1762
+
1248
1763
  // CRITICAL: Store the CallKit UUID for the React Native CallKitCoordinator
1249
1764
  // This allows the coordinator to find the existing CallKit call instead of creating a duplicate
1250
1765
  UserDefaults.standard.set(callUUID.uuidString, forKey: "@pending_callkit_uuid")