@telnyx/react-voice-commons-sdk 1.1.1 → 1.2.1

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 +45 -1
  2. package/TelnyxVoiceCommons.podspec +3 -3
  3. package/ios/CallKitBridge.swift +126 -44
  4. package/lib/callkit/callkit-coordinator.d.ts +12 -0
  5. package/lib/callkit/callkit-coordinator.js +32 -4
  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 +41 -3
  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 +49 -6
  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 +48 -3
  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/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:',
@@ -0,0 +1,20 @@
1
+ import { TelnyxEnvironment } from '../models/config';
2
+
3
+ export const SIGNALING_ENVIRONMENT_STORAGE_KEY = '@telnyx_signaling_environment';
4
+
5
+ /**
6
+ * Resolves the signaling environment for a stored-config restore.
7
+ *
8
+ * An explicit stored selection always wins. A missing or invalid stored value
9
+ * (e.g. credentials persisted by an older Commons version) falls back to the
10
+ * client's configured default, and only then to production.
11
+ */
12
+ export function resolveStoredEnvironment(
13
+ storedEnvironment: string | null | undefined,
14
+ defaultEnvironment?: TelnyxEnvironment
15
+ ): TelnyxEnvironment {
16
+ if (storedEnvironment === 'development' || storedEnvironment === 'production') {
17
+ return storedEnvironment;
18
+ }
19
+ return defaultEnvironment ?? 'production';
20
+ }
@@ -2,6 +2,7 @@ import { BehaviorSubject, Observable } from 'rxjs';
2
2
  import { distinctUntilChanged, map } from 'rxjs/operators';
3
3
  import { TelnyxCallState, CallStateHelpers } from './call-state';
4
4
  import { Call as TelnyxCall } from '@telnyx/react-native-voice-sdk';
5
+ import type { CallQualityMetrics } from '@telnyx/react-native-voice-sdk';
5
6
  import { Platform } from 'react-native';
6
7
 
7
8
  /**
@@ -16,6 +17,10 @@ export class Call {
16
17
  private readonly _isMuted = new BehaviorSubject<boolean>(false);
17
18
  private readonly _isHeld = new BehaviorSubject<boolean>(false);
18
19
  private readonly _duration = new BehaviorSubject<number>(0);
20
+ private readonly _qualityMetrics = new BehaviorSubject<CallQualityMetrics | null>(null);
21
+ private readonly _onQualityMetrics = (metrics: CallQualityMetrics) => {
22
+ this._qualityMetrics.next(metrics);
23
+ };
19
24
 
20
25
  private _durationTimer?: NodeJS.Timeout;
21
26
  private _startTime?: Date;
@@ -113,7 +118,7 @@ export class Call {
113
118
  /**
114
119
  * Custom headers received from the WebRTC INVITE message.
115
120
  * These headers are passed during call initiation and can contain application-specific information.
116
- * Format should be [{"name": "X-Header-Name", "value": "Value"}] where header names must start with "X-".
121
+ * Format: `[{"name": "X-Header-Name", "value": "Value"}]`; header names must start with `X-`.
117
122
  */
118
123
  get inviteCustomHeaders(): { name: string; value: string }[] | null {
119
124
  return this._telnyxCall.inviteCustomHeaders;
@@ -122,7 +127,7 @@ export class Call {
122
127
  /**
123
128
  * Custom headers received from the WebRTC ANSWER message.
124
129
  * These headers are passed during call acceptance and can contain application-specific information.
125
- * Format should be [{"name": "X-Header-Name", "value": "Value"}] where header names must start with "X-".
130
+ * Format: `[{"name": "X-Header-Name", "value": "Value"}]`; header names must start with `X-`.
126
131
  */
127
132
  get answerCustomHeaders(): { name: string; value: string }[] | null {
128
133
  return this._telnyxCall.answerCustomHeaders;
@@ -136,6 +141,19 @@ export class Call {
136
141
  return this._telnyxCall;
137
142
  }
138
143
 
144
+ /**
145
+ * Inject an ICE failure into the underlying SDK recovery flow for manual
146
+ * testing. The underlying SDK accepts this only when debug mode is enabled.
147
+ *
148
+ * @returns true when a recovery episode was started.
149
+ */
150
+ simulateIceFailureForTesting(): boolean {
151
+ const testableCall = this._telnyxCall as TelnyxCall & {
152
+ simulateIceFailureForTesting?: () => boolean;
153
+ };
154
+ return testableCall.simulateIceFailureForTesting?.() ?? false;
155
+ }
156
+
139
157
  /**
140
158
  * Observable stream of call state changes
141
159
  */
@@ -164,6 +182,23 @@ export class Call {
164
182
  return this._duration.asObservable().pipe(distinctUntilChanged());
165
183
  }
166
184
 
185
+ /**
186
+ * Observable stream of call quality metrics updates.
187
+ * Emits `null` initially, then a {@link CallQualityMetrics} object
188
+ * each time the underlying SDK collects a new sample.
189
+ */
190
+ get qualityMetrics$(): Observable<CallQualityMetrics | null> {
191
+ return this._qualityMetrics.asObservable().pipe(distinctUntilChanged());
192
+ }
193
+
194
+ /**
195
+ * Current call quality metrics (synchronous access).
196
+ * Returns `null` until the first metrics sample is collected.
197
+ */
198
+ get currentQualityMetrics(): CallQualityMetrics | null {
199
+ return this._qualityMetrics.value;
200
+ }
201
+
167
202
  /**
168
203
  * Observable that emits true when the call can be answered
169
204
  */
@@ -219,7 +254,10 @@ export class Call {
219
254
  const { callKitCoordinator } = await import('../callkit/callkit-coordinator');
220
255
  if (callKitCoordinator.isAvailable()) {
221
256
  console.log('Call: Using CallKit coordinator to answer call (iOS)');
222
- await callKitCoordinator.answerCallFromUI(this._telnyxCall);
257
+ const answered = await callKitCoordinator.answerCallFromUI(this._telnyxCall);
258
+ if (!answered) {
259
+ throw new Error('CallKit failed to answer call');
260
+ }
223
261
  return;
224
262
  }
225
263
  }
@@ -413,10 +451,12 @@ export class Call {
413
451
  */
414
452
  dispose(): void {
415
453
  this._stopDurationTimer();
454
+ this._telnyxCall.off?.('telnyx.call.qualityMetrics', this._onQualityMetrics);
416
455
  this._callState.complete();
417
456
  this._isMuted.complete();
418
457
  this._isHeld.complete();
419
458
  this._duration.complete();
459
+ this._qualityMetrics.complete();
420
460
  }
421
461
 
422
462
  /**
@@ -518,6 +558,11 @@ export class Call {
518
558
  }
519
559
  }
520
560
  });
561
+
562
+ // Forward quality metrics from the underlying SDK
563
+ // Subscribe to the event rather than the single onQualityMetrics slot, so
564
+ // an app can still set that callback on the underlying call.
565
+ this._telnyxCall.on('telnyx.call.qualityMetrics', this._onQualityMetrics);
521
566
  }
522
567
 
523
568
  /**
@@ -1,6 +1,10 @@
1
+ import type { TelnyxIceServer } from '@telnyx/react-native-voice-sdk';
2
+
1
3
  /**
2
4
  * Configuration for credential-based authentication
3
5
  */
6
+ export type TelnyxEnvironment = 'production' | 'development';
7
+
4
8
  export interface CredentialConfig {
5
9
  type: 'credential';
6
10
  sipUser: string;
@@ -15,14 +19,27 @@ export interface CredentialConfig {
15
19
  incomingCallRingtone?: string;
16
20
  /** Enable Trickle ICE. Default: false */
17
21
  useTrickleIce?: boolean;
22
+ /** Signaling environment. Default: production */
23
+ environment?: TelnyxEnvironment;
18
24
  /** Enable automatic call quality reporting. Default: true */
19
25
  enableCallReports?: boolean;
26
+ /**
27
+ * Deliver real-time call quality metrics on `Call.qualityMetrics$` while a
28
+ * call is active. Also enabled by `debug`. Default: false
29
+ */
30
+ enableQualityMetrics?: boolean;
20
31
  /** Stats collection interval in seconds. Default: 5 */
21
32
  callReportInterval?: number;
22
33
  /** Minimum log level for call reports: 'debug' | 'info' | 'warn' | 'error'. Default: 'debug' */
23
34
  callReportLogLevel?: string;
24
35
  /** Max log entries per call. Default: 1000 */
25
36
  callReportMaxLogEntries?: number;
37
+ /**
38
+ * Custom ICE servers (STUN/TURN) for WebRTC peer connections.
39
+ * When provided, overrides the Telnyx production defaults.
40
+ * When omitted, the SDK uses Telnyx defaults (STUN → TURN UDP → TURN TCP → TURNS 443).
41
+ */
42
+ iceServers?: TelnyxIceServer[];
26
43
  }
27
44
 
28
45
  /**
@@ -41,14 +58,27 @@ export interface TokenConfig {
41
58
  incomingCallRingtone?: string;
42
59
  /** Enable Trickle ICE. Default: false */
43
60
  useTrickleIce?: boolean;
61
+ /** Signaling environment. Default: production */
62
+ environment?: TelnyxEnvironment;
44
63
  /** Enable automatic call quality reporting. Default: true */
45
64
  enableCallReports?: boolean;
65
+ /**
66
+ * Deliver real-time call quality metrics on `Call.qualityMetrics$` while a
67
+ * call is active. Also enabled by `debug`. Default: false
68
+ */
69
+ enableQualityMetrics?: boolean;
46
70
  /** Stats collection interval in seconds. Default: 5 */
47
71
  callReportInterval?: number;
48
72
  /** Minimum log level for call reports: 'debug' | 'info' | 'warn' | 'error'. Default: 'debug' */
49
73
  callReportLogLevel?: string;
50
74
  /** Max log entries per call. Default: 1000 */
51
75
  callReportMaxLogEntries?: number;
76
+ /**
77
+ * Custom ICE servers (STUN/TURN) for WebRTC peer connections.
78
+ * When provided, overrides the Telnyx production defaults.
79
+ * When omitted, the SDK uses Telnyx defaults (STUN → TURN UDP → TURN TCP → TURNS 443).
80
+ */
81
+ iceServers?: TelnyxIceServer[];
52
82
  }
53
83
 
54
84
  /**
@@ -3,14 +3,26 @@ import { Platform } from 'react-native';
3
3
  import { TelnyxConnectionState } from './models/connection-state';
4
4
  import { Call } from './models/call';
5
5
  import { TelnyxCallState } from './models/call-state';
6
- import { Config, CredentialConfig, TokenConfig, validateConfig } from './models/config';
6
+ import {
7
+ Config,
8
+ CredentialConfig,
9
+ TelnyxEnvironment,
10
+ TokenConfig,
11
+ validateConfig,
12
+ } from './models/config';
7
13
  import { SessionManager } from './internal/session/session-manager';
14
+ import {
15
+ SIGNALING_ENVIRONMENT_STORAGE_KEY,
16
+ resolveStoredEnvironment,
17
+ } from './internal/signaling-environment';
8
18
  import { CallStateController, type CustomHeaders } from './internal/calls/call-state-controller';
9
19
  import { VoicePnBridge } from './internal/voice-pn-bridge';
20
+ import { ICE_SERVERS_STORAGE_KEY, parseStoredIceServers } from './internal/ice-servers-storage';
10
21
 
11
22
  const USE_TRICKLE_ICE_STORAGE_KEY = '@use_trickle_ice';
12
23
  const PUSH_WHEN_ACTIVE_STORAGE_KEY = '@push_when_active';
13
24
  const MISSED_CALL_NOTIFICATIONS_STORAGE_KEY = '@enable_missed_call_notifications';
25
+ const QUALITY_METRICS_STORAGE_KEY = '@enable_quality_metrics';
14
26
  const LEGACY_CALLKIT_ANSWER_KEY = '__legacy_callkit_answer__';
15
27
 
16
28
  interface PendingCallKitAnswer {
@@ -30,6 +42,9 @@ export interface TelnyxVoipClientOptions {
30
42
 
31
43
  /** Enable Trickle ICE for calls created by this client */
32
44
  useTrickleIce?: boolean;
45
+
46
+ /** Signaling environment used for connections - default: production */
47
+ environment?: TelnyxEnvironment;
33
48
  }
34
49
 
35
50
  /**
@@ -84,11 +99,12 @@ export class TelnyxVoipClient {
84
99
  enableAppStateManagement: true,
85
100
  debug: false,
86
101
  useTrickleIce: false,
102
+ environment: 'production',
87
103
  ...options,
88
104
  };
89
105
 
90
106
  // Initialize core components
91
- this._sessionManager = new SessionManager();
107
+ this._sessionManager = new SessionManager({ defaultEnvironment: this._options.environment });
92
108
  this._callStateController = new CallStateController(this._sessionManager);
93
109
 
94
110
  // Set up callback to initialize call state controller listeners when client is ready
@@ -304,6 +320,7 @@ export class TelnyxVoipClient {
304
320
  const loginConfig = await this._withNativeVoipPushToken({
305
321
  ...config,
306
322
  useTrickleIce: config.useTrickleIce ?? this._options.useTrickleIce,
323
+ environment: config.environment ?? this._options.environment,
307
324
  });
308
325
 
309
326
  await VoicePnBridge.setIncomingCallRingtone(loginConfig.incomingCallRingtone);
@@ -338,6 +355,7 @@ export class TelnyxVoipClient {
338
355
  const loginConfig = await this._withNativeVoipPushToken({
339
356
  ...config,
340
357
  useTrickleIce: config.useTrickleIce ?? this._options.useTrickleIce,
358
+ environment: config.environment ?? this._options.environment,
341
359
  });
342
360
 
343
361
  await VoicePnBridge.setIncomingCallRingtone(loginConfig.incomingCallRingtone);
@@ -372,7 +390,7 @@ export class TelnyxVoipClient {
372
390
  * This method is used for auto-reconnection scenarios where the app
373
391
  * comes back to the foreground and needs to restore the connection.
374
392
  *
375
- * @returns Promise<boolean> - true if reconnection was successful, false otherwise
393
+ * @returns Whether reconnection was successful.
376
394
  */
377
395
  async loginFromStoredConfig(): Promise<boolean> {
378
396
  this._throwIfDisposed();
@@ -394,10 +412,15 @@ export class TelnyxVoipClient {
394
412
  const storedMissedCallNotifications = await AsyncStorage.getItem(
395
413
  MISSED_CALL_NOTIFICATIONS_STORAGE_KEY
396
414
  );
415
+ const storedEnvironment = await AsyncStorage.getItem(SIGNALING_ENVIRONMENT_STORAGE_KEY);
416
+ const storedQualityMetrics = await AsyncStorage.getItem(QUALITY_METRICS_STORAGE_KEY);
417
+ const iceServers = parseStoredIceServers(await AsyncStorage.getItem(ICE_SERVERS_STORAGE_KEY));
397
418
  const useTrickleIce =
398
419
  storedUseTrickleIce === null ? this._options.useTrickleIce : storedUseTrickleIce === 'true';
399
420
  const pushWhenActive = storedPushWhenActive === 'true';
400
421
  const enableMissedCallNotifications = storedMissedCallNotifications === 'true';
422
+ const enableQualityMetrics = storedQualityMetrics === 'true';
423
+ const environment = resolveStoredEnvironment(storedEnvironment, this._options.environment);
401
424
  const nativePushToken =
402
425
  Platform.OS === 'ios' ? (await VoicePnBridge.getVoipToken())?.trim() : null;
403
426
  const pushNotificationDeviceToken = nativePushToken || storedPushToken;
@@ -415,6 +438,9 @@ export class TelnyxVoipClient {
415
438
  useTrickleIce,
416
439
  pushWhenActive,
417
440
  enableMissedCallNotifications,
441
+ enableQualityMetrics,
442
+ environment,
443
+ iceServers,
418
444
  });
419
445
 
420
446
  if (this._options.debug) {
@@ -437,6 +463,9 @@ export class TelnyxVoipClient {
437
463
  useTrickleIce,
438
464
  pushWhenActive,
439
465
  enableMissedCallNotifications,
466
+ enableQualityMetrics,
467
+ environment,
468
+ iceServers,
440
469
  });
441
470
 
442
471
  if (this._options.debug) {
@@ -750,6 +779,20 @@ export class TelnyxVoipClient {
750
779
  MISSED_CALL_NOTIFICATIONS_STORAGE_KEY,
751
780
  String(config.enableMissedCallNotifications ?? false)
752
781
  );
782
+ await AsyncStorage.setItem(
783
+ QUALITY_METRICS_STORAGE_KEY,
784
+ String(config.enableQualityMetrics ?? false)
785
+ );
786
+ await AsyncStorage.setItem(
787
+ SIGNALING_ENVIRONMENT_STORAGE_KEY,
788
+ config.environment ?? this._options.environment ?? 'production'
789
+ );
790
+
791
+ if (config.iceServers) {
792
+ await AsyncStorage.setItem(ICE_SERVERS_STORAGE_KEY, JSON.stringify(config.iceServers));
793
+ } else {
794
+ await AsyncStorage.removeItem(ICE_SERVERS_STORAGE_KEY);
795
+ }
753
796
 
754
797
  if (config.pushNotificationDeviceToken) {
755
798
  await AsyncStorage.setItem('@push_token', config.pushNotificationDeviceToken);
@@ -789,6 +832,20 @@ export class TelnyxVoipClient {
789
832
  MISSED_CALL_NOTIFICATIONS_STORAGE_KEY,
790
833
  String(config.enableMissedCallNotifications ?? false)
791
834
  );
835
+ await AsyncStorage.setItem(
836
+ QUALITY_METRICS_STORAGE_KEY,
837
+ String(config.enableQualityMetrics ?? false)
838
+ );
839
+ await AsyncStorage.setItem(
840
+ SIGNALING_ENVIRONMENT_STORAGE_KEY,
841
+ config.environment ?? this._options.environment ?? 'production'
842
+ );
843
+
844
+ if (config.iceServers) {
845
+ await AsyncStorage.setItem(ICE_SERVERS_STORAGE_KEY, JSON.stringify(config.iceServers));
846
+ } else {
847
+ await AsyncStorage.removeItem(ICE_SERVERS_STORAGE_KEY);
848
+ }
792
849
 
793
850
  if (config.pushNotificationDeviceToken) {
794
851
  await AsyncStorage.setItem('@push_token', config.pushNotificationDeviceToken);