@telnyx/react-voice-commons-sdk 1.0.0 → 1.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 (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 +20 -1
  8. package/ios/CallKitBridge.swift +527 -78
  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 +6 -4
  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,10 +436,9 @@ 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
443
  // Fallback: ensure audio is enabled for non-push or already-fulfilled cases
217
444
  let rtcAudioSession = RTCAudioSession.sharedInstance()
@@ -254,6 +481,9 @@ import React
254
481
 
255
482
  let endReason = CXCallEndedReason(rawValue: reason.intValue) ?? .remoteEnded
256
483
  provider.reportCall(with: uuid, endedAt: Date(), reason: endReason)
484
+ manager.activeCalls.removeValue(forKey: uuid)
485
+ manager.pendingAnswerActions.removeValue(forKey: uuid)?.fail()
486
+ manager.pendingHeldActions.removeValue(forKey: uuid)?.fail()
257
487
  resolve(["success": true])
258
488
  }
259
489
 
@@ -286,6 +516,7 @@ import React
286
516
  let callUpdate = CXCallUpdate()
287
517
  callUpdate.remoteHandle = callHandle
288
518
  callUpdate.localizedCallerName = displayName
519
+ callUpdate.supportsHolding = true
289
520
 
290
521
  provider.reportCall(with: uuid, updated: callUpdate)
291
522
  resolve(["success": true])
@@ -507,8 +738,33 @@ import React
507
738
  public var voipRegistry: PKPushRegistry?
508
739
  public var callKitProvider: CXProvider?
509
740
  public var callKitController: CXCallController?
510
- public var activeCalls: [UUID: [String: Any]] = [:]
511
- public var pendingAnswerAction: CXAnswerCallAction?
741
+ private let stateLock = NSRecursiveLock()
742
+ public lazy var activeCalls = LockedDictionary<UUID, [String: Any]>(lock: stateLock)
743
+ public lazy var pendingAnswerActions = LockedDictionary<UUID, CXAnswerCallAction>(
744
+ lock: stateLock)
745
+ public lazy var pendingHeldActions = LockedDictionary<UUID, CXSetHeldCallAction>(
746
+ lock: stateLock)
747
+
748
+ fileprivate func markCallKitRegistered(_ callUUID: UUID) {
749
+ guard var callData = activeCalls[callUUID] else {
750
+ return
751
+ }
752
+
753
+ callData["isCallKitRegistered"] = true
754
+ activeCalls[callUUID] = callData
755
+ }
756
+
757
+ fileprivate func isCallKitRegistered(_ callUUID: UUID) -> Bool {
758
+ guard let callData = activeCalls[callUUID] else {
759
+ return false
760
+ }
761
+
762
+ if callData["direction"] as? String == "incoming" {
763
+ return callData["isCallKitRegistered"] as? Bool == true
764
+ }
765
+
766
+ return true
767
+ }
512
768
 
513
769
  private override init() {
514
770
  super.init()
@@ -529,18 +785,27 @@ import React
529
785
  }
530
786
  }
531
787
 
532
- public func setupSynchronously() {
788
+ @discardableResult
789
+ public func setupSynchronously() -> CXProvider {
533
790
  // Only initialize once
534
- guard callKitProvider == nil else {
791
+ if let callKitProvider = callKitProvider {
535
792
  NSLog("TelnyxVoice: CallKit already setup, skipping")
536
- return
793
+ return callKitProvider
537
794
  }
538
795
 
539
796
  // CRITICAL: Setup CallKit synchronously for terminated app VoIP push handling
540
797
  // This MUST happen in the same run loop as the VoIP push
541
798
  NSLog("TelnyxVoice: Synchronous CallKit setup for terminated app scenario...")
542
799
  setupCallKit()
800
+ if let callKitProvider = callKitProvider {
801
+ NSLog("TelnyxVoice: ✅ CallKit provider ready for terminated app handling")
802
+ return callKitProvider
803
+ }
804
+
805
+ NSLog("TelnyxVoice: CallKit setup did not create a provider; creating fallback provider")
806
+ let callKitProvider = installCallKitProvider()
543
807
  NSLog("TelnyxVoice: ✅ CallKit provider ready for terminated app handling")
808
+ return callKitProvider
544
809
  }
545
810
 
546
811
  private func setupAutomatically() {
@@ -571,6 +836,10 @@ import React
571
836
  return
572
837
  }
573
838
 
839
+ _ = installCallKitProvider()
840
+ }
841
+
842
+ private func installCallKitProvider() -> CXProvider {
574
843
  // Use the localizedName from the app's bundle display name or fallback
575
844
  let appName =
576
845
  Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
@@ -579,19 +848,66 @@ import React
579
848
 
580
849
  let configuration = CXProviderConfiguration(localizedName: appName)
581
850
  configuration.supportsVideo = false
582
- configuration.maximumCallGroups = 1
851
+ configuration.maximumCallGroups = 2
583
852
  configuration.maximumCallsPerCallGroup = 1
584
853
  configuration.supportedHandleTypes = [.phoneNumber, .generic]
585
854
  configuration.includesCallsInRecents = true
586
855
 
587
- callKitProvider = CXProvider(configuration: configuration)
588
- callKitProvider?.setDelegate(self, queue: nil)
856
+ let provider = CXProvider(configuration: configuration)
857
+ provider.setDelegate(self, queue: nil)
858
+ callKitProvider = provider
589
859
  callKitController = CXCallController()
590
860
 
591
861
  NSLog("TelnyxVoice: CallKit provider and controller created")
862
+ return provider
592
863
  }
593
864
 
594
-
865
+ fileprivate func reportAndEndWatchdogCall(
866
+ callUUID: UUID,
867
+ callerName: String,
868
+ callerNumber: String,
869
+ payload: [AnyHashable: Any],
870
+ source: String,
871
+ endedReason: CXCallEndedReason,
872
+ completion: (() -> Void)? = nil
873
+ ) {
874
+ let callKitProvider = setupSynchronously()
875
+
876
+ activeCalls[callUUID] = [
877
+ "caller": callerName,
878
+ "handle": callerNumber,
879
+ "payload": payload,
880
+ "uuid": callUUID.uuidString,
881
+ "direction": "incoming",
882
+ "source": source,
883
+ "watchdogPlaceholder": true,
884
+ ]
885
+
886
+ let handle = CXHandle(type: .phoneNumber, value: callerNumber)
887
+ let callUpdate = CXCallUpdate()
888
+ callUpdate.remoteHandle = handle
889
+ callUpdate.hasVideo = false
890
+ callUpdate.localizedCallerName = callerName
891
+ callUpdate.supportsHolding = true
892
+
893
+ callKitProvider.reportNewIncomingCall(with: callUUID, update: callUpdate) { error in
894
+ if let error = error {
895
+ NSLog(
896
+ "TelnyxVoice: Watchdog CallKit report failed for \(source): \(error.localizedDescription)"
897
+ )
898
+ } else {
899
+ NSLog("TelnyxVoice: Watchdog CallKit call reported for \(source)")
900
+ }
901
+
902
+ callKitProvider.reportCall(with: callUUID, endedAt: Date(), reason: endedReason)
903
+ self.activeCalls.removeValue(forKey: callUUID)
904
+
905
+ self.pendingAnswerActions.removeValue(forKey: callUUID)?.fail()
906
+ self.pendingHeldActions.removeValue(forKey: callUUID)?.fail()
907
+
908
+ completion?()
909
+ }
910
+ }
595
911
 
596
912
  private func observeAppDelegate() {
597
913
  // Automatically hook into app lifecycle if needed
@@ -620,28 +936,34 @@ import React
620
936
  }
621
937
 
622
938
  NSLog("TelnyxVoice: Missed call VoIP push received: \(payload)")
623
- setupSynchronously()
939
+ let callKitProvider = setupSynchronously()
624
940
 
625
941
  let callId = TelnyxMissedCallPush.callId(from: payload)
626
942
  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
943
  let callUUID = TelnyxMissedCallPush.uuid(from: missedCallId)
634
944
  let callerName = TelnyxMissedCallPush.callerName(from: payload)
635
945
  let callerNumber = TelnyxMissedCallPush.callerNumber(from: payload)
636
946
 
947
+ if hasProcessedMissedCall(id: missedCallId) {
948
+ NSLog("TelnyxVoice: Suppressing duplicate missed call notification: \(missedCallId)")
949
+ reportAndEndWatchdogCall(
950
+ callUUID: UUID(),
951
+ callerName: callerName,
952
+ callerNumber: callerNumber,
953
+ payload: payload,
954
+ source: "missed_call_duplicate_watchdog",
955
+ endedReason: .remoteEnded,
956
+ completion: completion
957
+ )
958
+ return true
959
+ }
960
+
637
961
  let finishMissedCall = {
638
- self.callKitProvider?.reportCall(with: callUUID, endedAt: Date(), reason: .remoteEnded)
962
+ callKitProvider.reportCall(with: callUUID, endedAt: Date(), reason: .remoteEnded)
639
963
  self.activeCalls.removeValue(forKey: callUUID)
640
964
 
641
- if self.pendingAnswerAction?.callUUID == callUUID {
642
- self.pendingAnswerAction?.fail()
643
- self.pendingAnswerAction = nil
644
- }
965
+ self.pendingAnswerActions.removeValue(forKey: callUUID)?.fail()
966
+ self.pendingHeldActions.removeValue(forKey: callUUID)?.fail()
645
967
 
646
968
  NSLog("TelnyxVoice: Ended stale CallKit call for missed call UUID: \(callUUID)")
647
969
  self.clearPendingPushData(for: callId)
@@ -656,13 +978,30 @@ import React
656
978
 
657
979
  if let activeCall = activeCalls[callUUID] {
658
980
  guard activeCall["source"] as? String == "missed_call_push" else {
659
- NSLog("TelnyxVoice: Ignoring missed call push because UUID belongs to an active call: \(callUUID)")
981
+ NSLog("TelnyxVoice: Suppressing missed call notification because UUID belongs to an active call: \(callUUID)")
660
982
  markMissedCallProcessed(id: missedCallId)
661
- completion?()
983
+ reportAndEndWatchdogCall(
984
+ callUUID: UUID(),
985
+ callerName: callerName,
986
+ callerNumber: callerNumber,
987
+ payload: payload,
988
+ source: "missed_call_collision_watchdog",
989
+ endedReason: .remoteEnded,
990
+ completion: completion
991
+ )
662
992
  return true
663
993
  }
664
994
 
665
- finishMissedCall()
995
+ reportAndEndWatchdogCall(
996
+ callUUID: UUID(),
997
+ callerName: callerName,
998
+ callerNumber: callerNumber,
999
+ payload: payload,
1000
+ source: "missed_call_active_watchdog",
1001
+ endedReason: .remoteEnded
1002
+ ) {
1003
+ finishMissedCall()
1004
+ }
666
1005
  return true
667
1006
  }
668
1007
 
@@ -680,10 +1019,12 @@ import React
680
1019
  callUpdate.remoteHandle = handle
681
1020
  callUpdate.hasVideo = false
682
1021
  callUpdate.localizedCallerName = callerName
1022
+ callUpdate.supportsHolding = true
683
1023
 
684
- callKitProvider?.reportNewIncomingCall(with: callUUID, update: callUpdate) { error in
1024
+ callKitProvider.reportNewIncomingCall(with: callUUID, update: callUpdate) { error in
685
1025
  if let error = error {
686
1026
  NSLog("TelnyxVoice: Missed call CallKit report failed: \(error.localizedDescription)")
1027
+ callKitProvider.reportCall(with: callUUID, endedAt: Date(), reason: .failed)
687
1028
  self.activeCalls.removeValue(forKey: callUUID)
688
1029
  self.clearPendingPushData(for: callId)
689
1030
  completion?()
@@ -696,7 +1037,7 @@ import React
696
1037
  return true
697
1038
  }
698
1039
 
699
- private func clearPendingPushData(for callId: String?) {
1040
+ fileprivate func clearPendingPushData(for callId: String?) {
700
1041
  if hasPendingPushData(forDifferentCallThan: callId) {
701
1042
  NSLog("TelnyxVoice: Skipping pending push cleanup for missed call; pending data belongs to a different call")
702
1043
  return
@@ -721,7 +1062,9 @@ import React
721
1062
  UserDefaults.standard.string(forKey: "@pending_callkit_uuid"),
722
1063
  ].compactMap { $0 }
723
1064
 
724
- return pendingIds.contains { !$0.isEmpty && $0 != missedCallId }
1065
+ return pendingIds.contains {
1066
+ !$0.isEmpty && $0.caseInsensitiveCompare(missedCallId) != .orderedSame
1067
+ }
725
1068
  }
726
1069
 
727
1070
  private func pendingCallId(fromJsonForKey key: String) -> String? {
@@ -918,6 +1261,7 @@ import React
918
1261
  "uuid": callUUID.uuidString,
919
1262
  "direction": "incoming",
920
1263
  "source": "push",
1264
+ "isCallKitRegistered": false,
921
1265
  ]
922
1266
 
923
1267
  let handle = CXHandle(type: .phoneNumber, value: caller)
@@ -925,6 +1269,7 @@ import React
925
1269
  callUpdate.remoteHandle = handle
926
1270
  callUpdate.hasVideo = false
927
1271
  callUpdate.localizedCallerName = caller
1272
+ callUpdate.supportsHolding = true
928
1273
 
929
1274
  callKitProvider?.reportNewIncomingCall(with: callUUID, update: callUpdate) {
930
1275
  [weak self] error in
@@ -932,6 +1277,7 @@ import React
932
1277
  NSLog("TelnyxVoice: CallKit error: \(error.localizedDescription)")
933
1278
  self?.activeCalls.removeValue(forKey: callUUID)
934
1279
  } else {
1280
+ self?.markCallKitRegistered(callUUID)
935
1281
  NSLog("TelnyxVoice: CallKit incoming call reported successfully")
936
1282
  }
937
1283
  completion()
@@ -946,12 +1292,35 @@ import React
946
1292
  NSLog("📞 TelnyxVoice: CALLKIT PROVIDER RESET - Provider: \(provider)")
947
1293
  NSLog("TelnyxVoice: CallKit provider reset - ending all active calls")
948
1294
  activeCalls.removeAll()
1295
+ pendingAnswerActions.values.forEach { $0.fail() }
1296
+ pendingAnswerActions.removeAll()
1297
+ pendingHeldActions.values.forEach { $0.fail() }
1298
+ pendingHeldActions.removeAll()
949
1299
  }
950
1300
 
951
1301
  public func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
952
1302
  NSLog("📞 TelnyxVoice: CALLKIT ANSWER ACTION - Provider: \(provider), Action: \(action)")
953
1303
  NSLog("TelnyxVoice: User answered call with UUID: \(action.callUUID)")
954
1304
 
1305
+ if activeCalls[action.callUUID]?["watchdogPlaceholder"] as? Bool == true {
1306
+ NSLog("TelnyxVoice: Failing answer for watchdog placeholder call: \(action.callUUID)")
1307
+ activeCalls.removeValue(forKey: action.callUUID)
1308
+ action.fail()
1309
+ return
1310
+ }
1311
+
1312
+ guard isCallKitRegistered(action.callUUID) else {
1313
+ NSLog("TelnyxVoice: Failing answer for unknown UUID: \(action.callUUID)")
1314
+ action.fail()
1315
+ return
1316
+ }
1317
+
1318
+ guard pendingAnswerActions[action.callUUID] == nil else {
1319
+ NSLog("TelnyxVoice: Failing duplicate answer for UUID: \(action.callUUID)")
1320
+ action.fail()
1321
+ return
1322
+ }
1323
+
955
1324
  // Check if this is a programmatic answer (call already answered in WebRTC)
956
1325
  // vs a user answer from CallKit UI
957
1326
  if let callData = activeCalls[action.callUUID],
@@ -961,25 +1330,83 @@ import React
961
1330
  NSLog("TelnyxVoice: Call already answered in WebRTC, skipping emission")
962
1331
  } else {
963
1332
  // Notify React Native via CallKit bridge (only for user-initiated answers)
964
- CallKitBridge.shared?.emitCallEvent(
1333
+ let eventDelivered = CallKitBridge.shared?.emitCallEvent(
965
1334
  "CallKitDidPerformAnswerCallAction", callUUID: action.callUUID,
966
- callData: activeCalls[action.callUUID])
1335
+ callData: activeCalls[action.callUUID]) ?? false
1336
+ if !eventDelivered {
1337
+ UserDefaults.standard.set(action.callUUID.uuidString, forKey: "pending_callkit_answer")
1338
+ UserDefaults.standard.synchronize()
1339
+ NSLog("TelnyxVoice: Stored pending CallKit answer for UUID: \(action.callUUID)")
1340
+ }
967
1341
  }
968
1342
 
969
1343
  // Defer action.fulfill() until reportCallConnected when peer connection is ready
970
1344
  NSLog("TelnyxVoice: Deferring CXAnswerCallAction.fulfill() until peer connection is ready")
971
- self.pendingAnswerAction = action
1345
+ self.pendingAnswerActions[action.callUUID] = action
1346
+ }
1347
+
1348
+ public func provider(_ provider: CXProvider, perform action: CXSetHeldCallAction) {
1349
+ NSLog(
1350
+ "TelnyxVoice: User requested held=\(action.isOnHold) for UUID: \(action.callUUID)"
1351
+ )
1352
+
1353
+ guard activeCalls[action.callUUID] != nil else {
1354
+ NSLog("TelnyxVoice: Failing held action for unknown UUID: \(action.callUUID)")
1355
+ action.fail()
1356
+ return
1357
+ }
1358
+
1359
+ guard pendingHeldActions[action.callUUID] == nil else {
1360
+ NSLog("TelnyxVoice: Failing duplicate held action for UUID: \(action.callUUID)")
1361
+ action.fail()
1362
+ return
1363
+ }
1364
+
1365
+ pendingHeldActions[action.callUUID] = action
1366
+ let emitted = CallKitBridge.shared?.emitHeldCallEvent(
1367
+ callUUID: action.callUUID,
1368
+ isOnHold: action.isOnHold,
1369
+ callData: activeCalls[action.callUUID]
1370
+ ) ?? false
1371
+
1372
+ guard emitted else {
1373
+ pendingHeldActions.removeValue(forKey: action.callUUID)
1374
+ action.fail()
1375
+ return
1376
+ }
1377
+
1378
+ DispatchQueue.main.asyncAfter(deadline: .now() + 10) { [weak self, weak action] in
1379
+ guard let self = self, let action = action,
1380
+ self.pendingHeldActions[action.callUUID] === action
1381
+ else { return }
1382
+
1383
+ NSLog("TelnyxVoice: Held action timed out for UUID: \(action.callUUID)")
1384
+ self.pendingHeldActions.removeValue(forKey: action.callUUID)
1385
+ action.fail()
1386
+ }
972
1387
  }
973
1388
 
974
1389
  public func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
975
1390
  NSLog("TelnyxVoice: User ended call with UUID: \(action.callUUID)")
976
1391
 
1392
+ guard let callData = activeCalls[action.callUUID] else {
1393
+ NSLog("TelnyxVoice: Failing end action for unknown UUID: \(action.callUUID)")
1394
+ action.fail()
1395
+ return
1396
+ }
1397
+ if UserDefaults.standard.string(forKey: "pending_callkit_answer") == action.callUUID.uuidString {
1398
+ UserDefaults.standard.removeObject(forKey: "pending_callkit_answer")
1399
+ UserDefaults.standard.synchronize()
1400
+ }
1401
+
977
1402
  // Notify React Native via CallKit bridge
978
1403
  CallKitBridge.shared?.emitCallEvent(
979
1404
  "CallKitDidPerformEndCallAction", callUUID: action.callUUID,
980
- callData: activeCalls[action.callUUID])
1405
+ callData: callData)
981
1406
 
982
1407
  activeCalls.removeValue(forKey: action.callUUID)
1408
+ pendingAnswerActions.removeValue(forKey: action.callUUID)?.fail()
1409
+ pendingHeldActions.removeValue(forKey: action.callUUID)?.fail()
983
1410
  NSLog("📞 TelnyxVoice: Fulfilling CXEndCallAction for call UUID: \(action.callUUID)")
984
1411
  action.fulfill()
985
1412
  NSLog("📞 TelnyxVoice: ✅ CXEndCallAction fulfilled successfully")
@@ -993,11 +1420,26 @@ import React
993
1420
  "CallKitDidReceiveStartCallAction", callUUID: action.callUUID,
994
1421
  callData: activeCalls[action.callUUID])
995
1422
 
1423
+ let callUpdate = CXCallUpdate()
1424
+ callUpdate.remoteHandle = action.handle
1425
+ callUpdate.hasVideo = false
1426
+ callUpdate.supportsHolding = true
1427
+ provider.reportCall(with: action.callUUID, updated: callUpdate)
1428
+
996
1429
  NSLog("📞 TelnyxVoice: Fulfilling CXStartCallAction for call UUID: \(action.callUUID)")
997
1430
  action.fulfill()
998
1431
  NSLog("📞 TelnyxVoice: ✅ CXStartCallAction fulfilled successfully")
999
1432
  }
1000
1433
 
1434
+ public func provider(_ provider: CXProvider, timedOutPerforming action: CXAction) {
1435
+ if let answerAction = action as? CXAnswerCallAction {
1436
+ pendingAnswerActions.removeValue(forKey: answerAction.callUUID)
1437
+ } else if let heldAction = action as? CXSetHeldCallAction {
1438
+ pendingHeldActions.removeValue(forKey: heldAction.callUUID)
1439
+ }
1440
+ action.fail()
1441
+ }
1442
+
1001
1443
  public func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
1002
1444
  NSLog("TelnyxVoice: Audio session activated by CallKit")
1003
1445
 
@@ -1084,6 +1526,33 @@ import React
1084
1526
  super.init()
1085
1527
  }
1086
1528
 
1529
+ private func storePendingVoipPush(_ payload: [AnyHashable: Any]) {
1530
+ do {
1531
+ let jsonData = try JSONSerialization.data(withJSONObject: payload)
1532
+ let jsonString = String(data: jsonData, encoding: .utf8) ?? ""
1533
+
1534
+ UserDefaults.standard.set("incoming_call", forKey: "pending_push_action")
1535
+ UserDefaults.standard.set(jsonString, forKey: "pending_push_metadata")
1536
+
1537
+ let voipPushData = [
1538
+ "payload": payload
1539
+ ]
1540
+ let voipJsonData = try JSONSerialization.data(withJSONObject: voipPushData)
1541
+ let voipJsonString = String(data: voipJsonData, encoding: .utf8) ?? ""
1542
+ UserDefaults.standard.set(voipJsonString, forKey: "pending_voip_push")
1543
+ UserDefaults.standard.synchronize()
1544
+ NSLog(
1545
+ "[TelnyxVoipPushHandler] Stored VoIP push data after CallKit registration succeeded"
1546
+ )
1547
+ } catch {
1548
+ NSLog("[TelnyxVoipPushHandler] Error converting VoIP payload to JSON: \(error)")
1549
+ UserDefaults.standard.set("incoming_call", forKey: "pending_push_action")
1550
+ UserDefaults.standard.set("{}", forKey: "pending_push_metadata")
1551
+ UserDefaults.standard.set("{\"payload\":{}}", forKey: "pending_voip_push")
1552
+ UserDefaults.standard.synchronize()
1553
+ }
1554
+ }
1555
+
1087
1556
  /**
1088
1557
  * Initialize VoIP push registration
1089
1558
  * Call this from the main app's AppDelegate didFinishLaunchingWithOptions
@@ -1132,35 +1601,6 @@ import React
1132
1601
  } catch {
1133
1602
  NSLog("Failed to activate audio session: \(error)")
1134
1603
  }
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
1604
 
1165
1605
  // Extract caller information and call_id from the payload
1166
1606
  var callerName = "Unknown Caller"
@@ -1181,12 +1621,20 @@ import React
1181
1621
  callId = payload.dictionaryPayload["call_id"] as? String
1182
1622
  }
1183
1623
 
1184
- // Use call_id as CallKit UUID to ensure matching with WebRTC
1624
+ // Use call_id as CallKit UUID to ensure matching with WebRTC.
1185
1625
  guard let callIdString = callId, let callUUID = UUID(uuidString: callIdString) else {
1186
1626
  NSLog(
1187
- "[TelnyxVoipPushHandler] ❌ No valid call_id found in payload, cannot process call"
1627
+ "[TelnyxVoipPushHandler] ⚠️ Invalid or missing call_id in payload (\(callId ?? "nil")); reporting failed watchdog placeholder"
1628
+ )
1629
+ TelnyxCallKitManager.shared.reportAndEndWatchdogCall(
1630
+ callUUID: UUID(),
1631
+ callerName: callerName,
1632
+ callerNumber: callerNumber,
1633
+ payload: payload.dictionaryPayload,
1634
+ source: "malformed_push_watchdog",
1635
+ endedReason: .failed,
1636
+ completion: completion
1188
1637
  )
1189
- completion()
1190
1638
  return
1191
1639
  }
1192
1640
 
@@ -1202,16 +1650,7 @@ import React
1202
1650
 
1203
1651
  // CRITICAL: Setup CallKit SYNCHRONOUSLY - no async dispatch allowed
1204
1652
  // 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
- }
1653
+ let callKitProvider = callKitManager.setupSynchronously()
1215
1654
 
1216
1655
  NSLog("[TelnyxVoipPushHandler] ✅ CallKit provider ready, reporting incoming call")
1217
1656
 
@@ -1225,6 +1664,7 @@ import React
1225
1664
  "uuid": callUUID.uuidString,
1226
1665
  "direction": "incoming",
1227
1666
  "source": isAppRunning ? "push_notification" : "terminated_app_push",
1667
+ "isCallKitRegistered": false,
1228
1668
  ]
1229
1669
 
1230
1670
  // Report to CallKit immediately
@@ -1233,18 +1673,27 @@ import React
1233
1673
  callUpdate.remoteHandle = handle
1234
1674
  callUpdate.hasVideo = false
1235
1675
  callUpdate.localizedCallerName = callerName
1676
+ callUpdate.supportsHolding = true
1236
1677
 
1237
1678
  callKitProvider.reportNewIncomingCall(with: callUUID, update: callUpdate) { error in
1238
1679
  if let error = error {
1680
+ let nsError = error as NSError
1681
+ let reason =
1682
+ nsError.domain == CXErrorDomainIncomingCall && nsError.code == 3
1683
+ ? "filteredByDoNotDisturb" : "registrationRejected"
1239
1684
  NSLog(
1240
- "[TelnyxVoipPushHandler] ❌ CallKit error during terminated app handling: \(error.localizedDescription)"
1685
+ "[TelnyxVoipPushHandler] CallKit incoming registration rejected: domain=\(nsError.domain), code=\(nsError.code), reason=\(reason)"
1241
1686
  )
1242
1687
  callKitManager.activeCalls.removeValue(forKey: callUUID)
1688
+ callKitManager.clearPendingPushData(for: callUUID.uuidString)
1243
1689
  } else {
1244
1690
  NSLog(
1245
1691
  "[TelnyxVoipPushHandler] ✅ CallKit call reported successfully via unified path"
1246
1692
  )
1247
1693
 
1694
+ callKitManager.markCallKitRegistered(callUUID)
1695
+ self.storePendingVoipPush(payload.dictionaryPayload)
1696
+
1248
1697
  // CRITICAL: Store the CallKit UUID for the React Native CallKitCoordinator
1249
1698
  // This allows the coordinator to find the existing CallKit call instead of creating a duplicate
1250
1699
  UserDefaults.standard.set(callUUID.uuidString, forKey: "@pending_callkit_uuid")