@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
@@ -1,6 +1,7 @@
1
1
  import { Observable } from 'rxjs';
2
2
  import { TelnyxCallState } from './call-state';
3
3
  import { Call as TelnyxCall } from '@telnyx/react-native-voice-sdk';
4
+ import type { CallQualityMetrics } from '@telnyx/react-native-voice-sdk';
4
5
  /**
5
6
  * Represents a call with reactive state streams.
6
7
  *
@@ -19,6 +20,8 @@ export declare class Call {
19
20
  private readonly _isMuted;
20
21
  private readonly _isHeld;
21
22
  private readonly _duration;
23
+ private readonly _qualityMetrics;
24
+ private readonly _onQualityMetrics;
22
25
  private _durationTimer?;
23
26
  private _startTime?;
24
27
  constructor(
@@ -75,7 +78,7 @@ export declare class Call {
75
78
  /**
76
79
  * Custom headers received from the WebRTC INVITE message.
77
80
  * These headers are passed during call initiation and can contain application-specific information.
78
- * Format should be [{"name": "X-Header-Name", "value": "Value"}] where header names must start with "X-".
81
+ * Format: `[{"name": "X-Header-Name", "value": "Value"}]`; header names must start with `X-`.
79
82
  */
80
83
  get inviteCustomHeaders():
81
84
  | {
@@ -86,7 +89,7 @@ export declare class Call {
86
89
  /**
87
90
  * Custom headers received from the WebRTC ANSWER message.
88
91
  * These headers are passed during call acceptance and can contain application-specific information.
89
- * Format should be [{"name": "X-Header-Name", "value": "Value"}] where header names must start with "X-".
92
+ * Format: `[{"name": "X-Header-Name", "value": "Value"}]`; header names must start with `X-`.
90
93
  */
91
94
  get answerCustomHeaders():
92
95
  | {
@@ -99,6 +102,13 @@ export declare class Call {
99
102
  * @internal
100
103
  */
101
104
  get telnyxCall(): TelnyxCall;
105
+ /**
106
+ * Inject an ICE failure into the underlying SDK recovery flow for manual
107
+ * testing. The underlying SDK accepts this only when debug mode is enabled.
108
+ *
109
+ * @returns true when a recovery episode was started.
110
+ */
111
+ simulateIceFailureForTesting(): boolean;
102
112
  /**
103
113
  * Observable stream of call state changes
104
114
  */
@@ -115,6 +125,17 @@ export declare class Call {
115
125
  * Observable stream of call duration changes (in seconds)
116
126
  */
117
127
  get duration$(): Observable<number>;
128
+ /**
129
+ * Observable stream of call quality metrics updates.
130
+ * Emits `null` initially, then a {@link CallQualityMetrics} object
131
+ * each time the underlying SDK collects a new sample.
132
+ */
133
+ get qualityMetrics$(): Observable<CallQualityMetrics | null>;
134
+ /**
135
+ * Current call quality metrics (synchronous access).
136
+ * Returns `null` until the first metrics sample is collected.
137
+ */
138
+ get currentQualityMetrics(): CallQualityMetrics | null;
118
139
  /**
119
140
  * Observable that emits true when the call can be answered
120
141
  */
@@ -84,6 +84,10 @@ class Call {
84
84
  this._isMuted = new rxjs_1.BehaviorSubject(false);
85
85
  this._isHeld = new rxjs_1.BehaviorSubject(false);
86
86
  this._duration = new rxjs_1.BehaviorSubject(0);
87
+ this._qualityMetrics = new rxjs_1.BehaviorSubject(null);
88
+ this._onQualityMetrics = (metrics) => {
89
+ this._qualityMetrics.next(metrics);
90
+ };
87
91
  // Set initial state based on whether this is a reattached call
88
92
  if (isReattached) {
89
93
  console.log('Call: Setting initial state to ACTIVE for reattached call');
@@ -156,7 +160,7 @@ class Call {
156
160
  /**
157
161
  * Custom headers received from the WebRTC INVITE message.
158
162
  * These headers are passed during call initiation and can contain application-specific information.
159
- * Format should be [{"name": "X-Header-Name", "value": "Value"}] where header names must start with "X-".
163
+ * Format: `[{"name": "X-Header-Name", "value": "Value"}]`; header names must start with `X-`.
160
164
  */
161
165
  get inviteCustomHeaders() {
162
166
  return this._telnyxCall.inviteCustomHeaders;
@@ -164,7 +168,7 @@ class Call {
164
168
  /**
165
169
  * Custom headers received from the WebRTC ANSWER message.
166
170
  * These headers are passed during call acceptance and can contain application-specific information.
167
- * Format should be [{"name": "X-Header-Name", "value": "Value"}] where header names must start with "X-".
171
+ * Format: `[{"name": "X-Header-Name", "value": "Value"}]`; header names must start with `X-`.
168
172
  */
169
173
  get answerCustomHeaders() {
170
174
  return this._telnyxCall.answerCustomHeaders;
@@ -176,6 +180,16 @@ class Call {
176
180
  get telnyxCall() {
177
181
  return this._telnyxCall;
178
182
  }
183
+ /**
184
+ * Inject an ICE failure into the underlying SDK recovery flow for manual
185
+ * testing. The underlying SDK accepts this only when debug mode is enabled.
186
+ *
187
+ * @returns true when a recovery episode was started.
188
+ */
189
+ simulateIceFailureForTesting() {
190
+ const testableCall = this._telnyxCall;
191
+ return testableCall.simulateIceFailureForTesting?.() ?? false;
192
+ }
179
193
  /**
180
194
  * Observable stream of call state changes
181
195
  */
@@ -200,6 +214,21 @@ class Call {
200
214
  get duration$() {
201
215
  return this._duration.asObservable().pipe((0, operators_1.distinctUntilChanged)());
202
216
  }
217
+ /**
218
+ * Observable stream of call quality metrics updates.
219
+ * Emits `null` initially, then a {@link CallQualityMetrics} object
220
+ * each time the underlying SDK collects a new sample.
221
+ */
222
+ get qualityMetrics$() {
223
+ return this._qualityMetrics.asObservable().pipe((0, operators_1.distinctUntilChanged)());
224
+ }
225
+ /**
226
+ * Current call quality metrics (synchronous access).
227
+ * Returns `null` until the first metrics sample is collected.
228
+ */
229
+ get currentQualityMetrics() {
230
+ return this._qualityMetrics.value;
231
+ }
203
232
  /**
204
233
  * Observable that emits true when the call can be answered
205
234
  */
@@ -433,10 +462,12 @@ class Call {
433
462
  */
434
463
  dispose() {
435
464
  this._stopDurationTimer();
465
+ this._telnyxCall.off?.('telnyx.call.qualityMetrics', this._onQualityMetrics);
436
466
  this._callState.complete();
437
467
  this._isMuted.complete();
438
468
  this._isHeld.complete();
439
469
  this._duration.complete();
470
+ this._qualityMetrics.complete();
440
471
  }
441
472
  /**
442
473
  * Set up listeners for the underlying Telnyx call
@@ -531,6 +562,10 @@ class Call {
531
562
  }
532
563
  }
533
564
  });
565
+ // Forward quality metrics from the underlying SDK
566
+ // Subscribe to the event rather than the single onQualityMetrics slot, so
567
+ // an app can still set that callback on the underlying call.
568
+ this._telnyxCall.on('telnyx.call.qualityMetrics', this._onQualityMetrics);
534
569
  }
535
570
  /**
536
571
  * Map Telnyx SDK call states to our simplified call states
@@ -1,6 +1,8 @@
1
+ import type { TelnyxIceServer } from '@telnyx/react-native-voice-sdk';
1
2
  /**
2
3
  * Configuration for credential-based authentication
3
4
  */
5
+ export type TelnyxEnvironment = 'production' | 'development';
4
6
  export interface CredentialConfig {
5
7
  type: 'credential';
6
8
  sipUser: string;
@@ -15,14 +17,27 @@ export interface CredentialConfig {
15
17
  incomingCallRingtone?: string;
16
18
  /** Enable Trickle ICE. Default: false */
17
19
  useTrickleIce?: boolean;
20
+ /** Signaling environment. Default: production */
21
+ environment?: TelnyxEnvironment;
18
22
  /** Enable automatic call quality reporting. Default: true */
19
23
  enableCallReports?: boolean;
24
+ /**
25
+ * Deliver real-time call quality metrics on `Call.qualityMetrics$` while a
26
+ * call is active. Also enabled by `debug`. Default: false
27
+ */
28
+ enableQualityMetrics?: boolean;
20
29
  /** Stats collection interval in seconds. Default: 5 */
21
30
  callReportInterval?: number;
22
31
  /** Minimum log level for call reports: 'debug' | 'info' | 'warn' | 'error'. Default: 'debug' */
23
32
  callReportLogLevel?: string;
24
33
  /** Max log entries per call. Default: 1000 */
25
34
  callReportMaxLogEntries?: number;
35
+ /**
36
+ * Custom ICE servers (STUN/TURN) for WebRTC peer connections.
37
+ * When provided, overrides the Telnyx production defaults.
38
+ * When omitted, the SDK uses Telnyx defaults (STUN → TURN UDP → TURN TCP → TURNS 443).
39
+ */
40
+ iceServers?: TelnyxIceServer[];
26
41
  }
27
42
  /**
28
43
  * Configuration for token-based authentication
@@ -40,14 +55,27 @@ export interface TokenConfig {
40
55
  incomingCallRingtone?: string;
41
56
  /** Enable Trickle ICE. Default: false */
42
57
  useTrickleIce?: boolean;
58
+ /** Signaling environment. Default: production */
59
+ environment?: TelnyxEnvironment;
43
60
  /** Enable automatic call quality reporting. Default: true */
44
61
  enableCallReports?: boolean;
62
+ /**
63
+ * Deliver real-time call quality metrics on `Call.qualityMetrics$` while a
64
+ * call is active. Also enabled by `debug`. Default: false
65
+ */
66
+ enableQualityMetrics?: boolean;
45
67
  /** Stats collection interval in seconds. Default: 5 */
46
68
  callReportInterval?: number;
47
69
  /** Minimum log level for call reports: 'debug' | 'info' | 'warn' | 'error'. Default: 'debug' */
48
70
  callReportLogLevel?: string;
49
71
  /** Max log entries per call. Default: 1000 */
50
72
  callReportMaxLogEntries?: number;
73
+ /**
74
+ * Custom ICE servers (STUN/TURN) for WebRTC peer connections.
75
+ * When provided, overrides the Telnyx production defaults.
76
+ * When omitted, the SDK uses Telnyx defaults (STUN → TURN UDP → TURN TCP → TURNS 443).
77
+ */
78
+ iceServers?: TelnyxIceServer[];
51
79
  }
52
80
  /**
53
81
  * Union type for all supported authentication configurations
@@ -1,7 +1,7 @@
1
1
  import { Observable } from 'rxjs';
2
2
  import { TelnyxConnectionState } from './models/connection-state';
3
3
  import { Call } from './models/call';
4
- import { CredentialConfig, TokenConfig } from './models/config';
4
+ import { CredentialConfig, TelnyxEnvironment, TokenConfig } from './models/config';
5
5
  import { type CustomHeaders } from './internal/calls/call-state-controller';
6
6
  /**
7
7
  * Configuration options for TelnyxVoipClient
@@ -13,6 +13,8 @@ export interface TelnyxVoipClientOptions {
13
13
  debug?: boolean;
14
14
  /** Enable Trickle ICE for calls created by this client */
15
15
  useTrickleIce?: boolean;
16
+ /** Signaling environment used for connections - default: production */
17
+ environment?: TelnyxEnvironment;
16
18
  }
17
19
  /**
18
20
  * The main public interface for the react-voice-commons module.
@@ -162,7 +164,7 @@ export declare class TelnyxVoipClient {
162
164
  * This method is used for auto-reconnection scenarios where the app
163
165
  * comes back to the foreground and needs to restore the connection.
164
166
  *
165
- * @returns Promise<boolean> - true if reconnection was successful, false otherwise
167
+ * @returns Whether reconnection was successful.
166
168
  */
167
169
  loginFromStoredConfig(): Promise<boolean>;
168
170
  /**
@@ -61,11 +61,14 @@ const connection_state_1 = require('./models/connection-state');
61
61
  const call_state_1 = require('./models/call-state');
62
62
  const config_1 = require('./models/config');
63
63
  const session_manager_1 = require('./internal/session/session-manager');
64
+ const signaling_environment_1 = require('./internal/signaling-environment');
64
65
  const call_state_controller_1 = require('./internal/calls/call-state-controller');
65
66
  const voice_pn_bridge_1 = require('./internal/voice-pn-bridge');
67
+ const ice_servers_storage_1 = require('./internal/ice-servers-storage');
66
68
  const USE_TRICKLE_ICE_STORAGE_KEY = '@use_trickle_ice';
67
69
  const PUSH_WHEN_ACTIVE_STORAGE_KEY = '@push_when_active';
68
70
  const MISSED_CALL_NOTIFICATIONS_STORAGE_KEY = '@enable_missed_call_notifications';
71
+ const QUALITY_METRICS_STORAGE_KEY = '@enable_quality_metrics';
69
72
  const LEGACY_CALLKIT_ANSWER_KEY = '__legacy_callkit_answer__';
70
73
  /**
71
74
  * The main public interface for the react-voice-commons module.
@@ -111,10 +114,13 @@ class TelnyxVoipClient {
111
114
  enableAppStateManagement: true,
112
115
  debug: false,
113
116
  useTrickleIce: false,
117
+ environment: 'production',
114
118
  ...options,
115
119
  };
116
120
  // Initialize core components
117
- this._sessionManager = new session_manager_1.SessionManager();
121
+ this._sessionManager = new session_manager_1.SessionManager({
122
+ defaultEnvironment: this._options.environment,
123
+ });
118
124
  this._callStateController = new call_state_controller_1.CallStateController(
119
125
  this._sessionManager
120
126
  );
@@ -308,6 +314,7 @@ class TelnyxVoipClient {
308
314
  const loginConfig = await this._withNativeVoipPushToken({
309
315
  ...config,
310
316
  useTrickleIce: config.useTrickleIce ?? this._options.useTrickleIce,
317
+ environment: config.environment ?? this._options.environment,
311
318
  });
312
319
  await voice_pn_bridge_1.VoicePnBridge.setIncomingCallRingtone(loginConfig.incomingCallRingtone);
313
320
  // Store credentials for future reconnection
@@ -335,6 +342,7 @@ class TelnyxVoipClient {
335
342
  const loginConfig = await this._withNativeVoipPushToken({
336
343
  ...config,
337
344
  useTrickleIce: config.useTrickleIce ?? this._options.useTrickleIce,
345
+ environment: config.environment ?? this._options.environment,
338
346
  });
339
347
  await voice_pn_bridge_1.VoicePnBridge.setIncomingCallRingtone(loginConfig.incomingCallRingtone);
340
348
  // Store token for future reconnection
@@ -362,7 +370,7 @@ class TelnyxVoipClient {
362
370
  * This method is used for auto-reconnection scenarios where the app
363
371
  * comes back to the foreground and needs to restore the connection.
364
372
  *
365
- * @returns Promise<boolean> - true if reconnection was successful, false otherwise
373
+ * @returns Whether reconnection was successful.
366
374
  */
367
375
  async loginFromStoredConfig() {
368
376
  this._throwIfDisposed();
@@ -381,10 +389,22 @@ class TelnyxVoipClient {
381
389
  const storedMissedCallNotifications = await AsyncStorage.getItem(
382
390
  MISSED_CALL_NOTIFICATIONS_STORAGE_KEY
383
391
  );
392
+ const storedEnvironment = await AsyncStorage.getItem(
393
+ signaling_environment_1.SIGNALING_ENVIRONMENT_STORAGE_KEY
394
+ );
395
+ const storedQualityMetrics = await AsyncStorage.getItem(QUALITY_METRICS_STORAGE_KEY);
396
+ const iceServers = (0, ice_servers_storage_1.parseStoredIceServers)(
397
+ await AsyncStorage.getItem(ice_servers_storage_1.ICE_SERVERS_STORAGE_KEY)
398
+ );
384
399
  const useTrickleIce =
385
400
  storedUseTrickleIce === null ? this._options.useTrickleIce : storedUseTrickleIce === 'true';
386
401
  const pushWhenActive = storedPushWhenActive === 'true';
387
402
  const enableMissedCallNotifications = storedMissedCallNotifications === 'true';
403
+ const enableQualityMetrics = storedQualityMetrics === 'true';
404
+ const environment = (0, signaling_environment_1.resolveStoredEnvironment)(
405
+ storedEnvironment,
406
+ this._options.environment
407
+ );
388
408
  const nativePushToken =
389
409
  react_native_1.Platform.OS === 'ios'
390
410
  ? (await voice_pn_bridge_1.VoicePnBridge.getVoipToken())?.trim()
@@ -402,6 +422,9 @@ class TelnyxVoipClient {
402
422
  useTrickleIce,
403
423
  pushWhenActive,
404
424
  enableMissedCallNotifications,
425
+ enableQualityMetrics,
426
+ environment,
427
+ iceServers,
405
428
  });
406
429
  if (this._options.debug) {
407
430
  console.log(
@@ -421,6 +444,9 @@ class TelnyxVoipClient {
421
444
  useTrickleIce,
422
445
  pushWhenActive,
423
446
  enableMissedCallNotifications,
447
+ enableQualityMetrics,
448
+ environment,
449
+ iceServers,
424
450
  });
425
451
  if (this._options.debug) {
426
452
  console.log('TelnyxVoipClient: Reconnecting with stored token');
@@ -682,6 +708,22 @@ class TelnyxVoipClient {
682
708
  MISSED_CALL_NOTIFICATIONS_STORAGE_KEY,
683
709
  String(config.enableMissedCallNotifications ?? false)
684
710
  );
711
+ await AsyncStorage.setItem(
712
+ QUALITY_METRICS_STORAGE_KEY,
713
+ String(config.enableQualityMetrics ?? false)
714
+ );
715
+ await AsyncStorage.setItem(
716
+ signaling_environment_1.SIGNALING_ENVIRONMENT_STORAGE_KEY,
717
+ config.environment ?? this._options.environment ?? 'production'
718
+ );
719
+ if (config.iceServers) {
720
+ await AsyncStorage.setItem(
721
+ ice_servers_storage_1.ICE_SERVERS_STORAGE_KEY,
722
+ JSON.stringify(config.iceServers)
723
+ );
724
+ } else {
725
+ await AsyncStorage.removeItem(ice_servers_storage_1.ICE_SERVERS_STORAGE_KEY);
726
+ }
685
727
  if (config.pushNotificationDeviceToken) {
686
728
  await AsyncStorage.setItem('@push_token', config.pushNotificationDeviceToken);
687
729
  }
@@ -716,6 +758,22 @@ class TelnyxVoipClient {
716
758
  MISSED_CALL_NOTIFICATIONS_STORAGE_KEY,
717
759
  String(config.enableMissedCallNotifications ?? false)
718
760
  );
761
+ await AsyncStorage.setItem(
762
+ QUALITY_METRICS_STORAGE_KEY,
763
+ String(config.enableQualityMetrics ?? false)
764
+ );
765
+ await AsyncStorage.setItem(
766
+ signaling_environment_1.SIGNALING_ENVIRONMENT_STORAGE_KEY,
767
+ config.environment ?? this._options.environment ?? 'production'
768
+ );
769
+ if (config.iceServers) {
770
+ await AsyncStorage.setItem(
771
+ ice_servers_storage_1.ICE_SERVERS_STORAGE_KEY,
772
+ JSON.stringify(config.iceServers)
773
+ );
774
+ } else {
775
+ await AsyncStorage.removeItem(ice_servers_storage_1.ICE_SERVERS_STORAGE_KEY);
776
+ }
719
777
  if (config.pushNotificationDeviceToken) {
720
778
  await AsyncStorage.setItem('@push_token', config.pushNotificationDeviceToken);
721
779
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telnyx/react-voice-commons-sdk",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "A high-level, state-agnostic, drop-in module for the Telnyx React Native SDK that simplifies WebRTC voice calling integration",
5
5
  "main": "lib/index.js",
6
6
  "module": "lib/index.js",
@@ -68,7 +68,7 @@
68
68
  "@react-native-async-storage/async-storage": "^2.1.0",
69
69
  "react": ">=19.0.0 <20.0.0",
70
70
  "react-native": ">=0.79.0 <1.0.0",
71
- "react-native-webrtc": "^124.0.5"
71
+ "react-native-webrtc": "^155.0.1"
72
72
  },
73
73
  "peerDependenciesMeta": {
74
74
  "@react-native-async-storage/async-storage": {
@@ -86,9 +86,8 @@
86
86
  },
87
87
  "dependencies": {
88
88
  "@react-native-community/eslint-config": "^3.2.0",
89
- "@telnyx/react-native-voice-sdk": ">=1.0.0",
89
+ "@telnyx/react-native-voice-sdk": "^1.2.0",
90
90
  "eventemitter3": "^5.0.1",
91
- "expo": "~53.0.22",
92
91
  "react-native-url-polyfill": "^3.0.0",
93
92
  "react-native-voip-push-notification": "^3.3.3",
94
93
  "rxjs": "^7.8.2"
@@ -4,6 +4,14 @@ import { Call } from '@telnyx/react-native-voice-sdk';
4
4
  import { VoicePnBridge } from '../internal/voice-pn-bridge';
5
5
  import type { TelnyxVoipClient } from '../telnyx-voip-client';
6
6
 
7
+ export type RemoteCallEndedReason = {
8
+ cause?: string;
9
+ causeCode?: number;
10
+ sipCode?: number;
11
+ sipReason?: string;
12
+ sipCallId?: string;
13
+ };
14
+
7
15
  type PendingSwap = {
8
16
  mode: 'swap' | 'restoring' | 'rollback';
9
17
  originalActiveCallKitUUID: string;
@@ -37,6 +45,15 @@ class CallKitCoordinator {
37
45
  // Tracks action+UUID pairs so an end action is not dropped merely because
38
46
  // an answer or held action for the same call is still completing.
39
47
  private processingCalls = new Set<string>();
48
+ private activeOrAnsweringCallUUID: string | null = null;
49
+
50
+ private claimActiveCall(callKitUUID: string): boolean {
51
+ if (this.activeOrAnsweringCallUUID !== null && this.activeOrAnsweringCallUUID !== callKitUUID) {
52
+ return false;
53
+ }
54
+ this.activeOrAnsweringCallUUID = callKitUUID;
55
+ return true;
56
+ }
40
57
 
41
58
  // Tracks calls that have already been ended in CallKit to prevent duplicate reports
42
59
  private endedCalls = new Set<string>();
@@ -184,6 +201,16 @@ class CallKitCoordinator {
184
201
  );
185
202
 
186
203
  if (success) {
204
+ if (!this.claimActiveCall(callKitUUID)) {
205
+ console.warn('CallKitCoordinator: Another call is already active or answering');
206
+ await CallKit.reportCallEnded(callKitUUID, CallEndReason.Failed);
207
+ try {
208
+ await call.hangup();
209
+ } finally {
210
+ this.cleanupCall(callKitUUID);
211
+ }
212
+ return null;
213
+ }
187
214
  this.callMap.set(callKitUUID, call);
188
215
  this.setupWebRTCCallListeners(call, callKitUUID);
189
216
  (call as any)._callKitUUID = callKitUUID;
@@ -208,6 +235,11 @@ class CallKitCoordinator {
208
235
  return false;
209
236
  }
210
237
 
238
+ if (!this.claimActiveCall(callKitUUID)) {
239
+ console.warn('CallKitCoordinator: Another call is already active or answering');
240
+ return false;
241
+ }
242
+
211
243
  console.log(
212
244
  'CallKitCoordinator: Answering call from UI using CallKit answer simulation',
213
245
  callKitUUID
@@ -240,6 +272,8 @@ class CallKitCoordinator {
240
272
  this.isCallFromPush = this.pendingPushCallUUIDs.size > 0;
241
273
  await this.clearMatchingPendingVoipPush(callKitUUID);
242
274
  console.log('CallKitCoordinator: CallKit answer success');
275
+ } else {
276
+ this.releaseActiveCall(callKitUUID);
243
277
  }
244
278
 
245
279
  return success;
@@ -418,6 +452,22 @@ class CallKitCoordinator {
418
452
  return;
419
453
  }
420
454
 
455
+ if (!this.claimActiveCall(callKitUUID)) {
456
+ console.warn(
457
+ 'CallKitCoordinator: Rejecting answer because another call owns the active slot'
458
+ );
459
+ await CallKit.reportCallEnded(callKitUUID, CallEndReason.Failed);
460
+ const rejectedCall = this.callMap.get(callKitUUID);
461
+ if (rejectedCall) {
462
+ try {
463
+ await rejectedCall.hangup();
464
+ } finally {
465
+ this.cleanupCall(callKitUUID);
466
+ }
467
+ }
468
+ return;
469
+ }
470
+
421
471
  const call = this.callMap.get(callKitUUID);
422
472
 
423
473
  if (!call) {
@@ -428,7 +478,10 @@ class CallKitCoordinator {
428
478
  });
429
479
 
430
480
  console.log('CallKitCoordinator: No WebRTC call found, handling as push notification');
431
- await this.handlePushNotificationAnswer(callKitUUID, event);
481
+ const queued = await this.handlePushNotificationAnswer(callKitUUID, event);
482
+ if (!queued) {
483
+ this.releaseActiveCall(callKitUUID);
484
+ }
432
485
  return;
433
486
  }
434
487
 
@@ -744,11 +797,44 @@ class CallKitCoordinator {
744
797
  }
745
798
  }
746
799
 
800
+ /** End a still-pending PushKit CallKit call after a remote hangup. */
801
+ async handlePendingPushCallRemoteEnded(
802
+ callKitUUID: string,
803
+ reason?: RemoteCallEndedReason
804
+ ): Promise<boolean> {
805
+ callKitUUID = this.normalizeUUID(callKitUUID);
806
+
807
+ // Do not let an unrelated/stale socket BYE terminate another CallKit call.
808
+ // The UUID is added here only after native CallKit registration succeeds.
809
+ if (!this.pendingPushCallUUIDs.has(callKitUUID)) {
810
+ console.log('CallKitCoordinator: Ignoring remote-ended event for unknown push UUID', {
811
+ callKitUUID,
812
+ reason,
813
+ });
814
+ return false;
815
+ }
816
+
817
+ console.log('CallKitCoordinator: Ending pending push call after remote hangup', {
818
+ callKitUUID,
819
+ reason,
820
+ });
821
+
822
+ if (!this.endedCalls.has(callKitUUID)) {
823
+ await CallKit.reportCallEnded(callKitUUID, CallEndReason.RemoteEnded);
824
+ this.endedCalls.add(callKitUUID);
825
+ }
826
+
827
+ this.cleanupCall(callKitUUID);
828
+ await this.clearMatchingPendingVoipPush(callKitUUID);
829
+ await this.checkBackgroundDisconnection();
830
+ return true;
831
+ }
832
+
747
833
  /**
748
834
  * Handle push notification answer - when user answers from CallKit but we don't have a WebRTC call yet
749
835
  * This is the iOS equivalent of the Android FCM handler
750
836
  */
751
- private async handlePushNotificationAnswer(callKitUUID: string, event?: any) {
837
+ private async handlePushNotificationAnswer(callKitUUID: string, event?: any): Promise<boolean> {
752
838
  try {
753
839
  console.log(
754
840
  'CallKitCoordinator: Handling push notification answer for CallKit UUID:',
@@ -770,7 +856,7 @@ class CallKitCoordinator {
770
856
  // checkForInitialPushNotification() will run after setVoipClient()
771
857
  // and will find the push data still intact, call handleCallKitPushReceived()
772
858
  // which queues the matching auto-answer.
773
- return;
859
+ return false;
774
860
  }
775
861
 
776
862
  // Queue one UUID-targeted answer. If the push is already being
@@ -779,22 +865,22 @@ class CallKitCoordinator {
779
865
  voipClient.queueAnswerFromCallKit(callKitUUID);
780
866
  if (this.pendingPushCallUUIDs.has(callKitUUID)) {
781
867
  this.autoAnswerCallUUIDs.delete(callKitUUID);
782
- return;
868
+ return true;
783
869
  }
784
870
 
785
- await this.handleCallKitPushReceived(callKitUUID, event);
786
-
787
- return;
871
+ return await this.handleCallKitPushReceived(callKitUUID, event);
788
872
  }
789
873
 
790
874
  // For other platforms (shouldn't happen on iOS)
791
875
  console.error('CallKitCoordinator: ❌ Unsupported platform for push notification handling');
792
876
  await CallKit.reportCallEnded(callKitUUID, CallEndReason.Failed);
877
+ return false;
793
878
  } catch (error) {
794
879
  console.error('CallKitCoordinator: ❌ Error handling push notification answer:', error);
795
880
  // Report the call as failed to CallKit
796
881
  await CallKit.reportCallEnded(callKitUUID, CallEndReason.Failed);
797
882
  this.cleanupCall(callKitUUID);
883
+ return false;
798
884
  }
799
885
  }
800
886
 
@@ -849,6 +935,18 @@ class CallKitCoordinator {
849
935
 
850
936
  switch (state) {
851
937
  case 'active':
938
+ if (!this.claimActiveCall(callKitUUID)) {
939
+ console.warn(
940
+ 'CallKitCoordinator: Ending active call because another call owns the slot'
941
+ );
942
+ await CallKit.reportCallEnded(callKitUUID, CallEndReason.Failed);
943
+ try {
944
+ await call.hangup();
945
+ } finally {
946
+ this.cleanupCall(callKitUUID);
947
+ }
948
+ break;
949
+ }
852
950
  this.selectCall(call);
853
951
  this.pendingPushCallUUIDs.delete(callKitUUID);
854
952
  this.autoAnswerCallUUIDs.delete(callKitUUID);
@@ -927,6 +1025,9 @@ class CallKitCoordinator {
927
1025
  this.connectedCalls.delete(callKitUUID);
928
1026
  this.pendingPushCallUUIDs.delete(callKitUUID);
929
1027
  this.autoAnswerCallUUIDs.delete(callKitUUID);
1028
+ if (this.activeOrAnsweringCallUUID === callKitUUID) {
1029
+ this.activeOrAnsweringCallUUID = null;
1030
+ }
930
1031
 
931
1032
  // Get the call before removing it
932
1033
  const call = this.callMap.get(callKitUUID);
@@ -1084,6 +1185,13 @@ class CallKitCoordinator {
1084
1185
  return `${action}:${this.normalizeUUID(callKitUUID)}`;
1085
1186
  }
1086
1187
 
1188
+ private releaseActiveCall(callKitUUID: string): void {
1189
+ callKitUUID = this.normalizeUUID(callKitUUID);
1190
+ if (this.activeOrAnsweringCallUUID === callKitUUID) {
1191
+ this.activeOrAnsweringCallUUID = null;
1192
+ }
1193
+ }
1194
+
1087
1195
  private selectCall(call: Call): void {
1088
1196
  const callKitUUID = this.getCallKitUUID(call);
1089
1197
  const wrapperCall = this.voipClient?.findCallByTelnyxCall(call);
@@ -1322,6 +1430,10 @@ class CallKitCoordinator {
1322
1430
 
1323
1431
  this.isCallFromPush = this.pendingPushCallUUIDs.size > 0;
1324
1432
 
1433
+ if (this.callMap.size === 0) {
1434
+ this.activeOrAnsweringCallUUID = null;
1435
+ }
1436
+
1325
1437
  console.log('CallKitCoordinator: ✅ Coordinator flags reset');
1326
1438
  }
1327
1439
 
@@ -1337,6 +1449,16 @@ class CallKitCoordinator {
1337
1449
  return this.processingCalls.size > 0 || this.isCallFromPush;
1338
1450
  }
1339
1451
 
1452
+ /** @internal Exposed for deterministic single-active-call regression tests. */
1453
+ claimActiveCallForTesting(callKitUUID: string): boolean {
1454
+ return this.claimActiveCall(callKitUUID);
1455
+ }
1456
+
1457
+ /** @internal Exposed for deterministic single-active-call regression tests. */
1458
+ releaseActiveCallForTesting(callKitUUID: string): void {
1459
+ this.releaseActiveCall(callKitUUID);
1460
+ }
1461
+
1340
1462
  /**
1341
1463
  * Check if there's currently a call from push notification being processed
1342
1464
  * This helps prevent disconnection during push call handling
package/src/index.ts CHANGED
@@ -47,11 +47,16 @@ export {
47
47
  createCredentialConfig,
48
48
  createTokenConfig,
49
49
  } from './models/config';
50
- export type { Config, CredentialConfig, TokenConfig } from './models/config';
50
+ export type { Config, CredentialConfig, TokenConfig, TelnyxEnvironment } from './models/config';
51
+ export type { TelnyxIceServer } from '@telnyx/react-native-voice-sdk';
51
52
 
52
53
  // Re-export useful types from the underlying SDK
53
54
  export type { Call as TelnyxCall } from '@telnyx/react-native-voice-sdk';
54
55
 
56
+ // Re-export call quality metrics types from the underlying SDK
57
+ export { CallQualityLevel } from '@telnyx/react-native-voice-sdk';
58
+ export type { CallQualityMetrics, QualityStatsRecord } from '@telnyx/react-native-voice-sdk';
59
+
55
60
  // Export CallKit functionality
56
61
  export * from './callkit';
57
62