@telnyx/react-voice-commons-sdk 0.4.3 → 1.1.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 (47) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +28 -7
  3. package/android/src/main/java/com/telnyx/react_voice_commons/CallForegroundService.kt +16 -2
  4. package/android/src/main/java/com/telnyx/react_voice_commons/TelnyxFirebaseMessagingService.kt +16 -2
  5. package/android/src/main/java/com/telnyx/react_voice_commons/TelnyxNotificationHelper.kt +345 -24
  6. package/android/src/main/java/com/telnyx/react_voice_commons/VoicePnBridgeModule.kt +74 -1
  7. package/android/src/main/java/com/telnyx/react_voice_commons/VoicePnManager.kt +36 -1
  8. package/ios/CallKitBridge.m +20 -1
  9. package/ios/CallKitBridge.swift +935 -60
  10. package/ios/README.md +2 -2
  11. package/ios/VoicePnBridge.m +21 -1
  12. package/ios/VoicePnBridge.swift +62 -1
  13. package/lib/callkit/callkit-coordinator.d.ts +39 -3
  14. package/lib/callkit/callkit-coordinator.js +561 -148
  15. package/lib/callkit/callkit.d.ts +7 -0
  16. package/lib/callkit/callkit.js +83 -11
  17. package/lib/hooks/useAppStateHandler.js +6 -2
  18. package/lib/index.d.ts +1 -0
  19. package/lib/index.js +9 -0
  20. package/lib/internal/CallKitHandler.d.ts +3 -0
  21. package/lib/internal/CallKitHandler.js +52 -46
  22. package/lib/internal/calls/call-state-controller.d.ts +39 -1
  23. package/lib/internal/calls/call-state-controller.js +152 -64
  24. package/lib/internal/session/session-manager.d.ts +16 -1
  25. package/lib/internal/session/session-manager.js +229 -58
  26. package/lib/internal/voice-pn-bridge.d.ts +44 -0
  27. package/lib/internal/voice-pn-bridge.js +94 -0
  28. package/lib/models/call.js +35 -0
  29. package/lib/models/config.d.ts +18 -0
  30. package/lib/models/config.js +2 -0
  31. package/lib/telnyx-voice-app.js +85 -43
  32. package/lib/telnyx-voip-client.d.ts +48 -5
  33. package/lib/telnyx-voip-client.js +276 -22
  34. package/package.json +7 -5
  35. package/src/callkit/callkit-coordinator.ts +642 -160
  36. package/src/callkit/callkit.ts +110 -12
  37. package/src/hooks/useAppStateHandler.ts +7 -2
  38. package/src/index.ts +3 -0
  39. package/src/internal/CallKitHandler.tsx +60 -55
  40. package/src/internal/calls/call-state-controller.ts +173 -74
  41. package/src/internal/session/session-manager.ts +277 -61
  42. package/src/internal/voice-pn-bridge.ts +111 -0
  43. package/src/models/call.ts +34 -0
  44. package/src/models/config.ts +18 -0
  45. package/src/telnyx-voice-app.tsx +96 -45
  46. package/src/telnyx-voip-client.ts +260 -24
  47. package/src/types/telnyx-sdk.d.ts +12 -1
@@ -11,6 +11,10 @@ import {
11
11
  isTokenConfig,
12
12
  } from '../../models/config';
13
13
 
14
+ const USE_TRICKLE_ICE_STORAGE_KEY = '@use_trickle_ice';
15
+ const PUSH_WHEN_ACTIVE_STORAGE_KEY = '@push_when_active';
16
+ const MISSED_CALL_NOTIFICATIONS_STORAGE_KEY = '@enable_missed_call_notifications';
17
+
14
18
  /**
15
19
  * Manages the connection lifecycle to the Telnyx platform.
16
20
  *
@@ -25,6 +29,10 @@ export class SessionManager {
25
29
  private _currentConfig?: Config;
26
30
  private _sessionId: string;
27
31
  private _disposed = false;
32
+ private _disposing = false;
33
+ private _connectionGeneration = 0;
34
+ private _disposePromise?: Promise<void>;
35
+ private _connectPromise?: Promise<void>;
28
36
  private _onClientReady?: () => void;
29
37
  private _onDisconnect?: () => void;
30
38
 
@@ -75,28 +83,28 @@ export class SessionManager {
75
83
  return this._telnyxClient;
76
84
  }
77
85
 
86
+ get useTrickleIce(): boolean {
87
+ return Boolean(this._currentConfig?.useTrickleIce);
88
+ }
89
+
78
90
  /**
79
91
  * Connect using credential authentication
80
92
  */
81
93
  async connectWithCredential(config: CredentialConfig): Promise<void> {
82
- if (this._disposed) {
83
- throw new Error('SessionManager has been disposed');
84
- }
85
-
94
+ this._assertCanStartConnection();
86
95
  this._currentConfig = config;
87
96
  await this._connect();
97
+ this._assertCanStartConnection();
88
98
  }
89
99
 
90
100
  /**
91
101
  * Connect using token authentication
92
102
  */
93
103
  async connectWithToken(config: TokenConfig): Promise<void> {
94
- if (this._disposed) {
95
- throw new Error('SessionManager has been disposed');
96
- }
97
-
104
+ this._assertCanStartConnection();
98
105
  this._currentConfig = config;
99
106
  await this._connect();
107
+ this._assertCanStartConnection();
100
108
  }
101
109
 
102
110
  /**
@@ -114,6 +122,7 @@ export class SessionManager {
114
122
  return;
115
123
  }
116
124
 
125
+ this._connectionGeneration += 1;
117
126
  this._currentConfig = undefined;
118
127
  this._connectionState.next(TelnyxConnectionState.DISCONNECTED);
119
128
 
@@ -126,11 +135,7 @@ export class SessionManager {
126
135
  }
127
136
 
128
137
  if (this._telnyxClient) {
129
- try {
130
- await this._telnyxClient.disconnect();
131
- } catch (error) {
132
- console.error('Error during disconnect:', error);
133
- }
138
+ await this._disconnectAndForgetClient(this._telnyxClient, 'Error during disconnect:');
134
139
  }
135
140
  }
136
141
 
@@ -140,7 +145,11 @@ export class SessionManager {
140
145
  * which sends a 'telnyx_rtc.disable_push_notification' message via the socket.
141
146
  */
142
147
  disablePushNotifications(): void {
143
- if (this._telnyxClient && this.currentState === TelnyxConnectionState.CONNECTED) {
148
+ if (
149
+ !this._isTeardownActive() &&
150
+ this._telnyxClient &&
151
+ this.currentState === TelnyxConnectionState.CONNECTED
152
+ ) {
144
153
  console.log('SessionManager: Disabling push notifications for session:', this._sessionId);
145
154
  this._telnyxClient.disablePushNotification();
146
155
  } else {
@@ -152,7 +161,7 @@ export class SessionManager {
152
161
  * Handle push notification with stored config
153
162
  */
154
163
  handlePushNotificationWithConfig(pushMetaData: any, config: Config): void {
155
- if (this._disposed) {
164
+ if (this._isTeardownActive()) {
156
165
  return;
157
166
  }
158
167
 
@@ -166,7 +175,7 @@ export class SessionManager {
166
175
  * Handle push notification (async version)
167
176
  */
168
177
  async handlePushNotification(payload: Record<string, any>): Promise<void> {
169
- if (this._disposed) {
178
+ if (this._isTeardownActive()) {
170
179
  return;
171
180
  }
172
181
 
@@ -178,19 +187,38 @@ export class SessionManager {
178
187
  // Store the push notification payload for when the client is created
179
188
  (this as any)._pendingPushPayload = payload;
180
189
 
181
- // Each push always rebuilds the TelnyxRTC client. The voice_sdk_id is
182
- // baked into the WebSocket URL at socket-open time and can't be mutated
183
- // mid-flight; reusing a connection from a previous push means the
184
- // gateway routes THIS push's INVITE to a different (correctly-stamped)
185
- // client and we sit on the wrong socket forever.
186
- if (this._telnyxClient) {
187
- try {
188
- await this._telnyxClient.disconnect();
189
- } catch (err) {
190
- console.warn('SessionManager: disconnect of prior client threw:', err);
190
+ // A second-call push must stay on the client that owns the active/held
191
+ // call. Tearing it down destroys the first signaling session and clears
192
+ // the call collection before CallKit can complete Hold & Accept.
193
+ if (this._telnyxClient && this._hasActiveOrHeldCall(this._telnyxClient)) {
194
+ const actualPayload = this._extractPushPayload(payload);
195
+ const processVoIPNotification = (this._telnyxClient as any).processVoIPNotification;
196
+
197
+ if (typeof processVoIPNotification !== 'function') {
198
+ throw new Error('TelnyxRTC client cannot process an active-call VoIP notification');
191
199
  }
200
+
201
+ console.log(
202
+ 'SessionManager: Preserving active client while processing second-call push notification'
203
+ );
204
+ processVoIPNotification.call(this._telnyxClient, actualPayload);
205
+ (this as any)._pendingPushPayload = null;
206
+ return;
207
+ }
208
+
209
+ // The cold/no-active-call path still rebuilds the client so the socket is
210
+ // stamped with the push voice_sdk_id before connecting.
211
+ if (this._telnyxClient) {
212
+ await this._disconnectAndForgetClient(
213
+ this._telnyxClient,
214
+ 'SessionManager: disconnect of prior client threw:'
215
+ );
192
216
  }
193
- this._telnyxClient = undefined;
217
+
218
+ if (this._isTeardownActive()) {
219
+ return;
220
+ }
221
+
194
222
  if (this.currentState !== TelnyxConnectionState.DISCONNECTED) {
195
223
  this._connectionState.next(TelnyxConnectionState.DISCONNECTED);
196
224
  }
@@ -210,6 +238,18 @@ export class SessionManager {
210
238
  const storedPassword = await AsyncStorage.getItem('@telnyx_password');
211
239
  const storedCredentialToken = await AsyncStorage.getItem('@credential_token');
212
240
  const storedPushToken = await AsyncStorage.getItem('@push_token');
241
+ const storedUseTrickleIce = await AsyncStorage.getItem(USE_TRICKLE_ICE_STORAGE_KEY);
242
+ const storedPushWhenActive = await AsyncStorage.getItem(PUSH_WHEN_ACTIVE_STORAGE_KEY);
243
+ const storedMissedCallNotifications = await AsyncStorage.getItem(
244
+ MISSED_CALL_NOTIFICATIONS_STORAGE_KEY
245
+ );
246
+ const useTrickleIce = storedUseTrickleIce === 'true';
247
+ const pushWhenActive = storedPushWhenActive === 'true';
248
+ const enableMissedCallNotifications = storedMissedCallNotifications === 'true';
249
+
250
+ if (this._isTeardownActive()) {
251
+ return;
252
+ }
213
253
 
214
254
  // Check if we have credential-based authentication data
215
255
  if (storedUsername && storedPassword) {
@@ -217,6 +257,9 @@ export class SessionManager {
217
257
  const { createCredentialConfig } = require('../../models/config');
218
258
  this._currentConfig = createCredentialConfig(storedUsername, storedPassword, {
219
259
  pushNotificationDeviceToken: storedPushToken,
260
+ useTrickleIce,
261
+ pushWhenActive,
262
+ enableMissedCallNotifications,
220
263
  });
221
264
  }
222
265
  // Check if we have token-based authentication data
@@ -225,6 +268,9 @@ export class SessionManager {
225
268
  const { createTokenConfig } = require('../../models/config');
226
269
  this._currentConfig = createTokenConfig(storedCredentialToken, {
227
270
  pushNotificationDeviceToken: storedPushToken,
271
+ useTrickleIce,
272
+ pushWhenActive,
273
+ enableMissedCallNotifications,
228
274
  });
229
275
  }
230
276
 
@@ -240,6 +286,10 @@ export class SessionManager {
240
286
  }
241
287
  }
242
288
 
289
+ if (this._isTeardownActive()) {
290
+ return;
291
+ }
292
+
243
293
  // If we already have a client, process the push notification immediately
244
294
  if (this._telnyxClient) {
245
295
  console.log(
@@ -289,10 +339,16 @@ export class SessionManager {
289
339
  );
290
340
  try {
291
341
  await this._connect();
342
+ if (this._isTeardownActive()) {
343
+ return;
344
+ }
292
345
  console.log(
293
346
  'SessionManager: RELEASE DEBUG - Successfully connected after push notification trigger'
294
347
  );
295
348
  } catch (error) {
349
+ if (this._isTeardownActive()) {
350
+ return;
351
+ }
296
352
  console.error(
297
353
  'SessionManager: Failed to connect after push notification trigger:',
298
354
  error
@@ -314,80 +370,160 @@ export class SessionManager {
314
370
  console.log('SessionManager: RELEASE DEBUG - Push notification handling complete');
315
371
  }
316
372
 
373
+ private _hasActiveOrHeldCall(client: TelnyxSDK.TelnyxRTC): boolean {
374
+ const calls =
375
+ typeof (client as any).getActiveCalls === 'function'
376
+ ? (client as any).getActiveCalls()
377
+ : Array.from(((client as any).calls as Map<string, any> | undefined)?.values?.() || []);
378
+
379
+ return calls.some((call: any) => call?.state === 'active' || call?.state === 'held');
380
+ }
381
+
317
382
  /**
318
383
  * Dispose of the session manager and clean up resources
319
384
  */
320
- dispose(): void {
385
+ async dispose(): Promise<void> {
321
386
  if (this._disposed) {
322
387
  return;
323
388
  }
324
389
 
325
- this._disposed = true;
326
- this.disconnect();
327
- this._connectionState.complete();
390
+ if (!this._disposePromise) {
391
+ this._disposePromise = this._dispose();
392
+ }
393
+
394
+ await this._disposePromise;
328
395
  }
329
396
 
330
397
  /**
331
398
  * Internal method to establish connection with or without push notification handling
332
399
  */
333
400
  private async _connect(): Promise<void> {
334
- if (!this._currentConfig) {
401
+ const previousConnect = this._connectPromise;
402
+ if (previousConnect) {
403
+ try {
404
+ await previousConnect;
405
+ } catch (error) {
406
+ if (this._isTeardownActive()) {
407
+ throw error;
408
+ }
409
+ }
410
+ }
411
+
412
+ this._assertCanStartConnection();
413
+
414
+ const config = this._currentConfig;
415
+ if (!config) {
335
416
  throw new Error('No configuration provided');
336
417
  }
337
418
 
419
+ const connectionGeneration = (this._connectionGeneration += 1);
420
+ const connectPromise = this._runConnect(config, connectionGeneration);
421
+ this._connectPromise = connectPromise;
422
+
423
+ try {
424
+ await connectPromise;
425
+ } finally {
426
+ if (this._connectPromise === connectPromise) {
427
+ this._connectPromise = undefined;
428
+ }
429
+ }
430
+ }
431
+
432
+ private async _dispose(): Promise<void> {
433
+ this._disposing = true;
434
+ this._connectionGeneration += 1;
435
+
436
+ const inFlightConnect = this._connectPromise;
437
+
438
+ await this.disconnect();
439
+
440
+ if (inFlightConnect) {
441
+ try {
442
+ await inFlightConnect;
443
+ } catch {
444
+ // A connect canceled by disposal is expected; the cleanup path runs in _runConnect.
445
+ }
446
+ }
447
+
448
+ const client = this._telnyxClient;
449
+ if (client) {
450
+ await this._disconnectAndForgetClient(client, 'Error during dispose disconnect:');
451
+ }
452
+
453
+ this._currentConfig = undefined;
454
+ (this as any)._pendingPushPayload = null;
455
+ this._disposed = true;
456
+ this._disposing = false;
457
+ this._connectionState.complete();
458
+ }
459
+
460
+ private async _runConnect(config: Config, connectionGeneration: number): Promise<void> {
461
+ this._throwIfConnectCanceled(connectionGeneration);
338
462
  this._connectionState.next(TelnyxConnectionState.CONNECTING);
339
463
 
464
+ let client: TelnyxSDK.TelnyxRTC | undefined;
465
+ let canceledConnectCleanupCompleted = false;
466
+
340
467
  try {
341
468
  // Clean up existing client
342
469
  if (this._telnyxClient) {
343
- await this._telnyxClient.disconnect();
470
+ await this._disconnectClient(this._telnyxClient, 'Error during disconnect:');
344
471
  }
345
472
 
473
+ this._throwIfConnectCanceled(connectionGeneration);
474
+
346
475
  // Create new client instance with authentication options
347
476
  let clientOptions: TelnyxSDK.ClientOptions;
348
477
 
349
- if (isCredentialConfig(this._currentConfig)) {
478
+ if (isCredentialConfig(config)) {
350
479
  clientOptions = {
351
- login: this._currentConfig.sipUser,
352
- password: this._currentConfig.sipPassword,
353
- logLevel: this._currentConfig.debug ? 'debug' : 'warn',
354
- debug: this._currentConfig.debug ?? false,
355
- pushNotificationDeviceToken: this._currentConfig.pushNotificationDeviceToken,
356
- enableCallReports: this._currentConfig.enableCallReports,
357
- callReportInterval: this._currentConfig.callReportInterval,
358
- callReportLogLevel: this._currentConfig.callReportLogLevel,
359
- callReportMaxLogEntries: this._currentConfig.callReportMaxLogEntries,
480
+ login: config.sipUser,
481
+ password: config.sipPassword,
482
+ logLevel: config.debug ? 'debug' : 'warn',
483
+ debug: config.debug ?? false,
484
+ pushNotificationDeviceToken: config.pushNotificationDeviceToken,
485
+ pushWhenActive: config.pushWhenActive,
486
+ enableMissedCallNotifications: config.enableMissedCallNotifications ?? false,
487
+ useTrickleIce: config.useTrickleIce,
488
+ enableCallReports: config.enableCallReports,
489
+ callReportInterval: config.callReportInterval,
490
+ callReportLogLevel: config.callReportLogLevel,
491
+ callReportMaxLogEntries: config.callReportMaxLogEntries,
360
492
  sdkVersion: pkg.version,
361
493
  };
362
494
  console.log(
363
495
  '🔧 SessionManager: Creating TelnyxRTC with credential config, logLevel:',
364
496
  clientOptions.logLevel,
365
497
  'pushToken:',
366
- !!this._currentConfig.pushNotificationDeviceToken
498
+ !!config.pushNotificationDeviceToken
367
499
  );
368
- } else if (isTokenConfig(this._currentConfig)) {
500
+ } else if (isTokenConfig(config)) {
369
501
  clientOptions = {
370
- login_token: this._currentConfig.token,
371
- logLevel: this._currentConfig.debug ? 'debug' : 'warn',
372
- debug: this._currentConfig.debug ?? false,
373
- pushNotificationDeviceToken: this._currentConfig.pushNotificationDeviceToken,
374
- enableCallReports: this._currentConfig.enableCallReports,
375
- callReportInterval: this._currentConfig.callReportInterval,
376
- callReportLogLevel: this._currentConfig.callReportLogLevel,
377
- callReportMaxLogEntries: this._currentConfig.callReportMaxLogEntries,
502
+ login_token: config.token,
503
+ logLevel: config.debug ? 'debug' : 'warn',
504
+ debug: config.debug ?? false,
505
+ pushNotificationDeviceToken: config.pushNotificationDeviceToken,
506
+ pushWhenActive: config.pushWhenActive,
507
+ enableMissedCallNotifications: config.enableMissedCallNotifications ?? false,
508
+ useTrickleIce: config.useTrickleIce,
509
+ enableCallReports: config.enableCallReports,
510
+ callReportInterval: config.callReportInterval,
511
+ callReportLogLevel: config.callReportLogLevel,
512
+ callReportMaxLogEntries: config.callReportMaxLogEntries,
378
513
  sdkVersion: pkg.version,
379
514
  };
380
515
  console.log(
381
516
  '🔧 SessionManager: Creating TelnyxRTC with token config, logLevel:',
382
517
  clientOptions.logLevel,
383
518
  'pushToken:',
384
- !!this._currentConfig.pushNotificationDeviceToken
519
+ !!config.pushNotificationDeviceToken
385
520
  );
386
521
  } else {
387
522
  throw new Error('Invalid configuration type');
388
523
  }
389
524
 
390
- this._telnyxClient = new TelnyxSDK.TelnyxRTC(clientOptions);
525
+ client = new TelnyxSDK.TelnyxRTC(clientOptions);
526
+ this._telnyxClient = client;
391
527
 
392
528
  // CRITICAL: Process any pending push notification payload BEFORE connecting
393
529
  // This ensures voice_sdk_id and other payload variables are set before connect() is called
@@ -420,7 +556,7 @@ export class SessionManager {
420
556
  (this as any)._pendingPushPayload = null;
421
557
  }
422
558
 
423
- this._setupClientListeners();
559
+ this._setupClientListeners(client);
424
560
 
425
561
  // Set up CallStateController listeners immediately after client creation
426
562
  // This ensures they're ready before any incoming call events are emitted
@@ -436,16 +572,38 @@ export class SessionManager {
436
572
  console.log('🔧 SessionManager: No _onClientReady callback found');
437
573
  }
438
574
 
575
+ this._throwIfConnectCanceled(connectionGeneration);
576
+
439
577
  // Connect to the platform AFTER processing push notification
440
578
  console.log(
441
579
  'SessionManager: RELEASE DEBUG - About to call connect() after processing push notification'
442
580
  );
443
- await this._telnyxClient.connect();
581
+ await client.connect();
582
+
583
+ if (this._isConnectCanceled(connectionGeneration)) {
584
+ await this._disconnectAndForgetClient(client, 'Error during dispose disconnect:');
585
+ canceledConnectCleanupCompleted = true;
586
+ throw this._createConnectCanceledError();
587
+ }
444
588
 
445
589
  // Notify that client is ready for event listeners
446
590
  console.log('🔧 SessionManager: Client connected successfully');
447
591
  } catch (error) {
592
+ if (this._isConnectCanceled(connectionGeneration)) {
593
+ if (client && !canceledConnectCleanupCompleted) {
594
+ await this._disconnectAndForgetClient(client, 'Error during dispose disconnect:');
595
+ }
596
+ throw this._createConnectCanceledError();
597
+ }
598
+
448
599
  console.error('Connection failed:', error);
600
+ if (client) {
601
+ try {
602
+ await this._disconnectAndForgetClient(client, 'Error during failed connect cleanup:');
603
+ } catch (cleanupError) {
604
+ console.error('Error during failed connect cleanup:', cleanupError);
605
+ }
606
+ }
449
607
  this._connectionState.next(TelnyxConnectionState.ERROR);
450
608
  throw error;
451
609
  }
@@ -454,12 +612,16 @@ export class SessionManager {
454
612
  /**
455
613
  * Set up event listeners for the Telnyx client
456
614
  */
457
- private _setupClientListeners(): void {
458
- if (!this._telnyxClient) {
615
+ private _setupClientListeners(client: TelnyxSDK.TelnyxRTC): void {
616
+ if (!client) {
459
617
  return;
460
618
  }
461
619
 
462
- this._telnyxClient.on('telnyx.client.ready', () => {
620
+ client.on('telnyx.client.ready', () => {
621
+ if (this._isTeardownActive() || this._telnyxClient !== client) {
622
+ return;
623
+ }
624
+
463
625
  console.log('Telnyx client ready');
464
626
  this._connectionState.next(TelnyxConnectionState.CONNECTED);
465
627
 
@@ -479,7 +641,11 @@ export class SessionManager {
479
641
  }
480
642
  });
481
643
 
482
- this._telnyxClient.on('telnyx.client.error', (error: Error) => {
644
+ client.on('telnyx.client.error', (error: Error) => {
645
+ if (this._isTeardownActive() || this._telnyxClient !== client) {
646
+ return;
647
+ }
648
+
483
649
  console.error('Telnyx client error:', error);
484
650
  this._connectionState.next(TelnyxConnectionState.ERROR);
485
651
  });
@@ -544,4 +710,54 @@ export class SessionManager {
544
710
  private _generateSessionId(): string {
545
711
  return `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
546
712
  }
713
+
714
+ private _assertCanStartConnection(): void {
715
+ if (this._isTeardownActive()) {
716
+ throw new Error('SessionManager has been disposed');
717
+ }
718
+ }
719
+
720
+ private _isTeardownActive(): boolean {
721
+ return this._disposed || this._disposing;
722
+ }
723
+
724
+ private _isConnectCanceled(connectionGeneration: number): boolean {
725
+ return this._isTeardownActive() || this._connectionGeneration !== connectionGeneration;
726
+ }
727
+
728
+ private _throwIfConnectCanceled(connectionGeneration: number): void {
729
+ if (this._isConnectCanceled(connectionGeneration)) {
730
+ throw this._createConnectCanceledError();
731
+ }
732
+ }
733
+
734
+ private _createConnectCanceledError(): Error {
735
+ return new Error(
736
+ this._isTeardownActive()
737
+ ? 'SessionManager has been disposed'
738
+ : 'SessionManager connection has been canceled'
739
+ );
740
+ }
741
+
742
+ private async _disconnectClient(
743
+ client: TelnyxSDK.TelnyxRTC,
744
+ errorMessage: string
745
+ ): Promise<void> {
746
+ try {
747
+ await client.disconnect();
748
+ } catch (error) {
749
+ console.error(errorMessage, error);
750
+ throw error;
751
+ }
752
+ }
753
+
754
+ private async _disconnectAndForgetClient(
755
+ client: TelnyxSDK.TelnyxRTC,
756
+ errorMessage: string
757
+ ): Promise<void> {
758
+ await this._disconnectClient(client, errorMessage);
759
+ if (this._telnyxClient === client) {
760
+ this._telnyxClient = undefined;
761
+ }
762
+ }
547
763
  }
@@ -31,6 +31,11 @@ export interface VoicePnBridgeInterface {
31
31
  ): Promise<boolean>;
32
32
  hideOngoingCallNotification(): Promise<boolean>;
33
33
  hideIncomingCallNotification(): Promise<boolean>;
34
+ setIncomingCallRingtone(resourceName: string | null): Promise<boolean>;
35
+
36
+ // CallKit answer persistence (iOS only)
37
+ getPendingCallKitAnswer(): Promise<string | null>;
38
+ clearPendingCallKitAnswer(): Promise<boolean>;
34
39
 
35
40
  // Additional UserDefaults methods
36
41
  getVoipToken(): Promise<string | null>;
@@ -38,6 +43,10 @@ export interface VoicePnBridgeInterface {
38
43
  clearPendingVoipPush(): Promise<boolean>;
39
44
  getPendingVoipAction(): Promise<string | null>;
40
45
  clearPendingVoipAction(): Promise<boolean>;
46
+ setMissedCallNotificationsEnabled(enabled: boolean): Promise<boolean>;
47
+ getMissedCallNotificationsEnabled(): Promise<boolean>;
48
+ setSpeakerEnabled(enabled: boolean): Promise<boolean>;
49
+ isSpeakerEnabled(): Promise<boolean>;
41
50
  }
42
51
 
43
52
  const NativeBridge: VoicePnBridgeInterface = NativeModules.VoicePnBridge;
@@ -179,6 +188,48 @@ export class VoicePnBridge {
179
188
  }
180
189
  }
181
190
 
191
+ /**
192
+ * Configure Android's incoming-call ringtone using an app resource in `res/raw`.
193
+ * Pass no value to use the device's default phone ringtone.
194
+ */
195
+ static async setIncomingCallRingtone(resourceName?: string): Promise<boolean> {
196
+ if (Platform.OS !== 'android') return true;
197
+ try {
198
+ return await NativeBridge.setIncomingCallRingtone(resourceName?.trim() || null);
199
+ } catch (error) {
200
+ console.error('VoicePnBridge: Error setting incoming call ringtone:', error);
201
+ return false;
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Get pending CallKit answer UUID from native storage (iOS only).
207
+ * When the user answers a CallKit call before JS listeners are ready,
208
+ * the native side persists the answer UUID in UserDefaults so JS can detect it.
209
+ */
210
+ static async getPendingCallKitAnswer(): Promise<string | null> {
211
+ if (Platform.OS !== 'ios') return null;
212
+ try {
213
+ return await NativeBridge.getPendingCallKitAnswer();
214
+ } catch (error) {
215
+ console.error('VoicePnBridge: Error getting pending CallKit answer:', error);
216
+ return null;
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Clear pending CallKit answer from native storage (iOS only)
222
+ */
223
+ static async clearPendingCallKitAnswer(): Promise<boolean> {
224
+ if (Platform.OS !== 'ios') return true;
225
+ try {
226
+ return await NativeBridge.clearPendingCallKitAnswer();
227
+ } catch (error) {
228
+ console.error('VoicePnBridge: Error clearing pending CallKit answer:', error);
229
+ return false;
230
+ }
231
+ }
232
+
182
233
  /**
183
234
  * Get VoIP token from native storage
184
235
  */
@@ -244,6 +295,66 @@ export class VoicePnBridge {
244
295
  }
245
296
  }
246
297
 
298
+ /**
299
+ * Enable or disable local missed call notifications on iOS.
300
+ * Missed-call VoIP pushes are still handled through CallKit either way.
301
+ */
302
+ static async setMissedCallNotificationsEnabled(enabled: boolean): Promise<boolean> {
303
+ try {
304
+ return await NativeBridge.setMissedCallNotificationsEnabled(enabled);
305
+ } catch (error) {
306
+ console.error('VoicePnBridge: Error setting missed call notifications:', error);
307
+ return false;
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Returns true by default, matching the sample-app behavior for missed calls.
313
+ */
314
+ static async getMissedCallNotificationsEnabled(): Promise<boolean> {
315
+ try {
316
+ return await NativeBridge.getMissedCallNotificationsEnabled();
317
+ } catch (error) {
318
+ console.error('VoicePnBridge: Error getting missed call notifications setting:', error);
319
+ return true;
320
+ }
321
+ }
322
+
323
+ /**
324
+ * Route call audio through the loudspeaker when enabled, or back to the
325
+ * platform default voice route when disabled.
326
+ */
327
+ static async setSpeakerEnabled(enabled: boolean): Promise<boolean> {
328
+ try {
329
+ return await NativeBridge.setSpeakerEnabled(enabled);
330
+ } catch (error) {
331
+ console.error('VoicePnBridge: Error setting speaker route:', error);
332
+ throw error;
333
+ }
334
+ }
335
+
336
+ /**
337
+ * Returns whether the current audio route is using the loudspeaker.
338
+ */
339
+ static async isSpeakerEnabled(): Promise<boolean> {
340
+ try {
341
+ return await NativeBridge.isSpeakerEnabled();
342
+ } catch (error) {
343
+ console.error('VoicePnBridge: Error getting speaker route:', error);
344
+ throw error;
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Toggle between loudspeaker and the platform default voice route.
350
+ */
351
+ static async toggleSpeaker(): Promise<boolean> {
352
+ // This is intentionally read-then-write because route changes can also come from
353
+ // the OS, Bluetooth, wired accessories, or the active call session.
354
+ const enabled = await VoicePnBridge.isSpeakerEnabled();
355
+ return VoicePnBridge.setSpeakerEnabled(!enabled);
356
+ }
357
+
247
358
  /**
248
359
  * Android → React Native: Listen for immediate call action events from notification buttons
249
360
  * Use this for active calls where immediate response is needed (e.g., ending ongoing calls)