@telnyx/react-voice-commons-sdk 1.1.0 → 1.2.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 (35) hide show
  1. package/CHANGELOG.md +38 -1
  2. package/TelnyxVoiceCommons.podspec +3 -3
  3. package/ios/CallKitBridge.m +7 -0
  4. package/ios/CallKitBridge.swift +239 -91
  5. package/lib/callkit/callkit-coordinator.d.ts +19 -0
  6. package/lib/callkit/callkit-coordinator.js +105 -5
  7. package/lib/index.d.ts +4 -1
  8. package/lib/index.js +9 -0
  9. package/lib/internal/calls/call-state-controller.d.ts +1 -0
  10. package/lib/internal/calls/call-state-controller.js +35 -1
  11. package/lib/internal/ice-servers-storage.d.ts +9 -0
  12. package/lib/internal/ice-servers-storage.js +37 -0
  13. package/lib/internal/sdk-identity.d.ts +4 -0
  14. package/lib/internal/sdk-identity.js +60 -0
  15. package/lib/internal/session/session-manager.d.ts +7 -2
  16. package/lib/internal/session/session-manager.js +35 -4
  17. package/lib/internal/signaling-environment.d.ts +13 -0
  18. package/lib/internal/signaling-environment.js +18 -0
  19. package/lib/models/call.d.ts +23 -2
  20. package/lib/models/call.js +37 -2
  21. package/lib/models/config.d.ts +28 -0
  22. package/lib/telnyx-voip-client.d.ts +4 -2
  23. package/lib/telnyx-voip-client.js +60 -2
  24. package/package.json +3 -4
  25. package/src/callkit/callkit-coordinator.ts +129 -7
  26. package/src/index.ts +6 -1
  27. package/src/internal/calls/call-state-controller.ts +41 -2
  28. package/src/internal/ice-servers-storage.ts +37 -0
  29. package/src/internal/sdk-identity.ts +6 -0
  30. package/src/internal/session/session-manager.ts +38 -4
  31. package/src/internal/signaling-environment.ts +20 -0
  32. package/src/models/call.ts +44 -2
  33. package/src/models/config.ts +30 -0
  34. package/src/telnyx-voip-client.ts +60 -3
  35. package/src/types/telnyx-sdk.d.ts +67 -2
package/CHANGELOG.md CHANGED
@@ -1,6 +1,43 @@
1
1
  # CHANGELOG.md
2
2
 
3
- ## Unreleased
3
+ ## [1.2.0] (2026-10-28)
4
+
5
+ ### Enhancement
6
+
7
+ - Expose call-quality metrics through `Call.qualityMetrics$`, `currentQualityMetrics`, and the exported metrics and quality-level types.
8
+ - Add automatic active-call recovery through the Voice SDK, including signaling-health monitoring, ICE restart, RTP verification, and reconnect/reattach fallback.
9
+ - Add `iceServers` to credential and token configurations so applications can provide custom STUN/TURN servers; omitted values use the Voice SDK's Telnyx production defaults.
10
+ - Identify Commons signaling as `webrtc-react-native-commons` with the Commons package version while retaining per-client identity isolation and the existing User-Agent format.
11
+
12
+ ### Dependencies
13
+
14
+ - Require `react-native-webrtc ^155.0.1` (WebRTC M155), matching the Voice SDK's media dependency. Applications must target iOS 14 or newer; the Android minimum remains API 24.
15
+ - Require `@telnyx/react-native-voice-sdk ^1.2.0` when publishing so Commons uses the matching signaling security, call recovery, metrics, and ICE-server behavior while staying within the compatible 1.x line.
16
+ - Remove the unused direct `expo` dependency. Commons does not require Expo,
17
+ so this avoids installing Expo SDK 53 alongside an application's Expo SDK.
18
+
19
+ ### Security
20
+
21
+ - Use the Voice SDK's platform-default TLS certificate and hostname validation. On iOS, signaling uses `URLSessionWebSocketTask`; Android retains React Native's OkHttp-backed WebSocket.
22
+
23
+ ### Bug Fixing
24
+
25
+ - Handle BYE and missed-call pushes that arrive before the incoming WebRTC call exists, dismiss the matching CallKit call, clear pending push state, and show the missed-call notification without treating the original UUID as a collision.
26
+ - Serialize CallKit registration completion with missed-call invalidation so a delayed completion cannot restore an already-ended incoming call; PushKit completion remains exactly once.
27
+ - Keep the active CallKit UUID claimed until WebRTC answering finishes, share concurrent answer work, and reject a second call while another call owns the answering/active slot.
28
+ - Prevent duplicate incoming and reattached-call listeners when the same client-ready callback fires more than once during connection or reconnection.
29
+
30
+ ## [1.1.1] (2026-09-11)
31
+
32
+ ### Bug Fixing
33
+
34
+ - Improve iOS CallKit audio reliability by routing activation and connected-call verification through a single idempotent recovery path.
35
+ - Recover connected calls when CallKit owns an active play-and-record audio session but the WebRTC audio device remains disabled.
36
+ - Prevent duplicate CallKit setup from disabling WebRTC audio after a call is already active, and harden failure handling when audio recovery does not succeed.
37
+
38
+ ### Dependencies
39
+
40
+ - Continues to require `@telnyx/react-native-voice-sdk >= 1.1.0`; no new base Voice SDK release is required.
4
41
 
5
42
  ## [1.1.0] (2026-08-07)
6
43
 
@@ -9,7 +9,7 @@ Pod::Spec.new do |s|
9
9
  s.description = package['description']
10
10
  s.homepage = package['homepage']
11
11
  s.license = package['license']
12
- s.platforms = { :ios => '13.0' }
12
+ s.platforms = { :ios => '14.0' }
13
13
  s.swift_version = '5.4'
14
14
  s.author = package['author']
15
15
  s.source = { :git => package['repository']['url'], :tag => "#{s.version}" }
@@ -22,11 +22,11 @@ Pod::Spec.new do |s|
22
22
  s.dependency 'React-Core'
23
23
 
24
24
  # WebRTC Framework dependency
25
- s.dependency 'JitsiWebRTC', '~> 124.0.0'
25
+ s.dependency 'JitsiWebRTC', '~> 155.0.0'
26
26
 
27
27
  # iOS Frameworks
28
28
  s.frameworks = 'CallKit', 'PushKit', 'AVFoundation'
29
29
 
30
30
  # Compiler flags for Swift
31
31
  s.compiler_flags = '-DFOLLY_NO_CONFIG -DFOLLY_MOBILE=1 -DFOLLY_USE_LIBCPP=1'
32
- end
32
+ end
@@ -60,4 +60,11 @@ RCT_EXTERN_METHOD(updateCall:(NSString *)callUUID
60
60
  RCT_EXTERN_METHOD(getActiveCalls:(RCTPromiseResolveBlock)resolve
61
61
  rejecter:(RCTPromiseRejectBlock)reject)
62
62
 
63
+ #if DEBUG
64
+ RCT_EXTERN_METHOD(simulateAudioSetupRace:(NSString *)callUUID
65
+ delayMilliseconds:(nonnull NSNumber *)delayMilliseconds
66
+ resolver:(RCTPromiseResolveBlock)resolve
67
+ rejecter:(RCTPromiseRejectBlock)reject)
68
+ #endif
69
+
63
70
  @end
@@ -156,7 +156,11 @@ import React
156
156
  "handle": handle,
157
157
  "uuid": uuid.uuidString,
158
158
  "direction": "outgoing",
159
- "source": "react_native",
159
+ // VSDK-771 AFK review: align internal source tag with the
160
+ // new prefixed convention so grep-based family completeness
161
+ // (webrtc-react-native / webrtc-react-native-commons) is
162
+ // clean across the SDK. Not consumed by the wire contract.
163
+ "source": "webrtc-react-native",
160
164
  ]
161
165
 
162
166
  callController.request(transaction) { error in
@@ -197,7 +201,9 @@ import React
197
201
  "handle": handle,
198
202
  "uuid": uuid.uuidString,
199
203
  "direction": "incoming",
200
- "source": "react_native",
204
+ // VSDK-771 AFK review: align internal source tag with the
205
+ // new prefixed convention. See outgoing branch above.
206
+ "source": "webrtc-react-native",
201
207
  "isCallKitRegistered": false,
202
208
  ]
203
209
 
@@ -440,29 +446,31 @@ import React
440
446
  NSLog("TelnyxVoice: reportCallConnected - fulfilling deferred CXAnswerCallAction")
441
447
  pendingAction.fulfill()
442
448
  } else {
443
- // Fallback: ensure audio is enabled for non-push or already-fulfilled cases
444
- let rtcAudioSession = RTCAudioSession.sharedInstance()
445
- rtcAudioSession.lockForConfiguration()
446
- let webRTCConfig = RTCAudioSessionConfiguration.webRTC()
447
- webRTCConfig.categoryOptions = [.duckOthers, .allowBluetooth]
448
- do {
449
- try rtcAudioSession.setConfiguration(webRTCConfig)
450
- } catch {
451
- NSLog("TelnyxVoice: reportCallConnected - setConfiguration error: \(error)")
452
- }
453
- do {
454
- try rtcAudioSession.setActive(true)
455
- } catch {
456
- NSLog("TelnyxVoice: reportCallConnected - setActive error: \(error)")
457
- }
458
- rtcAudioSession.isAudioEnabled = true
459
- rtcAudioSession.unlockForConfiguration()
460
- rtcAudioSession.audioSessionDidActivate(AVAudioSession.sharedInstance())
449
+ manager.verifyAudioAfterConnection(for: uuid)
461
450
  }
462
451
 
463
452
  resolve(["success": true])
464
453
  }
465
454
 
455
+ #if DEBUG
456
+ @objc func simulateAudioSetupRace(
457
+ _ callUUID: String,
458
+ delayMilliseconds: NSNumber,
459
+ resolver resolve: @escaping RCTPromiseResolveBlock,
460
+ rejecter reject: @escaping RCTPromiseRejectBlock
461
+ ) {
462
+ guard let uuid = UUID(uuidString: callUUID) else {
463
+ reject("INVALID_UUID", "Invalid UUID format", nil)
464
+ return
465
+ }
466
+ getCallKitManager().simulateAudioSetupRace(
467
+ for: uuid,
468
+ delay: delayMilliseconds.doubleValue / 1_000
469
+ )
470
+ resolve(["scheduled": true, "delayMilliseconds": delayMilliseconds])
471
+ }
472
+ #endif
473
+
466
474
  @objc func reportCallEnded(
467
475
  _ callUUID: String, reason: NSNumber,
468
476
  resolver resolve: @escaping RCTPromiseResolveBlock,
@@ -739,11 +747,29 @@ import React
739
747
  public var callKitProvider: CXProvider?
740
748
  public var callKitController: CXCallController?
741
749
  private let stateLock = NSRecursiveLock()
750
+ // Keep terminal missed-call invalidation atomic with registration side effects so a
751
+ // delayed CallKit completion cannot restore an already-ended incoming call.
752
+ private let incomingRegistrationQueue = DispatchQueue(
753
+ label: "com.telnyx.react-voice-commons.incoming-registration")
742
754
  public lazy var activeCalls = LockedDictionary<UUID, [String: Any]>(lock: stateLock)
743
755
  public lazy var pendingAnswerActions = LockedDictionary<UUID, CXAnswerCallAction>(
744
756
  lock: stateLock)
745
757
  public lazy var pendingHeldActions = LockedDictionary<UUID, CXSetHeldCallAction>(
746
758
  lock: stateLock)
759
+ private var audioLifecycleGeneration = 0
760
+
761
+ private func advanceAudioLifecycleGeneration() -> Int {
762
+ stateLock.lock()
763
+ defer { stateLock.unlock() }
764
+ audioLifecycleGeneration += 1
765
+ return audioLifecycleGeneration
766
+ }
767
+
768
+ private func currentAudioLifecycleGeneration() -> Int {
769
+ stateLock.lock()
770
+ defer { stateLock.unlock() }
771
+ return audioLifecycleGeneration
772
+ }
747
773
 
748
774
  fileprivate func markCallKitRegistered(_ callUUID: UUID) {
749
775
  guard var callData = activeCalls[callUUID] else {
@@ -766,6 +792,35 @@ import React
766
792
  return true
767
793
  }
768
794
 
795
+ @discardableResult
796
+ fileprivate func performIncomingRegistrationIfOwned(
797
+ _ callUUID: UUID,
798
+ token: String,
799
+ effects: () -> Void
800
+ ) -> Bool {
801
+ return incomingRegistrationQueue.sync {
802
+ guard let callData = activeCalls[callUUID],
803
+ callData["incomingRegistrationToken"] as? String == token
804
+ else {
805
+ return false
806
+ }
807
+
808
+ effects()
809
+ return true
810
+ }
811
+ }
812
+
813
+ fileprivate func invalidateIncomingRegistration(_ callUUID: UUID) {
814
+ incomingRegistrationQueue.sync {
815
+ guard var callData = activeCalls[callUUID] else {
816
+ return
817
+ }
818
+
819
+ callData.removeValue(forKey: "incomingRegistrationToken")
820
+ activeCalls[callUUID] = callData
821
+ }
822
+ }
823
+
769
824
  private override init() {
770
825
  super.init()
771
826
  // Only setup if explicitly requested via setup() method
@@ -825,10 +880,6 @@ import React
825
880
  }
826
881
 
827
882
  private func setupCallKit() {
828
- // ALWAYS configure WebRTC audio, even if provider already exists
829
- RTCAudioSession.sharedInstance().useManualAudio = true
830
- RTCAudioSession.sharedInstance().isAudioEnabled = false
831
-
832
883
  // Guard against duplicate provider creation (async setupAutomatically can overwrite
833
884
  // the provider created by setupSynchronously for VoIP push)
834
885
  guard callKitProvider == nil else {
@@ -836,9 +887,84 @@ import React
836
887
  return
837
888
  }
838
889
 
890
+ // Set the initial state only when installing the provider. Re-running setup after
891
+ // CallKit activation must never disable a live WebRTC audio device.
892
+ RTCAudioSession.sharedInstance().useManualAudio = true
893
+ RTCAudioSession.sharedInstance().isAudioEnabled = false
894
+
839
895
  _ = installCallKitProvider()
840
896
  }
841
897
 
898
+ fileprivate func activateWebRTCAudio(
899
+ _ audioSession: AVAudioSession = AVAudioSession.sharedInstance(),
900
+ reason: String
901
+ ) {
902
+ let rtcAudioSession = RTCAudioSession.sharedInstance()
903
+ rtcAudioSession.lockForConfiguration()
904
+ let wasAudioEnabled = rtcAudioSession.isAudioEnabled
905
+ NSLog("TelnyxVoice: activating WebRTC audio (\(reason)); enabled=\(wasAudioEnabled)")
906
+ let webRTCConfig = RTCAudioSessionConfiguration.webRTC()
907
+ webRTCConfig.categoryOptions = [.duckOthers, .allowBluetooth]
908
+ do {
909
+ try rtcAudioSession.setConfiguration(webRTCConfig)
910
+ } catch {
911
+ NSLog("TelnyxVoice: activateWebRTCAudio configuration (\(reason)) error: \(error)")
912
+ }
913
+
914
+ var activationSucceeded = false
915
+ do {
916
+ try rtcAudioSession.setActive(true)
917
+ activationSucceeded = true
918
+ } catch {
919
+ NSLog("TelnyxVoice: activateWebRTCAudio activation (\(reason)) error: \(error)")
920
+ }
921
+ if activationSucceeded {
922
+ rtcAudioSession.isAudioEnabled = true
923
+ } else if !wasAudioEnabled {
924
+ rtcAudioSession.isAudioEnabled = false
925
+ }
926
+ rtcAudioSession.unlockForConfiguration()
927
+ if activationSucceeded && !wasAudioEnabled {
928
+ rtcAudioSession.audioSessionDidActivate(audioSession)
929
+ }
930
+ }
931
+
932
+ fileprivate func verifyAudioAfterConnection(for callUUID: UUID) {
933
+ // didActivate is asynchronous after CXAnswerCallAction.fulfill(). Verify shortly
934
+ // afterwards so either callback ordering converges on the same idempotent state.
935
+ let verificationGeneration = currentAudioLifecycleGeneration()
936
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) { [weak self] in
937
+ guard let self,
938
+ self.currentAudioLifecycleGeneration() == verificationGeneration,
939
+ self.activeCalls[callUUID] != nil
940
+ else { return }
941
+ let rtcAudioSession = RTCAudioSession.sharedInstance()
942
+ rtcAudioSession.lockForConfiguration()
943
+ let isAudioEnabled = rtcAudioSession.isAudioEnabled
944
+ rtcAudioSession.unlockForConfiguration()
945
+ if isAudioEnabled {
946
+ NSLog("TelnyxVoice: audio verification passed for \(callUUID)")
947
+ } else if AVAudioSession.sharedInstance().category != .playAndRecord {
948
+ NSLog("TelnyxVoice: audio verification skipped for inactive CallKit session \(callUUID)")
949
+ } else {
950
+ NSLog("TelnyxVoice: audio verification recovering disabled audio for \(callUUID)")
951
+ self.activateWebRTCAudio(reason: "connected-call verification")
952
+ }
953
+ }
954
+ }
955
+
956
+ #if DEBUG
957
+ fileprivate func simulateAudioSetupRace(for callUUID: UUID, delay: TimeInterval) {
958
+ NSLog("TelnyxVoice: [VSUP-226] scheduling late audio reset in \(delay)s")
959
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
960
+ let rtcAudioSession = RTCAudioSession.sharedInstance()
961
+ rtcAudioSession.isAudioEnabled = false
962
+ NSLog("TelnyxVoice: [VSUP-226] injected late setup reset; enabled=false")
963
+ self?.verifyAudioAfterConnection(for: callUUID)
964
+ }
965
+ }
966
+ #endif
967
+
842
968
  private func installCallKitProvider() -> CXProvider {
843
969
  // Use the localizedName from the app's bundle display name or fallback
844
970
  let appName =
@@ -977,7 +1103,36 @@ import React
977
1103
  }
978
1104
 
979
1105
  if let activeCall = activeCalls[callUUID] {
980
- guard activeCall["source"] as? String == "missed_call_push" else {
1106
+ let activeSource = activeCall["source"] as? String
1107
+
1108
+ // A missed-call push normally reuses the original incoming
1109
+ // push's call_id. End that still-ringing CallKit call and show
1110
+ // the notification instead of treating it as a UUID collision.
1111
+ if activeSource == "push_notification" || activeSource == "terminated_app_push"
1112
+ || activeSource == "push"
1113
+ {
1114
+ NSLog(
1115
+ "TelnyxVoice: Missed call matched active incoming push UUID: \(callUUID)"
1116
+ )
1117
+ // Stop the original report callback from persisting or emitting
1118
+ // this call while the missed-call watchdog report is pending.
1119
+ invalidateIncomingRegistration(callUUID)
1120
+ // This is a second PushKit delivery, so report it to CallKit
1121
+ // with a fresh UUID before ending the original ringing call.
1122
+ reportAndEndWatchdogCall(
1123
+ callUUID: UUID(),
1124
+ callerName: callerName,
1125
+ callerNumber: callerNumber,
1126
+ payload: payload,
1127
+ source: "missed_call_matched_watchdog",
1128
+ endedReason: .remoteEnded
1129
+ ) {
1130
+ finishMissedCall()
1131
+ }
1132
+ return true
1133
+ }
1134
+
1135
+ guard activeSource == "missed_call_push" else {
981
1136
  NSLog("TelnyxVoice: Suppressing missed call notification because UUID belongs to an active call: \(callUUID)")
982
1137
  markMissedCallProcessed(id: missedCallId)
983
1138
  reportAndEndWatchdogCall(
@@ -1404,6 +1559,7 @@ import React
1404
1559
  "CallKitDidPerformEndCallAction", callUUID: action.callUUID,
1405
1560
  callData: callData)
1406
1561
 
1562
+ _ = advanceAudioLifecycleGeneration()
1407
1563
  activeCalls.removeValue(forKey: action.callUUID)
1408
1564
  pendingAnswerActions.removeValue(forKey: action.callUUID)?.fail()
1409
1565
  pendingHeldActions.removeValue(forKey: action.callUUID)?.fail()
@@ -1442,31 +1598,8 @@ import React
1442
1598
 
1443
1599
  public func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
1444
1600
  NSLog("TelnyxVoice: Audio session activated by CallKit")
1445
-
1446
- let rtcAudioSession = RTCAudioSession.sharedInstance()
1447
-
1448
- // Step 1: Configure (matches native iOS SDK setupCorrectAudioConfiguration)
1449
- rtcAudioSession.lockForConfiguration()
1450
- let webRTCConfig = RTCAudioSessionConfiguration.webRTC()
1451
- webRTCConfig.categoryOptions = [.duckOthers, .allowBluetooth]
1452
- do {
1453
- try rtcAudioSession.setConfiguration(webRTCConfig)
1454
- } catch {
1455
- NSLog("TelnyxVoice: didActivateAudioSession - setConfiguration error: \(error)")
1456
- }
1457
- rtcAudioSession.unlockForConfiguration()
1458
-
1459
- // Step 2: Activate (matches native iOS SDK setAudioSessionActive)
1460
- rtcAudioSession.lockForConfiguration()
1461
- do {
1462
- try rtcAudioSession.setActive(true)
1463
- } catch {
1464
- NSLog("TelnyxVoice: didActivateAudioSession - setActive error: \(error)")
1465
- }
1466
- rtcAudioSession.isAudioEnabled = true
1467
- rtcAudioSession.unlockForConfiguration()
1468
-
1469
- rtcAudioSession.audioSessionDidActivate(audioSession)
1601
+ _ = advanceAudioLifecycleGeneration()
1602
+ activateWebRTCAudio(audioSession, reason: "CallKit didActivate")
1470
1603
 
1471
1604
  // Emit audio session activated event to React Native
1472
1605
  CallKitBridge.shared?.emitAudioSessionEvent(
@@ -1480,6 +1613,7 @@ import React
1480
1613
 
1481
1614
  public func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
1482
1615
  NSLog("TelnyxVoice: Audio session deactivated by CallKit")
1616
+ _ = advanceAudioLifecycleGeneration()
1483
1617
 
1484
1618
  let rtcAudioSession = RTCAudioSession.sharedInstance()
1485
1619
 
@@ -1657,6 +1791,7 @@ import React
1657
1791
 
1658
1792
  // Store call data manually and report to CallKit
1659
1793
  let isAppRunning = CallKitBridge.shared != nil
1794
+ let registrationToken = UUID().uuidString
1660
1795
  callKitManager.activeCalls[callUUID] = [
1661
1796
  "caller": callerName,
1662
1797
  "handle": callerNumber,
@@ -1665,6 +1800,7 @@ import React
1665
1800
  "direction": "incoming",
1666
1801
  "source": isAppRunning ? "push_notification" : "terminated_app_push",
1667
1802
  "isCallKitRegistered": false,
1803
+ "incomingRegistrationToken": registrationToken,
1668
1804
  ]
1669
1805
 
1670
1806
  // Report to CallKit immediately
@@ -1676,56 +1812,68 @@ import React
1676
1812
  callUpdate.supportsHolding = true
1677
1813
 
1678
1814
  callKitProvider.reportNewIncomingCall(with: callUUID, update: callUpdate) { error in
1679
- if let error = error {
1680
- let nsError = error as NSError
1681
- let reason =
1682
- nsError.domain == CXErrorDomainIncomingCall && nsError.code == 3
1683
- ? "filteredByDoNotDisturb" : "registrationRejected"
1684
- NSLog(
1685
- "[TelnyxVoipPushHandler] CallKit incoming registration rejected: domain=\(nsError.domain), code=\(nsError.code), reason=\(reason)"
1686
- )
1687
- callKitManager.activeCalls.removeValue(forKey: callUUID)
1688
- callKitManager.clearPendingPushData(for: callUUID.uuidString)
1689
- } else {
1690
- NSLog(
1691
- "[TelnyxVoipPushHandler] ✅ CallKit call reported successfully via unified path"
1692
- )
1693
-
1694
- callKitManager.markCallKitRegistered(callUUID)
1695
- self.storePendingVoipPush(payload.dictionaryPayload)
1815
+ defer { completion() }
1696
1816
 
1697
- // CRITICAL: Store the CallKit UUID for the React Native CallKitCoordinator
1698
- // This allows the coordinator to find the existing CallKit call instead of creating a duplicate
1699
- UserDefaults.standard.set(callUUID.uuidString, forKey: "@pending_callkit_uuid")
1700
- UserDefaults.standard.synchronize()
1701
- NSLog(
1702
- "[TelnyxVoipPushHandler] ✅ Stored CallKit UUID for React Native coordinator: \(callUUID.uuidString)"
1703
- )
1704
-
1705
- // Try to emit push received event if CallKit bridge is available
1706
- if let callKitBridge = CallKitBridge.shared {
1707
- let source = isAppRunning ? "push_notification" : "terminated_app_push"
1708
- callKitBridge.emitCallEvent(
1709
- "CallKitDidReceivePush", callUUID: callUUID,
1710
- callData: [
1711
- "caller": callerName,
1712
- "handle": callerNumber,
1713
- "payload": payload.dictionaryPayload,
1714
- "uuid": callUUID.uuidString,
1715
- "direction": "incoming",
1716
- "source": source,
1717
- ])
1817
+ let handled = callKitManager.performIncomingRegistrationIfOwned(
1818
+ callUUID, token: registrationToken
1819
+ ) {
1820
+ if let error = error {
1821
+ let nsError = error as NSError
1822
+ let reason =
1823
+ nsError.domain == CXErrorDomainIncomingCall && nsError.code == 3
1824
+ ? "filteredByDoNotDisturb" : "registrationRejected"
1718
1825
  NSLog(
1719
- "[TelnyxVoipPushHandler] ✅ Emitted CallKitDidReceivePush event to React Native"
1826
+ "[TelnyxVoipPushHandler] CallKit incoming registration rejected: domain=\(nsError.domain), code=\(nsError.code), reason=\(reason)"
1720
1827
  )
1828
+ callKitManager.activeCalls.removeValue(forKey: callUUID)
1829
+ callKitManager.clearPendingPushData(for: callUUID.uuidString)
1721
1830
  } else {
1722
1831
  NSLog(
1723
- "[TelnyxVoipPushHandler] ⚠️ React Native bridge not ready - event will be emitted when bridge becomes available"
1832
+ "[TelnyxVoipPushHandler] ✅ CallKit call reported successfully via unified path"
1724
1833
  )
1834
+
1835
+ callKitManager.markCallKitRegistered(callUUID)
1836
+ self.storePendingVoipPush(payload.dictionaryPayload)
1837
+
1838
+ // CRITICAL: Store the CallKit UUID for the React Native CallKitCoordinator
1839
+ // This allows the coordinator to find the existing CallKit call instead of creating a duplicate
1840
+ UserDefaults.standard.set(
1841
+ callUUID.uuidString, forKey: "@pending_callkit_uuid")
1842
+ UserDefaults.standard.synchronize()
1843
+ NSLog(
1844
+ "[TelnyxVoipPushHandler] ✅ Stored CallKit UUID for React Native coordinator: \(callUUID.uuidString)"
1845
+ )
1846
+
1847
+ // Try to emit push received event if CallKit bridge is available
1848
+ if let callKitBridge = CallKitBridge.shared {
1849
+ let source =
1850
+ isAppRunning ? "push_notification" : "terminated_app_push"
1851
+ callKitBridge.emitCallEvent(
1852
+ "CallKitDidReceivePush", callUUID: callUUID,
1853
+ callData: [
1854
+ "caller": callerName,
1855
+ "handle": callerNumber,
1856
+ "payload": payload.dictionaryPayload,
1857
+ "uuid": callUUID.uuidString,
1858
+ "direction": "incoming",
1859
+ "source": source,
1860
+ ])
1861
+ NSLog(
1862
+ "[TelnyxVoipPushHandler] ✅ Emitted CallKitDidReceivePush event to React Native"
1863
+ )
1864
+ } else {
1865
+ NSLog(
1866
+ "[TelnyxVoipPushHandler] ⚠️ React Native bridge not ready - event will be emitted when bridge becomes available"
1867
+ )
1868
+ }
1725
1869
  }
1726
1870
  }
1727
1871
 
1728
- completion()
1872
+ if !handled {
1873
+ NSLog(
1874
+ "[TelnyxVoipPushHandler] Ignoring stale incoming registration completion for ended call: \(callUUID.uuidString)"
1875
+ )
1876
+ }
1729
1877
  }
1730
1878
  }
1731
1879
 
@@ -1,5 +1,12 @@
1
1
  import { Call } from '@telnyx/react-native-voice-sdk';
2
2
  import type { TelnyxVoipClient } from '../telnyx-voip-client';
3
+ export type RemoteCallEndedReason = {
4
+ cause?: string;
5
+ causeCode?: number;
6
+ sipCode?: number;
7
+ sipReason?: string;
8
+ sipCallId?: string;
9
+ };
3
10
  /**
4
11
  * CallKit Coordinator - Manages the proper CallKit-first flow for iOS
5
12
  *
@@ -11,6 +18,8 @@ declare class CallKitCoordinator {
11
18
  private static instance;
12
19
  private callMap;
13
20
  private processingCalls;
21
+ private activeOrAnsweringCallUUID;
22
+ private claimActiveCall;
14
23
  private endedCalls;
15
24
  private connectedCalls;
16
25
  private isCallFromPush;
@@ -80,6 +89,11 @@ declare class CallKitCoordinator {
80
89
  * before calling this method.
81
90
  */
82
91
  handleCallKitPushReceived(callKitUUID: string, event?: any): Promise<boolean>;
92
+ /** End a still-pending PushKit CallKit call after a remote hangup. */
93
+ handlePendingPushCallRemoteEnded(
94
+ callKitUUID: string,
95
+ reason?: RemoteCallEndedReason
96
+ ): Promise<boolean>;
83
97
  /**
84
98
  * Handle push notification answer - when user answers from CallKit but we don't have a WebRTC call yet
85
99
  * This is the iOS equivalent of the Android FCM handler
@@ -130,6 +144,7 @@ declare class CallKitCoordinator {
130
144
  private getSDKClient;
131
145
  private normalizeUUID;
132
146
  private actionKey;
147
+ private releaseActiveCall;
133
148
  private selectCall;
134
149
  private isSelectedCall;
135
150
  private recordPendingSwapAction;
@@ -152,6 +167,10 @@ declare class CallKitCoordinator {
152
167
  * This helps prevent premature flag resets during CallKit operations
153
168
  */
154
169
  hasProcessingCalls(): boolean;
170
+ /** @internal Exposed for deterministic single-active-call regression tests. */
171
+ claimActiveCallForTesting(callKitUUID: string): boolean;
172
+ /** @internal Exposed for deterministic single-active-call regression tests. */
173
+ releaseActiveCallForTesting(callKitUUID: string): void;
155
174
  /**
156
175
  * Check if there's currently a call from push notification being processed
157
176
  * This helps prevent disconnection during push call handling