@telnyx/react-voice-commons-sdk 0.4.3 → 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 (47) 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/TelnyxFirebaseMessagingService.kt +16 -2
  5. package/android/src/main/java/com/telnyx/react_voice_commons/TelnyxNotificationHelper.kt +345 -24
  6. package/android/src/main/java/com/telnyx/react_voice_commons/VoicePnBridgeModule.kt +74 -1
  7. package/android/src/main/java/com/telnyx/react_voice_commons/VoicePnManager.kt +36 -1
  8. package/ios/CallKitBridge.m +20 -1
  9. package/ios/CallKitBridge.swift +935 -60
  10. package/ios/README.md +2 -2
  11. package/ios/VoicePnBridge.m +21 -1
  12. package/ios/VoicePnBridge.swift +62 -1
  13. package/lib/callkit/callkit-coordinator.d.ts +39 -3
  14. package/lib/callkit/callkit-coordinator.js +561 -148
  15. package/lib/callkit/callkit.d.ts +7 -0
  16. package/lib/callkit/callkit.js +83 -11
  17. package/lib/hooks/useAppStateHandler.js +6 -2
  18. package/lib/index.d.ts +1 -0
  19. package/lib/index.js +9 -0
  20. package/lib/internal/CallKitHandler.d.ts +3 -0
  21. package/lib/internal/CallKitHandler.js +52 -46
  22. package/lib/internal/calls/call-state-controller.d.ts +39 -1
  23. package/lib/internal/calls/call-state-controller.js +152 -64
  24. package/lib/internal/session/session-manager.d.ts +16 -1
  25. package/lib/internal/session/session-manager.js +229 -58
  26. package/lib/internal/voice-pn-bridge.d.ts +44 -0
  27. package/lib/internal/voice-pn-bridge.js +94 -0
  28. package/lib/models/call.js +35 -0
  29. package/lib/models/config.d.ts +18 -0
  30. package/lib/models/config.js +2 -0
  31. package/lib/telnyx-voice-app.js +85 -43
  32. package/lib/telnyx-voip-client.d.ts +48 -5
  33. package/lib/telnyx-voip-client.js +276 -22
  34. package/package.json +7 -5
  35. package/src/callkit/callkit-coordinator.ts +642 -160
  36. package/src/callkit/callkit.ts +110 -12
  37. package/src/hooks/useAppStateHandler.ts +7 -2
  38. package/src/index.ts +3 -0
  39. package/src/internal/CallKitHandler.tsx +60 -55
  40. package/src/internal/calls/call-state-controller.ts +173 -74
  41. package/src/internal/session/session-manager.ts +277 -61
  42. package/src/internal/voice-pn-bridge.ts +111 -0
  43. package/src/models/call.ts +34 -0
  44. package/src/models/config.ts +18 -0
  45. package/src/telnyx-voice-app.tsx +96 -45
  46. package/src/telnyx-voip-client.ts +260 -24
  47. package/src/types/telnyx-sdk.d.ts +12 -1
@@ -6,8 +6,52 @@ import React
6
6
  import AVFoundation
7
7
  import UIKit
8
8
  import PushKit
9
+ import UserNotifications
9
10
  import WebRTC
10
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
+
11
55
  @objc(CallKitBridge)
12
56
  class CallKitBridge: RCTEventEmitter {
13
57
 
@@ -28,6 +72,7 @@ import React
28
72
  "CallKitDidReceiveStartCallAction",
29
73
  "CallKitDidPerformAnswerCallAction",
30
74
  "CallKitDidPerformEndCallAction",
75
+ "CallKitDidPerformHeldCallAction",
31
76
  "CallKitDidReceivePush",
32
77
  "AudioSessionActivated",
33
78
  "AudioSessionDeactivated",
@@ -49,8 +94,9 @@ import React
49
94
  }
50
95
 
51
96
  // Direct event emission methods called by TelnyxVoiceAppDelegate
52
- public func emitCallEvent(_ eventName: String, callUUID: UUID, callData: [String: Any]?) {
53
- guard hasListeners else { return }
97
+ @discardableResult
98
+ public func emitCallEvent(_ eventName: String, callUUID: UUID, callData: [String: Any]?) -> Bool {
99
+ guard hasListeners else { return false }
54
100
 
55
101
  let eventData: [String: Any] = [
56
102
  "callUUID": callUUID.uuidString,
@@ -58,6 +104,22 @@ import React
58
104
  ]
59
105
 
60
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
61
123
  }
62
124
 
63
125
  // Event emission method for audio session changes
@@ -89,8 +151,17 @@ import React
89
151
  let startCallAction = CXStartCallAction(call: uuid, handle: callHandle)
90
152
  let transaction = CXTransaction(action: startCallAction)
91
153
 
154
+ manager.activeCalls[uuid] = [
155
+ "caller": displayName,
156
+ "handle": handle,
157
+ "uuid": uuid.uuidString,
158
+ "direction": "outgoing",
159
+ "source": "react_native",
160
+ ]
161
+
92
162
  callController.request(transaction) { error in
93
163
  if let error = error {
164
+ manager.activeCalls.removeValue(forKey: uuid)
94
165
  reject("START_CALL_ERROR", error.localizedDescription, error)
95
166
  } else {
96
167
  resolve(["success": true, "callUUID": callUUID])
@@ -119,11 +190,23 @@ import React
119
190
  callUpdate.remoteHandle = callHandle
120
191
  callUpdate.hasVideo = false
121
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
+ ]
122
203
 
123
204
  provider.reportNewIncomingCall(with: uuid, update: callUpdate) { error in
124
205
  if let error = error {
206
+ manager.activeCalls.removeValue(forKey: uuid)
125
207
  reject("INCOMING_CALL_ERROR", error.localizedDescription, error)
126
208
  } else {
209
+ manager.markCallKitRegistered(uuid)
127
210
  resolve(["success": true, "callUUID": callUUID])
128
211
  }
129
212
  }
@@ -156,6 +239,127 @@ import React
156
239
  }
157
240
  }
158
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
+
159
363
  @objc func answerCall(
160
364
  _ callUUID: String, resolver resolve: @escaping RCTPromiseResolveBlock,
161
365
  rejecter reject: @escaping RCTPromiseRejectBlock
@@ -171,6 +375,15 @@ import React
171
375
  return
172
376
  }
173
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
+
174
387
  // Mark this call as already answered to prevent duplicate emission
175
388
  if var callData = manager.activeCalls[uuid] {
176
389
  callData["isAlreadyAnswered"] = true
@@ -189,6 +402,22 @@ import React
189
402
  }
190
403
  }
191
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
+
192
421
  @objc func reportCallConnected(
193
422
  _ callUUID: String, resolver resolve: @escaping RCTPromiseResolveBlock,
194
423
  rejecter reject: @escaping RCTPromiseRejectBlock
@@ -207,10 +436,9 @@ import React
207
436
  provider.reportOutgoingCall(with: uuid, connectedAt: Date())
208
437
 
209
438
  // Fulfill deferred CXAnswerCallAction now that peer connection is ready
210
- if let pendingAction = manager.pendingAnswerAction {
439
+ if let pendingAction = manager.pendingAnswerActions.removeValue(forKey: uuid) {
211
440
  NSLog("TelnyxVoice: reportCallConnected - fulfilling deferred CXAnswerCallAction")
212
441
  pendingAction.fulfill()
213
- manager.pendingAnswerAction = nil
214
442
  } else {
215
443
  // Fallback: ensure audio is enabled for non-push or already-fulfilled cases
216
444
  let rtcAudioSession = RTCAudioSession.sharedInstance()
@@ -253,6 +481,9 @@ import React
253
481
 
254
482
  let endReason = CXCallEndedReason(rawValue: reason.intValue) ?? .remoteEnded
255
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()
256
487
  resolve(["success": true])
257
488
  }
258
489
 
@@ -285,12 +516,217 @@ import React
285
516
  let callUpdate = CXCallUpdate()
286
517
  callUpdate.remoteHandle = callHandle
287
518
  callUpdate.localizedCallerName = displayName
519
+ callUpdate.supportsHolding = true
288
520
 
289
521
  provider.reportCall(with: uuid, updated: callUpdate)
290
522
  resolve(["success": true])
291
523
  }
292
524
  }
293
525
 
526
+ // MARK: - Missed Call
527
+ enum TelnyxVoiceUserDefaultsKey {
528
+ static let missedCallNotificationsEnabled = "telnyx_enable_missed_call_notifications"
529
+ }
530
+
531
+ private enum TelnyxMissedCallPush {
532
+ static let processedMissedCallIdsKey = "telnyx_processed_missed_call_ids"
533
+ private static let missedCallMarkerKeys: Set<String> = [
534
+ "is_missed_call",
535
+ "message",
536
+ "missed",
537
+ "missed_call",
538
+ "notification_type",
539
+ "push_type",
540
+ "type",
541
+ ]
542
+ private static let affirmativeMissedCallValues: Set<String> = [
543
+ "1",
544
+ "missed",
545
+ "missed call",
546
+ "true",
547
+ "yes",
548
+ ]
549
+
550
+ static func isMissedCallPayload(_ payload: [AnyHashable: Any]) -> Bool {
551
+ return containsMissedCallMarker(payload)
552
+ }
553
+
554
+ static func stableIdentifier(from payload: [AnyHashable: Any]) -> String {
555
+ if let callId = callId(from: payload), !callId.isEmpty {
556
+ return callId
557
+ }
558
+
559
+ return "payload-\(hashHex(canonicalString(from: payload)))"
560
+ }
561
+
562
+ static func uuid(from identifier: String) -> UUID {
563
+ if let uuid = UUID(uuidString: identifier) {
564
+ return uuid
565
+ }
566
+
567
+ let firstHash = hashHex(identifier)
568
+ let secondHash = hashHex("telnyx-\(identifier)")
569
+ let uuidString =
570
+ "\(firstHash.prefix(8))-\(firstHash.dropFirst(8).prefix(4))-\(firstHash.dropFirst(12).prefix(4))-\(secondHash.prefix(4))-\(secondHash.dropFirst(4).prefix(12))"
571
+
572
+ return UUID(uuidString: uuidString) ?? UUID()
573
+ }
574
+
575
+ static func callId(from payload: [AnyHashable: Any]) -> String? {
576
+ return stringValue(for: ["call_id", "callId"], in: payload)
577
+ }
578
+
579
+ static func callerName(from payload: [AnyHashable: Any]) -> String {
580
+ return stringValue(for: ["caller_name", "caller", "from"], in: payload)
581
+ ?? callerNumber(from: payload)
582
+ }
583
+
584
+ static func callerNumber(from payload: [AnyHashable: Any]) -> String {
585
+ return stringValue(for: ["caller_number", "caller", "from"], in: payload)
586
+ ?? "Unknown Caller"
587
+ }
588
+
589
+ static func missedCallNotificationsEnabled() -> Bool {
590
+ // bool(forKey:) returns false for both "unset" and "false"; default to enabled
591
+ // unless the app explicitly stores a preference.
592
+ guard
593
+ UserDefaults.standard.object(
594
+ forKey: TelnyxVoiceUserDefaultsKey.missedCallNotificationsEnabled) != nil
595
+ else {
596
+ return true
597
+ }
598
+ return UserDefaults.standard.bool(
599
+ forKey: TelnyxVoiceUserDefaultsKey.missedCallNotificationsEnabled)
600
+ }
601
+
602
+ private static func stringValue(for keys: [String], in payload: [AnyHashable: Any]) -> String? {
603
+ if let metadata = payload["metadata"] as? [AnyHashable: Any],
604
+ let value = firstStringValue(for: keys, in: metadata)
605
+ {
606
+ return value
607
+ }
608
+
609
+ if let metadata = payload["metadata"] as? [String: Any],
610
+ let value = firstStringValue(for: keys, in: metadata)
611
+ {
612
+ return value
613
+ }
614
+
615
+ return firstStringValue(for: keys, in: payload)
616
+ }
617
+
618
+ private static func firstStringValue(for keys: [String], in dictionary: [AnyHashable: Any])
619
+ -> String?
620
+ {
621
+ for key in keys {
622
+ if let value = dictionary[key] as? String, !value.isEmpty {
623
+ return value
624
+ }
625
+ }
626
+ return nil
627
+ }
628
+
629
+ private static func firstStringValue(for keys: [String], in dictionary: [String: Any])
630
+ -> String?
631
+ {
632
+ for key in keys {
633
+ if let value = dictionary[key] as? String, !value.isEmpty {
634
+ return value
635
+ }
636
+ }
637
+ return nil
638
+ }
639
+
640
+ private static func containsMissedCallMarker(_ value: Any, key: String? = nil) -> Bool {
641
+ if let key = key, missedCallMarkerKeys.contains(normalizedPayloadKey(key)) {
642
+ if let boolValue = value as? Bool {
643
+ return boolValue
644
+ }
645
+
646
+ if let stringValue = value as? String {
647
+ return affirmativeMissedCallValues.contains(normalizedMissedCallValue(stringValue))
648
+ }
649
+ }
650
+
651
+ if let stringValue = value as? String {
652
+ let normalized = normalizedMissedCallValue(stringValue)
653
+ if normalized == "missed call" || normalized == "missed" {
654
+ return true
655
+ }
656
+ }
657
+
658
+ if let dictionary = value as? [AnyHashable: Any] {
659
+ return dictionary.contains { rawKey, nestedValue in
660
+ containsMissedCallMarker(nestedValue, key: String(describing: rawKey))
661
+ }
662
+ }
663
+
664
+ if let dictionary = value as? [String: Any] {
665
+ return dictionary.contains { key, nestedValue in
666
+ containsMissedCallMarker(nestedValue, key: key)
667
+ }
668
+ }
669
+
670
+ if let array = value as? [Any] {
671
+ return array.contains { containsMissedCallMarker($0) }
672
+ }
673
+
674
+ return false
675
+ }
676
+
677
+ private static func normalizedPayloadKey(_ key: String) -> String {
678
+ return key
679
+ .lowercased()
680
+ .replacingOccurrences(of: "-", with: "_")
681
+ .replacingOccurrences(of: " ", with: "_")
682
+ .trimmingCharacters(in: .whitespacesAndNewlines)
683
+ }
684
+
685
+ private static func normalizedMissedCallValue(_ value: String) -> String {
686
+ return value
687
+ .lowercased()
688
+ .replacingOccurrences(of: "_", with: " ")
689
+ .replacingOccurrences(of: "-", with: " ")
690
+ .replacingOccurrences(of: "!", with: "")
691
+ .trimmingCharacters(in: .whitespacesAndNewlines)
692
+ }
693
+
694
+ private static func canonicalString(from value: Any) -> String {
695
+ if let dictionary = value as? [AnyHashable: Any] {
696
+ return dictionary
697
+ .map { (String(describing: $0.key), $0.value) }
698
+ .sorted { $0.0 < $1.0 }
699
+ .map { "\($0):\(canonicalString(from: $1))" }
700
+ .joined(separator: "|")
701
+ }
702
+
703
+ if let dictionary = value as? [String: Any] {
704
+ return dictionary
705
+ .sorted { $0.key < $1.key }
706
+ .map { "\($0):\(canonicalString(from: $1))" }
707
+ .joined(separator: "|")
708
+ }
709
+
710
+ if let array = value as? [Any] {
711
+ return array.map { canonicalString(from: $0) }.joined(separator: ",")
712
+ }
713
+
714
+ return String(describing: value)
715
+ }
716
+
717
+ private static func hashHex(_ value: String) -> String {
718
+ let prime: UInt64 = 1_099_511_628_211
719
+ var hash: UInt64 = 14_695_981_039_346_656_037
720
+
721
+ for byte in value.utf8 {
722
+ hash ^= UInt64(byte)
723
+ hash = hash &* prime
724
+ }
725
+
726
+ return String(format: "%016llx", hash)
727
+ }
728
+ }
729
+
294
730
  // MARK: - TelnyxCallKitManager
295
731
  /// TelnyxCallKitManager - Automatically handles CallKit integration for Telnyx Voice
296
732
  /// This class sets up CallKit and VoIP push notifications automatically when the app starts
@@ -302,8 +738,33 @@ import React
302
738
  public var voipRegistry: PKPushRegistry?
303
739
  public var callKitProvider: CXProvider?
304
740
  public var callKitController: CXCallController?
305
- public var activeCalls: [UUID: [String: Any]] = [:]
306
- 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
+ }
307
768
 
308
769
  private override init() {
309
770
  super.init()
@@ -324,18 +785,27 @@ import React
324
785
  }
325
786
  }
326
787
 
327
- public func setupSynchronously() {
788
+ @discardableResult
789
+ public func setupSynchronously() -> CXProvider {
328
790
  // Only initialize once
329
- guard callKitProvider == nil else {
791
+ if let callKitProvider = callKitProvider {
330
792
  NSLog("TelnyxVoice: CallKit already setup, skipping")
331
- return
793
+ return callKitProvider
332
794
  }
333
795
 
334
796
  // CRITICAL: Setup CallKit synchronously for terminated app VoIP push handling
335
797
  // This MUST happen in the same run loop as the VoIP push
336
798
  NSLog("TelnyxVoice: Synchronous CallKit setup for terminated app scenario...")
337
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()
338
807
  NSLog("TelnyxVoice: ✅ CallKit provider ready for terminated app handling")
808
+ return callKitProvider
339
809
  }
340
810
 
341
811
  private func setupAutomatically() {
@@ -366,6 +836,10 @@ import React
366
836
  return
367
837
  }
368
838
 
839
+ _ = installCallKitProvider()
840
+ }
841
+
842
+ private func installCallKitProvider() -> CXProvider {
369
843
  // Use the localizedName from the app's bundle display name or fallback
370
844
  let appName =
371
845
  Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
@@ -374,19 +848,66 @@ import React
374
848
 
375
849
  let configuration = CXProviderConfiguration(localizedName: appName)
376
850
  configuration.supportsVideo = false
377
- configuration.maximumCallGroups = 1
851
+ configuration.maximumCallGroups = 2
378
852
  configuration.maximumCallsPerCallGroup = 1
379
853
  configuration.supportedHandleTypes = [.phoneNumber, .generic]
380
854
  configuration.includesCallsInRecents = true
381
855
 
382
- callKitProvider = CXProvider(configuration: configuration)
383
- callKitProvider?.setDelegate(self, queue: nil)
856
+ let provider = CXProvider(configuration: configuration)
857
+ provider.setDelegate(self, queue: nil)
858
+ callKitProvider = provider
384
859
  callKitController = CXCallController()
385
860
 
386
861
  NSLog("TelnyxVoice: CallKit provider and controller created")
862
+ return provider
387
863
  }
388
864
 
389
-
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
+ }
390
911
 
391
912
  private func observeAppDelegate() {
392
913
  // Automatically hook into app lifecycle if needed
@@ -404,6 +925,243 @@ import React
404
925
  setupCallKit()
405
926
  }
406
927
  }
928
+
929
+ @discardableResult
930
+ public func handleMissedCallPushIfNeeded(
931
+ _ payload: [AnyHashable: Any],
932
+ completion: (() -> Void)? = nil
933
+ ) -> Bool {
934
+ guard TelnyxMissedCallPush.isMissedCallPayload(payload) else {
935
+ return false
936
+ }
937
+
938
+ NSLog("TelnyxVoice: Missed call VoIP push received: \(payload)")
939
+ let callKitProvider = setupSynchronously()
940
+
941
+ let callId = TelnyxMissedCallPush.callId(from: payload)
942
+ let missedCallId = TelnyxMissedCallPush.stableIdentifier(from: payload)
943
+ let callUUID = TelnyxMissedCallPush.uuid(from: missedCallId)
944
+ let callerName = TelnyxMissedCallPush.callerName(from: payload)
945
+ let callerNumber = TelnyxMissedCallPush.callerNumber(from: payload)
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
+
961
+ let finishMissedCall = {
962
+ callKitProvider.reportCall(with: callUUID, endedAt: Date(), reason: .remoteEnded)
963
+ self.activeCalls.removeValue(forKey: callUUID)
964
+
965
+ self.pendingAnswerActions.removeValue(forKey: callUUID)?.fail()
966
+ self.pendingHeldActions.removeValue(forKey: callUUID)?.fail()
967
+
968
+ NSLog("TelnyxVoice: Ended stale CallKit call for missed call UUID: \(callUUID)")
969
+ self.clearPendingPushData(for: callId)
970
+ self.showMissedCallNotification(
971
+ callerName: callerName,
972
+ callerNumber: callerNumber,
973
+ missedCallId: missedCallId
974
+ )
975
+ self.markMissedCallProcessed(id: missedCallId)
976
+ completion?()
977
+ }
978
+
979
+ if let activeCall = activeCalls[callUUID] {
980
+ guard activeCall["source"] as? String == "missed_call_push" else {
981
+ NSLog("TelnyxVoice: Suppressing missed call notification because UUID belongs to an active call: \(callUUID)")
982
+ markMissedCallProcessed(id: missedCallId)
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
+ )
992
+ return true
993
+ }
994
+
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
+ }
1005
+ return true
1006
+ }
1007
+
1008
+ activeCalls[callUUID] = [
1009
+ "caller": callerName,
1010
+ "handle": callerNumber,
1011
+ "payload": payload,
1012
+ "uuid": callUUID.uuidString,
1013
+ "direction": "incoming",
1014
+ "source": "missed_call_push",
1015
+ ]
1016
+
1017
+ let handle = CXHandle(type: .phoneNumber, value: callerNumber)
1018
+ let callUpdate = CXCallUpdate()
1019
+ callUpdate.remoteHandle = handle
1020
+ callUpdate.hasVideo = false
1021
+ callUpdate.localizedCallerName = callerName
1022
+ callUpdate.supportsHolding = true
1023
+
1024
+ callKitProvider.reportNewIncomingCall(with: callUUID, update: callUpdate) { error in
1025
+ if let error = error {
1026
+ NSLog("TelnyxVoice: Missed call CallKit report failed: \(error.localizedDescription)")
1027
+ callKitProvider.reportCall(with: callUUID, endedAt: Date(), reason: .failed)
1028
+ self.activeCalls.removeValue(forKey: callUUID)
1029
+ self.clearPendingPushData(for: callId)
1030
+ completion?()
1031
+ return
1032
+ }
1033
+
1034
+ finishMissedCall()
1035
+ }
1036
+
1037
+ return true
1038
+ }
1039
+
1040
+ fileprivate func clearPendingPushData(for callId: String?) {
1041
+ if hasPendingPushData(forDifferentCallThan: callId) {
1042
+ NSLog("TelnyxVoice: Skipping pending push cleanup for missed call; pending data belongs to a different call")
1043
+ return
1044
+ }
1045
+
1046
+ UserDefaults.standard.removeObject(forKey: "pending_push_action")
1047
+ UserDefaults.standard.removeObject(forKey: "pending_push_metadata")
1048
+ UserDefaults.standard.removeObject(forKey: "pending_voip_push")
1049
+ UserDefaults.standard.removeObject(forKey: "pending_voip_action")
1050
+ UserDefaults.standard.removeObject(forKey: "@pending_callkit_uuid")
1051
+ UserDefaults.standard.synchronize()
1052
+ }
1053
+
1054
+ private func hasPendingPushData(forDifferentCallThan missedCallId: String?) -> Bool {
1055
+ guard let missedCallId = missedCallId, !missedCallId.isEmpty else {
1056
+ return false
1057
+ }
1058
+
1059
+ let pendingIds = [
1060
+ pendingCallId(fromJsonForKey: "pending_push_metadata"),
1061
+ pendingCallId(fromJsonForKey: "pending_voip_push"),
1062
+ UserDefaults.standard.string(forKey: "@pending_callkit_uuid"),
1063
+ ].compactMap { $0 }
1064
+
1065
+ return pendingIds.contains {
1066
+ !$0.isEmpty && $0.caseInsensitiveCompare(missedCallId) != .orderedSame
1067
+ }
1068
+ }
1069
+
1070
+ private func pendingCallId(fromJsonForKey key: String) -> String? {
1071
+ guard let jsonString = UserDefaults.standard.string(forKey: key),
1072
+ let data = jsonString.data(using: .utf8),
1073
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
1074
+ else {
1075
+ return nil
1076
+ }
1077
+
1078
+ if let directCallId = callId(from: json) {
1079
+ return directCallId
1080
+ }
1081
+
1082
+ if let payload = json["payload"] as? [String: Any] {
1083
+ return callId(from: payload)
1084
+ }
1085
+
1086
+ return nil
1087
+ }
1088
+
1089
+ private func callId(from dictionary: [String: Any]) -> String? {
1090
+ for key in ["call_id", "callId", "telnyx_call_id"] {
1091
+ if let value = dictionary[key] as? String, !value.isEmpty {
1092
+ return value
1093
+ }
1094
+ }
1095
+
1096
+ if let metadata = dictionary["metadata"] as? [String: Any] {
1097
+ return callId(from: metadata)
1098
+ }
1099
+
1100
+ return nil
1101
+ }
1102
+
1103
+ private func hasProcessedMissedCall(id: String) -> Bool {
1104
+ return processedMissedCallIds().contains(id)
1105
+ }
1106
+
1107
+ private func markMissedCallProcessed(id: String) {
1108
+ var ids = processedMissedCallIds().filter { $0 != id }
1109
+ ids.append(id)
1110
+ UserDefaults.standard.set(Array(ids.suffix(50)), forKey: TelnyxMissedCallPush.processedMissedCallIdsKey)
1111
+ UserDefaults.standard.synchronize()
1112
+ }
1113
+
1114
+ private func processedMissedCallIds() -> [String] {
1115
+ return UserDefaults.standard.stringArray(
1116
+ forKey: TelnyxMissedCallPush.processedMissedCallIdsKey) ?? []
1117
+ }
1118
+
1119
+ private func showMissedCallNotification(
1120
+ callerName: String, callerNumber: String, missedCallId: String
1121
+ ) {
1122
+ guard TelnyxMissedCallPush.missedCallNotificationsEnabled() else {
1123
+ NSLog("TelnyxVoice: Missed call local notification disabled")
1124
+ return
1125
+ }
1126
+
1127
+ let content = UNMutableNotificationContent()
1128
+ content.title = "Missed Call"
1129
+ content.body = "You missed a call from \(callerName)"
1130
+ content.sound = .default
1131
+ content.userInfo = [
1132
+ "call_id": missedCallId,
1133
+ "caller_name": callerName,
1134
+ "caller_number": callerNumber,
1135
+ ]
1136
+
1137
+ let identifier = "telnyx.missed-call.\(missedCallId)"
1138
+ let request = UNNotificationRequest(
1139
+ identifier: identifier,
1140
+ content: content,
1141
+ trigger: nil
1142
+ )
1143
+
1144
+ let notificationCenter = UNUserNotificationCenter.current()
1145
+ notificationCenter.getNotificationSettings { settings in
1146
+ guard settings.authorizationStatus == .authorized
1147
+ || settings.authorizationStatus == .provisional
1148
+ || settings.authorizationStatus == .ephemeral
1149
+ else {
1150
+ NSLog(
1151
+ "TelnyxVoice: Missed call notification not shown; notification authorization status: \(settings.authorizationStatus.rawValue)"
1152
+ )
1153
+ return
1154
+ }
1155
+
1156
+ notificationCenter.add(request) { error in
1157
+ if let error = error {
1158
+ NSLog("TelnyxVoice: Failed to show missed call notification: \(error)")
1159
+ } else {
1160
+ NSLog("TelnyxVoice: Missed call notification scheduled")
1161
+ }
1162
+ }
1163
+ }
1164
+ }
407
1165
  }
408
1166
 
409
1167
  // MARK: - PKPushRegistryDelegate
@@ -458,6 +1216,10 @@ import React
458
1216
  ) {
459
1217
  NSLog("TelnyxVoice: Received VoIP push notification: \(payload.dictionaryPayload)")
460
1218
 
1219
+ if handleMissedCallPushIfNeeded(payload.dictionaryPayload, completion: completion) {
1220
+ return
1221
+ }
1222
+
461
1223
  // Store the VoIP push data for VoicePnBridge (same format as AppDelegate was using)
462
1224
  do {
463
1225
  let jsonData = try JSONSerialization.data(withJSONObject: payload.dictionaryPayload)
@@ -499,6 +1261,7 @@ import React
499
1261
  "uuid": callUUID.uuidString,
500
1262
  "direction": "incoming",
501
1263
  "source": "push",
1264
+ "isCallKitRegistered": false,
502
1265
  ]
503
1266
 
504
1267
  let handle = CXHandle(type: .phoneNumber, value: caller)
@@ -506,6 +1269,7 @@ import React
506
1269
  callUpdate.remoteHandle = handle
507
1270
  callUpdate.hasVideo = false
508
1271
  callUpdate.localizedCallerName = caller
1272
+ callUpdate.supportsHolding = true
509
1273
 
510
1274
  callKitProvider?.reportNewIncomingCall(with: callUUID, update: callUpdate) {
511
1275
  [weak self] error in
@@ -513,6 +1277,7 @@ import React
513
1277
  NSLog("TelnyxVoice: CallKit error: \(error.localizedDescription)")
514
1278
  self?.activeCalls.removeValue(forKey: callUUID)
515
1279
  } else {
1280
+ self?.markCallKitRegistered(callUUID)
516
1281
  NSLog("TelnyxVoice: CallKit incoming call reported successfully")
517
1282
  }
518
1283
  completion()
@@ -527,12 +1292,35 @@ import React
527
1292
  NSLog("📞 TelnyxVoice: CALLKIT PROVIDER RESET - Provider: \(provider)")
528
1293
  NSLog("TelnyxVoice: CallKit provider reset - ending all active calls")
529
1294
  activeCalls.removeAll()
1295
+ pendingAnswerActions.values.forEach { $0.fail() }
1296
+ pendingAnswerActions.removeAll()
1297
+ pendingHeldActions.values.forEach { $0.fail() }
1298
+ pendingHeldActions.removeAll()
530
1299
  }
531
1300
 
532
1301
  public func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
533
1302
  NSLog("📞 TelnyxVoice: CALLKIT ANSWER ACTION - Provider: \(provider), Action: \(action)")
534
1303
  NSLog("TelnyxVoice: User answered call with UUID: \(action.callUUID)")
535
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
+
536
1324
  // Check if this is a programmatic answer (call already answered in WebRTC)
537
1325
  // vs a user answer from CallKit UI
538
1326
  if let callData = activeCalls[action.callUUID],
@@ -542,25 +1330,83 @@ import React
542
1330
  NSLog("TelnyxVoice: Call already answered in WebRTC, skipping emission")
543
1331
  } else {
544
1332
  // Notify React Native via CallKit bridge (only for user-initiated answers)
545
- CallKitBridge.shared?.emitCallEvent(
1333
+ let eventDelivered = CallKitBridge.shared?.emitCallEvent(
546
1334
  "CallKitDidPerformAnswerCallAction", callUUID: action.callUUID,
547
- 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
+ }
548
1341
  }
549
1342
 
550
1343
  // Defer action.fulfill() until reportCallConnected when peer connection is ready
551
1344
  NSLog("TelnyxVoice: Deferring CXAnswerCallAction.fulfill() until peer connection is ready")
552
- 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
+ }
553
1387
  }
554
1388
 
555
1389
  public func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
556
1390
  NSLog("TelnyxVoice: User ended call with UUID: \(action.callUUID)")
557
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
+
558
1402
  // Notify React Native via CallKit bridge
559
1403
  CallKitBridge.shared?.emitCallEvent(
560
1404
  "CallKitDidPerformEndCallAction", callUUID: action.callUUID,
561
- callData: activeCalls[action.callUUID])
1405
+ callData: callData)
562
1406
 
563
1407
  activeCalls.removeValue(forKey: action.callUUID)
1408
+ pendingAnswerActions.removeValue(forKey: action.callUUID)?.fail()
1409
+ pendingHeldActions.removeValue(forKey: action.callUUID)?.fail()
564
1410
  NSLog("📞 TelnyxVoice: Fulfilling CXEndCallAction for call UUID: \(action.callUUID)")
565
1411
  action.fulfill()
566
1412
  NSLog("📞 TelnyxVoice: ✅ CXEndCallAction fulfilled successfully")
@@ -574,11 +1420,26 @@ import React
574
1420
  "CallKitDidReceiveStartCallAction", callUUID: action.callUUID,
575
1421
  callData: activeCalls[action.callUUID])
576
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
+
577
1429
  NSLog("📞 TelnyxVoice: Fulfilling CXStartCallAction for call UUID: \(action.callUUID)")
578
1430
  action.fulfill()
579
1431
  NSLog("📞 TelnyxVoice: ✅ CXStartCallAction fulfilled successfully")
580
1432
  }
581
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
+
582
1443
  public func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
583
1444
  NSLog("TelnyxVoice: Audio session activated by CallKit")
584
1445
 
@@ -665,6 +1526,33 @@ import React
665
1526
  super.init()
666
1527
  }
667
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
+
668
1556
  /**
669
1557
  * Initialize VoIP push registration
670
1558
  * Call this from the main app's AppDelegate didFinishLaunchingWithOptions
@@ -698,6 +1586,13 @@ import React
698
1586
  NSLog("[TelnyxVoipPushHandler] VoIP push received for type: \(type.rawValue)")
699
1587
  NSLog("[TelnyxVoipPushHandler] VoIP payload: \(payload.dictionaryPayload)")
700
1588
 
1589
+ if TelnyxCallKitManager.shared.handleMissedCallPushIfNeeded(
1590
+ payload.dictionaryPayload,
1591
+ completion: completion
1592
+ ) {
1593
+ return
1594
+ }
1595
+
701
1596
  // Configure audio session early for VoIP call
702
1597
  let audioSession = AVAudioSession.sharedInstance()
703
1598
  do {
@@ -706,35 +1601,6 @@ import React
706
1601
  } catch {
707
1602
  NSLog("Failed to activate audio session: \(error)")
708
1603
  }
709
- // Store the VoIP push data for VoicePnBridge
710
- do {
711
- let jsonData = try JSONSerialization.data(withJSONObject: payload.dictionaryPayload)
712
- let jsonString = String(data: jsonData, encoding: .utf8) ?? ""
713
-
714
- // Store for TelnyxVoiceApp (push action flow)
715
- UserDefaults.standard.set("incoming_call", forKey: "pending_push_action")
716
- UserDefaults.standard.set(jsonString, forKey: "pending_push_metadata")
717
-
718
- // ALSO store for VoicePnBridge.getPendingVoipPush() (CallKit coordinator flow)
719
- // This creates a structured object with payload property
720
- let voipPushData = [
721
- "payload": payload.dictionaryPayload
722
- ]
723
- let voipJsonData = try JSONSerialization.data(withJSONObject: voipPushData)
724
- let voipJsonString = String(data: voipJsonData, encoding: .utf8) ?? ""
725
- UserDefaults.standard.set(voipJsonString, forKey: "pending_voip_push")
726
-
727
- UserDefaults.standard.synchronize()
728
- NSLog(
729
- "[TelnyxVoipPushHandler] Stored VoIP push data for both TelnyxVoiceApp and VoicePnBridge"
730
- )
731
- } catch {
732
- NSLog("[TelnyxVoipPushHandler] Error converting VoIP payload to JSON: \(error)")
733
- UserDefaults.standard.set("incoming_call", forKey: "pending_push_action")
734
- UserDefaults.standard.set("{}", forKey: "pending_push_metadata")
735
- UserDefaults.standard.set("{\"payload\":{}}", forKey: "pending_voip_push")
736
- UserDefaults.standard.synchronize()
737
- }
738
1604
 
739
1605
  // Extract caller information and call_id from the payload
740
1606
  var callerName = "Unknown Caller"
@@ -755,12 +1621,20 @@ import React
755
1621
  callId = payload.dictionaryPayload["call_id"] as? String
756
1622
  }
757
1623
 
758
- // Use call_id as CallKit UUID to ensure matching with WebRTC
1624
+ // Use call_id as CallKit UUID to ensure matching with WebRTC.
759
1625
  guard let callIdString = callId, let callUUID = UUID(uuidString: callIdString) else {
760
1626
  NSLog(
761
- "[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
762
1637
  )
763
- completion()
764
1638
  return
765
1639
  }
766
1640
 
@@ -776,16 +1650,7 @@ import React
776
1650
 
777
1651
  // CRITICAL: Setup CallKit SYNCHRONOUSLY - no async dispatch allowed
778
1652
  // This must happen in the same run loop as the VoIP push
779
- callKitManager.setupSynchronously()
780
-
781
- // Ensure we have a valid CallKit provider after setup
782
- guard let callKitProvider = callKitManager.callKitProvider else {
783
- NSLog(
784
- "[TelnyxVoipPushHandler] ❌ FATAL: CallKit provider not available after synchronous setup!"
785
- )
786
- completion()
787
- return
788
- }
1653
+ let callKitProvider = callKitManager.setupSynchronously()
789
1654
 
790
1655
  NSLog("[TelnyxVoipPushHandler] ✅ CallKit provider ready, reporting incoming call")
791
1656
 
@@ -799,6 +1664,7 @@ import React
799
1664
  "uuid": callUUID.uuidString,
800
1665
  "direction": "incoming",
801
1666
  "source": isAppRunning ? "push_notification" : "terminated_app_push",
1667
+ "isCallKitRegistered": false,
802
1668
  ]
803
1669
 
804
1670
  // Report to CallKit immediately
@@ -807,18 +1673,27 @@ import React
807
1673
  callUpdate.remoteHandle = handle
808
1674
  callUpdate.hasVideo = false
809
1675
  callUpdate.localizedCallerName = callerName
1676
+ callUpdate.supportsHolding = true
810
1677
 
811
1678
  callKitProvider.reportNewIncomingCall(with: callUUID, update: callUpdate) { error in
812
1679
  if let error = error {
1680
+ let nsError = error as NSError
1681
+ let reason =
1682
+ nsError.domain == CXErrorDomainIncomingCall && nsError.code == 3
1683
+ ? "filteredByDoNotDisturb" : "registrationRejected"
813
1684
  NSLog(
814
- "[TelnyxVoipPushHandler] ❌ CallKit error during terminated app handling: \(error.localizedDescription)"
1685
+ "[TelnyxVoipPushHandler] CallKit incoming registration rejected: domain=\(nsError.domain), code=\(nsError.code), reason=\(reason)"
815
1686
  )
816
1687
  callKitManager.activeCalls.removeValue(forKey: callUUID)
1688
+ callKitManager.clearPendingPushData(for: callUUID.uuidString)
817
1689
  } else {
818
1690
  NSLog(
819
1691
  "[TelnyxVoipPushHandler] ✅ CallKit call reported successfully via unified path"
820
1692
  )
821
1693
 
1694
+ callKitManager.markCallKitRegistered(callUUID)
1695
+ self.storePendingVoipPush(payload.dictionaryPayload)
1696
+
822
1697
  // CRITICAL: Store the CallKit UUID for the React Native CallKitCoordinator
823
1698
  // This allows the coordinator to find the existing CallKit call instead of creating a duplicate
824
1699
  UserDefaults.standard.set(callUUID.uuidString, forKey: "@pending_callkit_uuid")