@telnyx/react-voice-commons-sdk 1.1.1 → 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 (34) hide show
  1. package/CHANGELOG.md +38 -1
  2. package/TelnyxVoiceCommons.podspec +3 -3
  3. package/ios/CallKitBridge.swift +126 -44
  4. package/lib/callkit/callkit-coordinator.d.ts +19 -0
  5. package/lib/callkit/callkit-coordinator.js +105 -5
  6. package/lib/index.d.ts +4 -1
  7. package/lib/index.js +9 -0
  8. package/lib/internal/calls/call-state-controller.d.ts +1 -0
  9. package/lib/internal/calls/call-state-controller.js +35 -1
  10. package/lib/internal/ice-servers-storage.d.ts +9 -0
  11. package/lib/internal/ice-servers-storage.js +37 -0
  12. package/lib/internal/sdk-identity.d.ts +4 -0
  13. package/lib/internal/sdk-identity.js +60 -0
  14. package/lib/internal/session/session-manager.d.ts +7 -2
  15. package/lib/internal/session/session-manager.js +35 -4
  16. package/lib/internal/signaling-environment.d.ts +13 -0
  17. package/lib/internal/signaling-environment.js +18 -0
  18. package/lib/models/call.d.ts +23 -2
  19. package/lib/models/call.js +37 -2
  20. package/lib/models/config.d.ts +28 -0
  21. package/lib/telnyx-voip-client.d.ts +4 -2
  22. package/lib/telnyx-voip-client.js +60 -2
  23. package/package.json +3 -4
  24. package/src/callkit/callkit-coordinator.ts +129 -7
  25. package/src/index.ts +6 -1
  26. package/src/internal/calls/call-state-controller.ts +41 -2
  27. package/src/internal/ice-servers-storage.ts +37 -0
  28. package/src/internal/sdk-identity.ts +6 -0
  29. package/src/internal/session/session-manager.ts +38 -4
  30. package/src/internal/signaling-environment.ts +20 -0
  31. package/src/models/call.ts +44 -2
  32. package/src/models/config.ts +30 -0
  33. package/src/telnyx-voip-client.ts +60 -3
  34. 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
@@ -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
 
@@ -741,6 +747,10 @@ import React
741
747
  public var callKitProvider: CXProvider?
742
748
  public var callKitController: CXCallController?
743
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")
744
754
  public lazy var activeCalls = LockedDictionary<UUID, [String: Any]>(lock: stateLock)
745
755
  public lazy var pendingAnswerActions = LockedDictionary<UUID, CXAnswerCallAction>(
746
756
  lock: stateLock)
@@ -782,6 +792,35 @@ import React
782
792
  return true
783
793
  }
784
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
+
785
824
  private override init() {
786
825
  super.init()
787
826
  // Only setup if explicitly requested via setup() method
@@ -1064,7 +1103,36 @@ import React
1064
1103
  }
1065
1104
 
1066
1105
  if let activeCall = activeCalls[callUUID] {
1067
- 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 {
1068
1136
  NSLog("TelnyxVoice: Suppressing missed call notification because UUID belongs to an active call: \(callUUID)")
1069
1137
  markMissedCallProcessed(id: missedCallId)
1070
1138
  reportAndEndWatchdogCall(
@@ -1723,6 +1791,7 @@ import React
1723
1791
 
1724
1792
  // Store call data manually and report to CallKit
1725
1793
  let isAppRunning = CallKitBridge.shared != nil
1794
+ let registrationToken = UUID().uuidString
1726
1795
  callKitManager.activeCalls[callUUID] = [
1727
1796
  "caller": callerName,
1728
1797
  "handle": callerNumber,
@@ -1731,6 +1800,7 @@ import React
1731
1800
  "direction": "incoming",
1732
1801
  "source": isAppRunning ? "push_notification" : "terminated_app_push",
1733
1802
  "isCallKitRegistered": false,
1803
+ "incomingRegistrationToken": registrationToken,
1734
1804
  ]
1735
1805
 
1736
1806
  // Report to CallKit immediately
@@ -1742,56 +1812,68 @@ import React
1742
1812
  callUpdate.supportsHolding = true
1743
1813
 
1744
1814
  callKitProvider.reportNewIncomingCall(with: callUUID, update: callUpdate) { error in
1745
- if let error = error {
1746
- let nsError = error as NSError
1747
- let reason =
1748
- nsError.domain == CXErrorDomainIncomingCall && nsError.code == 3
1749
- ? "filteredByDoNotDisturb" : "registrationRejected"
1750
- NSLog(
1751
- "[TelnyxVoipPushHandler] CallKit incoming registration rejected: domain=\(nsError.domain), code=\(nsError.code), reason=\(reason)"
1752
- )
1753
- callKitManager.activeCalls.removeValue(forKey: callUUID)
1754
- callKitManager.clearPendingPushData(for: callUUID.uuidString)
1755
- } else {
1756
- NSLog(
1757
- "[TelnyxVoipPushHandler] ✅ CallKit call reported successfully via unified path"
1758
- )
1759
-
1760
- callKitManager.markCallKitRegistered(callUUID)
1761
- self.storePendingVoipPush(payload.dictionaryPayload)
1815
+ defer { completion() }
1762
1816
 
1763
- // CRITICAL: Store the CallKit UUID for the React Native CallKitCoordinator
1764
- // This allows the coordinator to find the existing CallKit call instead of creating a duplicate
1765
- UserDefaults.standard.set(callUUID.uuidString, forKey: "@pending_callkit_uuid")
1766
- UserDefaults.standard.synchronize()
1767
- NSLog(
1768
- "[TelnyxVoipPushHandler] ✅ Stored CallKit UUID for React Native coordinator: \(callUUID.uuidString)"
1769
- )
1770
-
1771
- // Try to emit push received event if CallKit bridge is available
1772
- if let callKitBridge = CallKitBridge.shared {
1773
- let source = isAppRunning ? "push_notification" : "terminated_app_push"
1774
- callKitBridge.emitCallEvent(
1775
- "CallKitDidReceivePush", callUUID: callUUID,
1776
- callData: [
1777
- "caller": callerName,
1778
- "handle": callerNumber,
1779
- "payload": payload.dictionaryPayload,
1780
- "uuid": callUUID.uuidString,
1781
- "direction": "incoming",
1782
- "source": source,
1783
- ])
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"
1784
1825
  NSLog(
1785
- "[TelnyxVoipPushHandler] ✅ Emitted CallKitDidReceivePush event to React Native"
1826
+ "[TelnyxVoipPushHandler] CallKit incoming registration rejected: domain=\(nsError.domain), code=\(nsError.code), reason=\(reason)"
1786
1827
  )
1828
+ callKitManager.activeCalls.removeValue(forKey: callUUID)
1829
+ callKitManager.clearPendingPushData(for: callUUID.uuidString)
1787
1830
  } else {
1788
1831
  NSLog(
1789
- "[TelnyxVoipPushHandler] ⚠️ React Native bridge not ready - event will be emitted when bridge becomes available"
1832
+ "[TelnyxVoipPushHandler] ✅ CallKit call reported successfully via unified path"
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)"
1790
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
+ }
1791
1869
  }
1792
1870
  }
1793
1871
 
1794
- completion()
1872
+ if !handled {
1873
+ NSLog(
1874
+ "[TelnyxVoipPushHandler] Ignoring stale incoming registration completion for ended call: \(callUUID.uuidString)"
1875
+ )
1876
+ }
1795
1877
  }
1796
1878
  }
1797
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
@@ -64,6 +64,13 @@ const voice_pn_bridge_1 = require('../internal/voice-pn-bridge');
64
64
  * guidelines for proper CallKit integration.
65
65
  */
66
66
  class CallKitCoordinator {
67
+ claimActiveCall(callKitUUID) {
68
+ if (this.activeOrAnsweringCallUUID !== null && this.activeOrAnsweringCallUUID !== callKitUUID) {
69
+ return false;
70
+ }
71
+ this.activeOrAnsweringCallUUID = callKitUUID;
72
+ return true;
73
+ }
67
74
  static getInstance() {
68
75
  if (!CallKitCoordinator.instance) {
69
76
  CallKitCoordinator.instance = new CallKitCoordinator();
@@ -76,6 +83,7 @@ class CallKitCoordinator {
76
83
  // Tracks action+UUID pairs so an end action is not dropped merely because
77
84
  // an answer or held action for the same call is still completing.
78
85
  this.processingCalls = new Set();
86
+ this.activeOrAnsweringCallUUID = null;
79
87
  // Tracks calls that have already been ended in CallKit to prevent duplicate reports
80
88
  this.endedCalls = new Set();
81
89
  // Tracks calls that have already been reported as connected to prevent duplicate reports
@@ -187,6 +195,16 @@ class CallKitCoordinator {
187
195
  displayName || destinationNumber
188
196
  );
189
197
  if (success) {
198
+ if (!this.claimActiveCall(callKitUUID)) {
199
+ console.warn('CallKitCoordinator: Another call is already active or answering');
200
+ await callkit_1.default.reportCallEnded(callKitUUID, callkit_1.CallEndReason.Failed);
201
+ try {
202
+ await call.hangup();
203
+ } finally {
204
+ this.cleanupCall(callKitUUID);
205
+ }
206
+ return null;
207
+ }
190
208
  this.callMap.set(callKitUUID, call);
191
209
  this.setupWebRTCCallListeners(call, callKitUUID);
192
210
  call._callKitUUID = callKitUUID;
@@ -208,6 +226,10 @@ class CallKitCoordinator {
208
226
  console.warn('CallKitCoordinator: Cannot answer call - no CallKit UUID found');
209
227
  return false;
210
228
  }
229
+ if (!this.claimActiveCall(callKitUUID)) {
230
+ console.warn('CallKitCoordinator: Another call is already active or answering');
231
+ return false;
232
+ }
211
233
  console.log(
212
234
  'CallKitCoordinator: Answering call from UI using CallKit answer simulation',
213
235
  callKitUUID
@@ -236,6 +258,8 @@ class CallKitCoordinator {
236
258
  this.isCallFromPush = this.pendingPushCallUUIDs.size > 0;
237
259
  await this.clearMatchingPendingVoipPush(callKitUUID);
238
260
  console.log('CallKitCoordinator: CallKit answer success');
261
+ } else {
262
+ this.releaseActiveCall(callKitUUID);
239
263
  }
240
264
  return success;
241
265
  } catch (error) {
@@ -391,6 +415,21 @@ class CallKitCoordinator {
391
415
  console.log('CallKitCoordinator: Answer action already being processed, skipping duplicate');
392
416
  return;
393
417
  }
418
+ if (!this.claimActiveCall(callKitUUID)) {
419
+ console.warn(
420
+ 'CallKitCoordinator: Rejecting answer because another call owns the active slot'
421
+ );
422
+ await callkit_1.default.reportCallEnded(callKitUUID, callkit_1.CallEndReason.Failed);
423
+ const rejectedCall = this.callMap.get(callKitUUID);
424
+ if (rejectedCall) {
425
+ try {
426
+ await rejectedCall.hangup();
427
+ } finally {
428
+ this.cleanupCall(callKitUUID);
429
+ }
430
+ }
431
+ return;
432
+ }
394
433
  const call = this.callMap.get(callKitUUID);
395
434
  if (!call) {
396
435
  console.warn('CallKitCoordinator: No WebRTC call found for CallKit answer action', {
@@ -399,7 +438,10 @@ class CallKitCoordinator {
399
438
  availableWebRTCCallIds: Array.from(this.callMap.values()).map((c) => c.callId),
400
439
  });
401
440
  console.log('CallKitCoordinator: No WebRTC call found, handling as push notification');
402
- await this.handlePushNotificationAnswer(callKitUUID, event);
441
+ const queued = await this.handlePushNotificationAnswer(callKitUUID, event);
442
+ if (!queued) {
443
+ this.releaseActiveCall(callKitUUID);
444
+ }
403
445
  return;
404
446
  }
405
447
  console.log('CallKitCoordinator: Processing CallKit answer action', {
@@ -675,6 +717,31 @@ class CallKitCoordinator {
675
717
  return false;
676
718
  }
677
719
  }
720
+ /** End a still-pending PushKit CallKit call after a remote hangup. */
721
+ async handlePendingPushCallRemoteEnded(callKitUUID, reason) {
722
+ callKitUUID = this.normalizeUUID(callKitUUID);
723
+ // Do not let an unrelated/stale socket BYE terminate another CallKit call.
724
+ // The UUID is added here only after native CallKit registration succeeds.
725
+ if (!this.pendingPushCallUUIDs.has(callKitUUID)) {
726
+ console.log('CallKitCoordinator: Ignoring remote-ended event for unknown push UUID', {
727
+ callKitUUID,
728
+ reason,
729
+ });
730
+ return false;
731
+ }
732
+ console.log('CallKitCoordinator: Ending pending push call after remote hangup', {
733
+ callKitUUID,
734
+ reason,
735
+ });
736
+ if (!this.endedCalls.has(callKitUUID)) {
737
+ await callkit_1.default.reportCallEnded(callKitUUID, callkit_1.CallEndReason.RemoteEnded);
738
+ this.endedCalls.add(callKitUUID);
739
+ }
740
+ this.cleanupCall(callKitUUID);
741
+ await this.clearMatchingPendingVoipPush(callKitUUID);
742
+ await this.checkBackgroundDisconnection();
743
+ return true;
744
+ }
678
745
  /**
679
746
  * Handle push notification answer - when user answers from CallKit but we don't have a WebRTC call yet
680
747
  * This is the iOS equivalent of the Android FCM handler
@@ -698,7 +765,7 @@ class CallKitCoordinator {
698
765
  // checkForInitialPushNotification() will run after setVoipClient()
699
766
  // and will find the push data still intact, call handleCallKitPushReceived()
700
767
  // which queues the matching auto-answer.
701
- return;
768
+ return false;
702
769
  }
703
770
  // Queue one UUID-targeted answer. If the push is already being
704
771
  // processed, its eventual INVITE will consume this action. Otherwise
@@ -706,19 +773,20 @@ class CallKitCoordinator {
706
773
  voipClient.queueAnswerFromCallKit(callKitUUID);
707
774
  if (this.pendingPushCallUUIDs.has(callKitUUID)) {
708
775
  this.autoAnswerCallUUIDs.delete(callKitUUID);
709
- return;
776
+ return true;
710
777
  }
711
- await this.handleCallKitPushReceived(callKitUUID, event);
712
- return;
778
+ return await this.handleCallKitPushReceived(callKitUUID, event);
713
779
  }
714
780
  // For other platforms (shouldn't happen on iOS)
715
781
  console.error('CallKitCoordinator: ❌ Unsupported platform for push notification handling');
716
782
  await callkit_1.default.reportCallEnded(callKitUUID, callkit_1.CallEndReason.Failed);
783
+ return false;
717
784
  } catch (error) {
718
785
  console.error('CallKitCoordinator: ❌ Error handling push notification answer:', error);
719
786
  // Report the call as failed to CallKit
720
787
  await callkit_1.default.reportCallEnded(callKitUUID, callkit_1.CallEndReason.Failed);
721
788
  this.cleanupCall(callKitUUID);
789
+ return false;
722
790
  }
723
791
  }
724
792
  /**
@@ -764,6 +832,18 @@ class CallKitCoordinator {
764
832
  });
765
833
  switch (state) {
766
834
  case 'active':
835
+ if (!this.claimActiveCall(callKitUUID)) {
836
+ console.warn(
837
+ 'CallKitCoordinator: Ending active call because another call owns the slot'
838
+ );
839
+ await callkit_1.default.reportCallEnded(callKitUUID, callkit_1.CallEndReason.Failed);
840
+ try {
841
+ await call.hangup();
842
+ } finally {
843
+ this.cleanupCall(callKitUUID);
844
+ }
845
+ break;
846
+ }
767
847
  this.selectCall(call);
768
848
  this.pendingPushCallUUIDs.delete(callKitUUID);
769
849
  this.autoAnswerCallUUIDs.delete(callKitUUID);
@@ -838,6 +918,9 @@ class CallKitCoordinator {
838
918
  this.connectedCalls.delete(callKitUUID);
839
919
  this.pendingPushCallUUIDs.delete(callKitUUID);
840
920
  this.autoAnswerCallUUIDs.delete(callKitUUID);
921
+ if (this.activeOrAnsweringCallUUID === callKitUUID) {
922
+ this.activeOrAnsweringCallUUID = null;
923
+ }
841
924
  // Get the call before removing it
842
925
  const call = this.callMap.get(callKitUUID);
843
926
  // Clean up state listeners
@@ -967,6 +1050,12 @@ class CallKitCoordinator {
967
1050
  actionKey(action, callKitUUID) {
968
1051
  return `${action}:${this.normalizeUUID(callKitUUID)}`;
969
1052
  }
1053
+ releaseActiveCall(callKitUUID) {
1054
+ callKitUUID = this.normalizeUUID(callKitUUID);
1055
+ if (this.activeOrAnsweringCallUUID === callKitUUID) {
1056
+ this.activeOrAnsweringCallUUID = null;
1057
+ }
1058
+ }
970
1059
  selectCall(call) {
971
1060
  const callKitUUID = this.getCallKitUUID(call);
972
1061
  const wrapperCall = this.voipClient?.findCallByTelnyxCall(call);
@@ -1174,6 +1263,9 @@ class CallKitCoordinator {
1174
1263
  resetFlags() {
1175
1264
  console.log('CallKitCoordinator: Resetting coordinator flags');
1176
1265
  this.isCallFromPush = this.pendingPushCallUUIDs.size > 0;
1266
+ if (this.callMap.size === 0) {
1267
+ this.activeOrAnsweringCallUUID = null;
1268
+ }
1177
1269
  console.log('CallKitCoordinator: ✅ Coordinator flags reset');
1178
1270
  }
1179
1271
  /**
@@ -1187,6 +1279,14 @@ class CallKitCoordinator {
1187
1279
  // call arrives during push notification handling.
1188
1280
  return this.processingCalls.size > 0 || this.isCallFromPush;
1189
1281
  }
1282
+ /** @internal Exposed for deterministic single-active-call regression tests. */
1283
+ claimActiveCallForTesting(callKitUUID) {
1284
+ return this.claimActiveCall(callKitUUID);
1285
+ }
1286
+ /** @internal Exposed for deterministic single-active-call regression tests. */
1287
+ releaseActiveCallForTesting(callKitUUID) {
1288
+ this.releaseActiveCall(callKitUUID);
1289
+ }
1190
1290
  /**
1191
1291
  * Check if there's currently a call from push notification being processed
1192
1292
  * This helps prevent disconnection during push call handling
package/lib/index.d.ts CHANGED
@@ -38,8 +38,11 @@ export {
38
38
  createCredentialConfig,
39
39
  createTokenConfig,
40
40
  } from './models/config';
41
- export type { Config, CredentialConfig, TokenConfig } from './models/config';
41
+ export type { Config, CredentialConfig, TokenConfig, TelnyxEnvironment } from './models/config';
42
+ export type { TelnyxIceServer } from '@telnyx/react-native-voice-sdk';
42
43
  export type { Call as TelnyxCall } from '@telnyx/react-native-voice-sdk';
44
+ export { CallQualityLevel } from '@telnyx/react-native-voice-sdk';
45
+ export type { CallQualityMetrics, QualityStatsRecord } from '@telnyx/react-native-voice-sdk';
43
46
  export * from './callkit';
44
47
  export { VoicePnBridge } from './internal/voice-pn-bridge';
45
48
  export { useAppReadyNotifier } from './hooks/useAppReadyNotifier';
package/lib/index.js CHANGED
@@ -39,6 +39,7 @@ var __exportStar =
39
39
  Object.defineProperty(exports, '__esModule', { value: true });
40
40
  exports.useAppReadyNotifier =
41
41
  exports.VoicePnBridge =
42
+ exports.CallQualityLevel =
42
43
  exports.createTokenConfig =
43
44
  exports.createCredentialConfig =
44
45
  exports.validateTokenConfig =
@@ -214,6 +215,14 @@ Object.defineProperty(exports, 'createTokenConfig', {
214
215
  return config_1.createTokenConfig;
215
216
  },
216
217
  });
218
+ // Re-export call quality metrics types from the underlying SDK
219
+ var react_native_voice_sdk_1 = require('@telnyx/react-native-voice-sdk');
220
+ Object.defineProperty(exports, 'CallQualityLevel', {
221
+ enumerable: true,
222
+ get: function () {
223
+ return react_native_voice_sdk_1.CallQualityLevel;
224
+ },
225
+ });
217
226
  // Export CallKit functionality
218
227
  __exportStar(require('./callkit'), exports);
219
228
  // Native push notification helpers
@@ -122,6 +122,7 @@ export declare class CallStateController {
122
122
  private _removeClientListeners;
123
123
  private _handleTelnyxIncomingCall;
124
124
  private _handleTelnyxReattachedCall;
125
+ private _handleRemoteCallEnded;
125
126
  private _handleTelnyxCallStateChanged;
126
127
  private _handleTelnyxCallRemoved;
127
128
  /**