@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
@@ -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.1",
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.1.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
 
@@ -4,7 +4,7 @@ import { TelnyxRTC, Call as TelnyxCall, type CallOptions } from '@telnyx/react-n
4
4
  import { Call } from '../../models/call';
5
5
  import { TelnyxCallState } from '../../models/call-state';
6
6
  import { SessionManager } from '../session/session-manager';
7
- import { callKitCoordinator } from '../../callkit/callkit-coordinator';
7
+ import { callKitCoordinator, type RemoteCallEndedReason } from '../../callkit/callkit-coordinator';
8
8
 
9
9
  export type CustomHeader = { name: string; value: string };
10
10
  export type CustomHeaders = Record<string, string> | CustomHeader[];
@@ -178,8 +178,28 @@ export class CallStateController {
178
178
  * This should be called by the session manager after client creation
179
179
  */
180
180
  initializeClientListeners(): void {
181
+ const telnyxClient = this._sessionManager.telnyxClient;
182
+
181
183
  console.log('CallStateController: initializeClientListeners called');
182
- console.log('CallStateController: Current client exists:', !!this._sessionManager.telnyxClient);
184
+ console.log('CallStateController: Current client exists:', !!telnyxClient);
185
+
186
+ if (!telnyxClient) {
187
+ console.log('CallStateController: No telnyxClient available, skipping listener setup');
188
+ return;
189
+ }
190
+
191
+ // Skip redundant listener setup — SessionManager calls _onClientReady
192
+ // twice during connect/reconnect (once before connection, once on
193
+ // telnyx.client.ready event). _setupClientListeners() already
194
+ // removes old listeners before re-adding, so this guard skips the
195
+ // redundant off→on churn rather than preventing actual duplicates.
196
+ if (this._listenerClient === telnyxClient) {
197
+ console.log(
198
+ 'CallStateController: Listeners already registered for this client instance, skipping'
199
+ );
200
+ return;
201
+ }
202
+
183
203
  this._setupClientListeners();
184
204
 
185
205
  // CallKit integration now handled by CallKitCoordinator
@@ -352,6 +372,11 @@ export class CallStateController {
352
372
  // Listen for call removal events from TelnyxRTC (multi-call support)
353
373
  telnyxClient.on('telnyx.call.removed', this._handleTelnyxCallRemoved);
354
374
 
375
+ // A caller can hang up while PushKit is reconnecting the socket, before
376
+ // an INVITE creates a Call object. Forward that terminal event so the
377
+ // already-presented CallKit call is dismissed.
378
+ telnyxClient.on('telnyx.call.remoteEnded', this._handleRemoteCallEnded);
379
+
355
380
  console.log('CallStateController: Client listeners set up successfully');
356
381
  }
357
382
 
@@ -364,6 +389,7 @@ export class CallStateController {
364
389
  this._listenerClient.off('telnyx.call.reattached', this._handleTelnyxReattachedCall);
365
390
  this._listenerClient.off('telnyx.call.stateChanged', this._handleTelnyxCallStateChanged);
366
391
  this._listenerClient.off('telnyx.call.removed', this._handleTelnyxCallRemoved);
392
+ this._listenerClient.off('telnyx.call.remoteEnded', this._handleRemoteCallEnded);
367
393
  this._listenerClient = undefined;
368
394
  }
369
395
 
@@ -377,6 +403,19 @@ export class CallStateController {
377
403
  this._handleIncomingCall(telnyxCall, msg, true);
378
404
  };
379
405
 
406
+ private _handleRemoteCallEnded = (callId: string, reason: RemoteCallEndedReason): void => {
407
+ console.log('CallStateController: Pending push call ended remotely', {
408
+ callId,
409
+ reason,
410
+ });
411
+
412
+ // A tracked call can also reach the existing state-listener teardown path.
413
+ // Both paths are intentionally idempotent so pre-INVITE calls are covered too.
414
+ void callKitCoordinator.handlePendingPushCallRemoteEnded(callId, reason).catch((error) => {
415
+ console.error('CallStateController: Failed to end pending push CallKit call:', error);
416
+ });
417
+ };
418
+
380
419
  private _handleTelnyxCallStateChanged = (telnyxCall: TelnyxCall, state: string): void => {
381
420
  console.log(
382
421
  'CallStateController: Call state changed from TelnyxRTC:',
@@ -0,0 +1,37 @@
1
+ import type { TelnyxIceServer } from '@telnyx/react-native-voice-sdk';
2
+
3
+ export const ICE_SERVERS_STORAGE_KEY = '@ice_servers';
4
+
5
+ /**
6
+ * Parse the ICE servers persisted by a previous login. A missing, malformed,
7
+ * or structurally invalid value yields `undefined` (the core SDK then uses the
8
+ * Telnyx defaults) rather than throwing, so a bad stored list never discards
9
+ * otherwise valid stored authentication.
10
+ */
11
+ export function parseStoredIceServers(stored: string | null): TelnyxIceServer[] | undefined {
12
+ if (!stored) {
13
+ return undefined;
14
+ }
15
+ try {
16
+ const parsed: unknown = JSON.parse(stored);
17
+ if (Array.isArray(parsed) && parsed.length > 0 && parsed.every(isIceServer)) {
18
+ return parsed;
19
+ }
20
+ } catch {
21
+ // Fall through to the warning below.
22
+ }
23
+ console.warn('Ignoring invalid stored ICE servers; using the SDK defaults');
24
+ return undefined;
25
+ }
26
+
27
+ function isIceServer(value: unknown): value is TelnyxIceServer {
28
+ if (!value || typeof value !== 'object') {
29
+ return false;
30
+ }
31
+ const server = value as Record<string, unknown>;
32
+ return (
33
+ typeof server.urls === 'string' &&
34
+ (server.username === undefined || typeof server.username === 'string') &&
35
+ (server.credential === undefined || typeof server.credential === 'string')
36
+ );
37
+ }
@@ -0,0 +1,6 @@
1
+ import * as pkg from '../../package.json';
2
+
3
+ export const REACT_NATIVE_COMMONS_SDK_IDENTITY = Object.freeze({
4
+ name: 'webrtc-react-native-commons',
5
+ version: pkg.version,
6
+ });
@@ -1,11 +1,17 @@
1
1
  import { BehaviorSubject, Observable } from 'rxjs';
2
2
  import { distinctUntilChanged } from 'rxjs/operators';
3
3
  import * as TelnyxSDK from '@telnyx/react-native-voice-sdk';
4
- import * as pkg from '../../../package.json';
4
+ import { REACT_NATIVE_COMMONS_SDK_IDENTITY } from '../sdk-identity';
5
+ import { ICE_SERVERS_STORAGE_KEY, parseStoredIceServers } from '../ice-servers-storage';
6
+ import {
7
+ SIGNALING_ENVIRONMENT_STORAGE_KEY,
8
+ resolveStoredEnvironment,
9
+ } from '../signaling-environment';
5
10
  import { TelnyxConnectionState } from '../../models/connection-state';
6
11
  import {
7
12
  Config,
8
13
  CredentialConfig,
14
+ TelnyxEnvironment,
9
15
  TokenConfig,
10
16
  isCredentialConfig,
11
17
  isTokenConfig,
@@ -14,6 +20,12 @@ import {
14
20
  const USE_TRICKLE_ICE_STORAGE_KEY = '@use_trickle_ice';
15
21
  const PUSH_WHEN_ACTIVE_STORAGE_KEY = '@push_when_active';
16
22
  const MISSED_CALL_NOTIFICATIONS_STORAGE_KEY = '@enable_missed_call_notifications';
23
+ const QUALITY_METRICS_STORAGE_KEY = '@enable_quality_metrics';
24
+
25
+ export interface SessionManagerOptions {
26
+ /** Environment used when a config or stored restore does not specify one */
27
+ defaultEnvironment?: TelnyxEnvironment;
28
+ }
17
29
 
18
30
  /**
19
31
  * Manages the connection lifecycle to the Telnyx platform.
@@ -35,9 +47,11 @@ export class SessionManager {
35
47
  private _connectPromise?: Promise<void>;
36
48
  private _onClientReady?: () => void;
37
49
  private _onDisconnect?: () => void;
50
+ private readonly _defaultEnvironment: TelnyxEnvironment;
38
51
 
39
- constructor() {
52
+ constructor(options: SessionManagerOptions = {}) {
40
53
  this._sessionId = this._generateSessionId();
54
+ this._defaultEnvironment = options.defaultEnvironment ?? 'production';
41
55
  }
42
56
 
43
57
  /**
@@ -243,9 +257,15 @@ export class SessionManager {
243
257
  const storedMissedCallNotifications = await AsyncStorage.getItem(
244
258
  MISSED_CALL_NOTIFICATIONS_STORAGE_KEY
245
259
  );
260
+ const storedEnvironment = await AsyncStorage.getItem(SIGNALING_ENVIRONMENT_STORAGE_KEY);
261
+ const storedQualityMetrics = await AsyncStorage.getItem(QUALITY_METRICS_STORAGE_KEY);
262
+ const storedIceServers = await AsyncStorage.getItem(ICE_SERVERS_STORAGE_KEY);
263
+ const iceServers = parseStoredIceServers(storedIceServers);
246
264
  const useTrickleIce = storedUseTrickleIce === 'true';
247
265
  const pushWhenActive = storedPushWhenActive === 'true';
248
266
  const enableMissedCallNotifications = storedMissedCallNotifications === 'true';
267
+ const enableQualityMetrics = storedQualityMetrics === 'true';
268
+ const environment = resolveStoredEnvironment(storedEnvironment, this._defaultEnvironment);
249
269
 
250
270
  if (this._isTeardownActive()) {
251
271
  return;
@@ -260,6 +280,9 @@ export class SessionManager {
260
280
  useTrickleIce,
261
281
  pushWhenActive,
262
282
  enableMissedCallNotifications,
283
+ enableQualityMetrics,
284
+ environment,
285
+ iceServers,
263
286
  });
264
287
  }
265
288
  // Check if we have token-based authentication data
@@ -271,6 +294,9 @@ export class SessionManager {
271
294
  useTrickleIce,
272
295
  pushWhenActive,
273
296
  enableMissedCallNotifications,
297
+ enableQualityMetrics,
298
+ environment,
299
+ iceServers,
274
300
  });
275
301
  }
276
302
 
@@ -485,11 +511,15 @@ export class SessionManager {
485
511
  pushWhenActive: config.pushWhenActive,
486
512
  enableMissedCallNotifications: config.enableMissedCallNotifications ?? false,
487
513
  useTrickleIce: config.useTrickleIce,
514
+ environment: config.environment ?? this._defaultEnvironment,
488
515
  enableCallReports: config.enableCallReports,
516
+ enableQualityMetrics: config.enableQualityMetrics,
489
517
  callReportInterval: config.callReportInterval,
490
518
  callReportLogLevel: config.callReportLogLevel,
491
519
  callReportMaxLogEntries: config.callReportMaxLogEntries,
492
- sdkVersion: pkg.version,
520
+ iceServers: config.iceServers,
521
+ sdkName: REACT_NATIVE_COMMONS_SDK_IDENTITY.name,
522
+ sdkVersion: REACT_NATIVE_COMMONS_SDK_IDENTITY.version,
493
523
  };
494
524
  console.log(
495
525
  '🔧 SessionManager: Creating TelnyxRTC with credential config, logLevel:',
@@ -506,11 +536,15 @@ export class SessionManager {
506
536
  pushWhenActive: config.pushWhenActive,
507
537
  enableMissedCallNotifications: config.enableMissedCallNotifications ?? false,
508
538
  useTrickleIce: config.useTrickleIce,
539
+ environment: config.environment ?? this._defaultEnvironment,
509
540
  enableCallReports: config.enableCallReports,
541
+ enableQualityMetrics: config.enableQualityMetrics,
510
542
  callReportInterval: config.callReportInterval,
511
543
  callReportLogLevel: config.callReportLogLevel,
512
544
  callReportMaxLogEntries: config.callReportMaxLogEntries,
513
- sdkVersion: pkg.version,
545
+ iceServers: config.iceServers,
546
+ sdkName: REACT_NATIVE_COMMONS_SDK_IDENTITY.name,
547
+ sdkVersion: REACT_NATIVE_COMMONS_SDK_IDENTITY.version,
514
548
  };
515
549
  console.log(
516
550
  '🔧 SessionManager: Creating TelnyxRTC with token config, logLevel:',