@telnyx/react-voice-commons-sdk 1.0.0 → 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 (43) 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/TelnyxNotificationHelper.kt +345 -24
  5. package/android/src/main/java/com/telnyx/react_voice_commons/VoicePnBridgeModule.kt +19 -0
  6. package/android/src/main/java/com/telnyx/react_voice_commons/VoicePnManager.kt +19 -0
  7. package/ios/CallKitBridge.m +20 -1
  8. package/ios/CallKitBridge.swift +527 -78
  9. package/ios/README.md +2 -2
  10. package/ios/VoicePnBridge.m +6 -0
  11. package/ios/VoicePnBridge.swift +15 -0
  12. package/lib/callkit/callkit-coordinator.d.ts +39 -3
  13. package/lib/callkit/callkit-coordinator.js +561 -148
  14. package/lib/callkit/callkit.d.ts +7 -0
  15. package/lib/callkit/callkit.js +83 -11
  16. package/lib/hooks/useAppStateHandler.js +6 -2
  17. package/lib/internal/CallKitHandler.d.ts +3 -0
  18. package/lib/internal/CallKitHandler.js +52 -46
  19. package/lib/internal/calls/call-state-controller.d.ts +33 -1
  20. package/lib/internal/calls/call-state-controller.js +100 -28
  21. package/lib/internal/session/session-manager.d.ts +15 -1
  22. package/lib/internal/session/session-manager.js +214 -62
  23. package/lib/internal/voice-pn-bridge.d.ts +18 -0
  24. package/lib/internal/voice-pn-bridge.js +39 -0
  25. package/lib/models/call.js +35 -0
  26. package/lib/models/config.d.ts +10 -0
  27. package/lib/models/config.js +2 -0
  28. package/lib/telnyx-voice-app.js +85 -43
  29. package/lib/telnyx-voip-client.d.ts +46 -5
  30. package/lib/telnyx-voip-client.js +238 -22
  31. package/package.json +6 -4
  32. package/src/callkit/callkit-coordinator.ts +642 -160
  33. package/src/callkit/callkit.ts +110 -12
  34. package/src/hooks/useAppStateHandler.ts +7 -2
  35. package/src/internal/CallKitHandler.tsx +60 -55
  36. package/src/internal/calls/call-state-controller.ts +112 -29
  37. package/src/internal/session/session-manager.ts +260 -65
  38. package/src/internal/voice-pn-bridge.ts +47 -0
  39. package/src/models/call.ts +34 -0
  40. package/src/models/config.ts +10 -0
  41. package/src/telnyx-voice-app.tsx +96 -45
  42. package/src/telnyx-voip-client.ts +216 -24
  43. package/src/types/telnyx-sdk.d.ts +2 -1
@@ -12,6 +12,7 @@ import {
12
12
  } from '../../models/config';
13
13
 
14
14
  const USE_TRICKLE_ICE_STORAGE_KEY = '@use_trickle_ice';
15
+ const PUSH_WHEN_ACTIVE_STORAGE_KEY = '@push_when_active';
15
16
  const MISSED_CALL_NOTIFICATIONS_STORAGE_KEY = '@enable_missed_call_notifications';
16
17
 
17
18
  /**
@@ -28,6 +29,10 @@ export class SessionManager {
28
29
  private _currentConfig?: Config;
29
30
  private _sessionId: string;
30
31
  private _disposed = false;
32
+ private _disposing = false;
33
+ private _connectionGeneration = 0;
34
+ private _disposePromise?: Promise<void>;
35
+ private _connectPromise?: Promise<void>;
31
36
  private _onClientReady?: () => void;
32
37
  private _onDisconnect?: () => void;
33
38
 
@@ -86,24 +91,20 @@ export class SessionManager {
86
91
  * Connect using credential authentication
87
92
  */
88
93
  async connectWithCredential(config: CredentialConfig): Promise<void> {
89
- if (this._disposed) {
90
- throw new Error('SessionManager has been disposed');
91
- }
92
-
94
+ this._assertCanStartConnection();
93
95
  this._currentConfig = config;
94
96
  await this._connect();
97
+ this._assertCanStartConnection();
95
98
  }
96
99
 
97
100
  /**
98
101
  * Connect using token authentication
99
102
  */
100
103
  async connectWithToken(config: TokenConfig): Promise<void> {
101
- if (this._disposed) {
102
- throw new Error('SessionManager has been disposed');
103
- }
104
-
104
+ this._assertCanStartConnection();
105
105
  this._currentConfig = config;
106
106
  await this._connect();
107
+ this._assertCanStartConnection();
107
108
  }
108
109
 
109
110
  /**
@@ -121,6 +122,7 @@ export class SessionManager {
121
122
  return;
122
123
  }
123
124
 
125
+ this._connectionGeneration += 1;
124
126
  this._currentConfig = undefined;
125
127
  this._connectionState.next(TelnyxConnectionState.DISCONNECTED);
126
128
 
@@ -133,11 +135,7 @@ export class SessionManager {
133
135
  }
134
136
 
135
137
  if (this._telnyxClient) {
136
- try {
137
- await this._telnyxClient.disconnect();
138
- } catch (error) {
139
- console.error('Error during disconnect:', error);
140
- }
138
+ await this._disconnectAndForgetClient(this._telnyxClient, 'Error during disconnect:');
141
139
  }
142
140
  }
143
141
 
@@ -147,7 +145,11 @@ export class SessionManager {
147
145
  * which sends a 'telnyx_rtc.disable_push_notification' message via the socket.
148
146
  */
149
147
  disablePushNotifications(): void {
150
- if (this._telnyxClient && this.currentState === TelnyxConnectionState.CONNECTED) {
148
+ if (
149
+ !this._isTeardownActive() &&
150
+ this._telnyxClient &&
151
+ this.currentState === TelnyxConnectionState.CONNECTED
152
+ ) {
151
153
  console.log('SessionManager: Disabling push notifications for session:', this._sessionId);
152
154
  this._telnyxClient.disablePushNotification();
153
155
  } else {
@@ -159,7 +161,7 @@ export class SessionManager {
159
161
  * Handle push notification with stored config
160
162
  */
161
163
  handlePushNotificationWithConfig(pushMetaData: any, config: Config): void {
162
- if (this._disposed) {
164
+ if (this._isTeardownActive()) {
163
165
  return;
164
166
  }
165
167
 
@@ -173,7 +175,7 @@ export class SessionManager {
173
175
  * Handle push notification (async version)
174
176
  */
175
177
  async handlePushNotification(payload: Record<string, any>): Promise<void> {
176
- if (this._disposed) {
178
+ if (this._isTeardownActive()) {
177
179
  return;
178
180
  }
179
181
 
@@ -185,19 +187,38 @@ export class SessionManager {
185
187
  // Store the push notification payload for when the client is created
186
188
  (this as any)._pendingPushPayload = payload;
187
189
 
188
- // Each push always rebuilds the TelnyxRTC client. The voice_sdk_id is
189
- // baked into the WebSocket URL at socket-open time and can't be mutated
190
- // mid-flight; reusing a connection from a previous push means the
191
- // gateway routes THIS push's INVITE to a different (correctly-stamped)
192
- // client and we sit on the wrong socket forever.
193
- if (this._telnyxClient) {
194
- try {
195
- await this._telnyxClient.disconnect();
196
- } catch (err) {
197
- 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');
198
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;
199
207
  }
200
- this._telnyxClient = undefined;
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
+ );
216
+ }
217
+
218
+ if (this._isTeardownActive()) {
219
+ return;
220
+ }
221
+
201
222
  if (this.currentState !== TelnyxConnectionState.DISCONNECTED) {
202
223
  this._connectionState.next(TelnyxConnectionState.DISCONNECTED);
203
224
  }
@@ -218,12 +239,18 @@ export class SessionManager {
218
239
  const storedCredentialToken = await AsyncStorage.getItem('@credential_token');
219
240
  const storedPushToken = await AsyncStorage.getItem('@push_token');
220
241
  const storedUseTrickleIce = await AsyncStorage.getItem(USE_TRICKLE_ICE_STORAGE_KEY);
242
+ const storedPushWhenActive = await AsyncStorage.getItem(PUSH_WHEN_ACTIVE_STORAGE_KEY);
221
243
  const storedMissedCallNotifications = await AsyncStorage.getItem(
222
244
  MISSED_CALL_NOTIFICATIONS_STORAGE_KEY
223
245
  );
224
246
  const useTrickleIce = storedUseTrickleIce === 'true';
247
+ const pushWhenActive = storedPushWhenActive === 'true';
225
248
  const enableMissedCallNotifications = storedMissedCallNotifications === 'true';
226
249
 
250
+ if (this._isTeardownActive()) {
251
+ return;
252
+ }
253
+
227
254
  // Check if we have credential-based authentication data
228
255
  if (storedUsername && storedPassword) {
229
256
  console.log('SessionManager: RELEASE DEBUG - Found stored credentials, creating config');
@@ -231,6 +258,7 @@ export class SessionManager {
231
258
  this._currentConfig = createCredentialConfig(storedUsername, storedPassword, {
232
259
  pushNotificationDeviceToken: storedPushToken,
233
260
  useTrickleIce,
261
+ pushWhenActive,
234
262
  enableMissedCallNotifications,
235
263
  });
236
264
  }
@@ -241,6 +269,7 @@ export class SessionManager {
241
269
  this._currentConfig = createTokenConfig(storedCredentialToken, {
242
270
  pushNotificationDeviceToken: storedPushToken,
243
271
  useTrickleIce,
272
+ pushWhenActive,
244
273
  enableMissedCallNotifications,
245
274
  });
246
275
  }
@@ -257,6 +286,10 @@ export class SessionManager {
257
286
  }
258
287
  }
259
288
 
289
+ if (this._isTeardownActive()) {
290
+ return;
291
+ }
292
+
260
293
  // If we already have a client, process the push notification immediately
261
294
  if (this._telnyxClient) {
262
295
  console.log(
@@ -306,10 +339,16 @@ export class SessionManager {
306
339
  );
307
340
  try {
308
341
  await this._connect();
342
+ if (this._isTeardownActive()) {
343
+ return;
344
+ }
309
345
  console.log(
310
346
  'SessionManager: RELEASE DEBUG - Successfully connected after push notification trigger'
311
347
  );
312
348
  } catch (error) {
349
+ if (this._isTeardownActive()) {
350
+ return;
351
+ }
313
352
  console.error(
314
353
  'SessionManager: Failed to connect after push notification trigger:',
315
354
  error
@@ -331,84 +370,160 @@ export class SessionManager {
331
370
  console.log('SessionManager: RELEASE DEBUG - Push notification handling complete');
332
371
  }
333
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
+
334
382
  /**
335
383
  * Dispose of the session manager and clean up resources
336
384
  */
337
- dispose(): void {
385
+ async dispose(): Promise<void> {
338
386
  if (this._disposed) {
339
387
  return;
340
388
  }
341
389
 
342
- this._disposed = true;
343
- this.disconnect();
344
- this._connectionState.complete();
390
+ if (!this._disposePromise) {
391
+ this._disposePromise = this._dispose();
392
+ }
393
+
394
+ await this._disposePromise;
345
395
  }
346
396
 
347
397
  /**
348
398
  * Internal method to establish connection with or without push notification handling
349
399
  */
350
400
  private async _connect(): Promise<void> {
351
- 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) {
352
416
  throw new Error('No configuration provided');
353
417
  }
354
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);
355
462
  this._connectionState.next(TelnyxConnectionState.CONNECTING);
356
463
 
464
+ let client: TelnyxSDK.TelnyxRTC | undefined;
465
+ let canceledConnectCleanupCompleted = false;
466
+
357
467
  try {
358
468
  // Clean up existing client
359
469
  if (this._telnyxClient) {
360
- await this._telnyxClient.disconnect();
470
+ await this._disconnectClient(this._telnyxClient, 'Error during disconnect:');
361
471
  }
362
472
 
473
+ this._throwIfConnectCanceled(connectionGeneration);
474
+
363
475
  // Create new client instance with authentication options
364
476
  let clientOptions: TelnyxSDK.ClientOptions;
365
477
 
366
- if (isCredentialConfig(this._currentConfig)) {
478
+ if (isCredentialConfig(config)) {
367
479
  clientOptions = {
368
- login: this._currentConfig.sipUser,
369
- password: this._currentConfig.sipPassword,
370
- logLevel: this._currentConfig.debug ? 'debug' : 'warn',
371
- debug: this._currentConfig.debug ?? false,
372
- pushNotificationDeviceToken: this._currentConfig.pushNotificationDeviceToken,
373
- enableMissedCallNotifications: this._currentConfig.enableMissedCallNotifications ?? false,
374
- useTrickleIce: this._currentConfig.useTrickleIce,
375
- enableCallReports: this._currentConfig.enableCallReports,
376
- callReportInterval: this._currentConfig.callReportInterval,
377
- callReportLogLevel: this._currentConfig.callReportLogLevel,
378
- 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,
379
492
  sdkVersion: pkg.version,
380
493
  };
381
494
  console.log(
382
495
  '🔧 SessionManager: Creating TelnyxRTC with credential config, logLevel:',
383
496
  clientOptions.logLevel,
384
497
  'pushToken:',
385
- !!this._currentConfig.pushNotificationDeviceToken
498
+ !!config.pushNotificationDeviceToken
386
499
  );
387
- } else if (isTokenConfig(this._currentConfig)) {
500
+ } else if (isTokenConfig(config)) {
388
501
  clientOptions = {
389
- login_token: this._currentConfig.token,
390
- logLevel: this._currentConfig.debug ? 'debug' : 'warn',
391
- debug: this._currentConfig.debug ?? false,
392
- pushNotificationDeviceToken: this._currentConfig.pushNotificationDeviceToken,
393
- enableMissedCallNotifications: this._currentConfig.enableMissedCallNotifications ?? false,
394
- useTrickleIce: this._currentConfig.useTrickleIce,
395
- enableCallReports: this._currentConfig.enableCallReports,
396
- callReportInterval: this._currentConfig.callReportInterval,
397
- callReportLogLevel: this._currentConfig.callReportLogLevel,
398
- 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,
399
513
  sdkVersion: pkg.version,
400
514
  };
401
515
  console.log(
402
516
  '🔧 SessionManager: Creating TelnyxRTC with token config, logLevel:',
403
517
  clientOptions.logLevel,
404
518
  'pushToken:',
405
- !!this._currentConfig.pushNotificationDeviceToken
519
+ !!config.pushNotificationDeviceToken
406
520
  );
407
521
  } else {
408
522
  throw new Error('Invalid configuration type');
409
523
  }
410
524
 
411
- this._telnyxClient = new TelnyxSDK.TelnyxRTC(clientOptions);
525
+ client = new TelnyxSDK.TelnyxRTC(clientOptions);
526
+ this._telnyxClient = client;
412
527
 
413
528
  // CRITICAL: Process any pending push notification payload BEFORE connecting
414
529
  // This ensures voice_sdk_id and other payload variables are set before connect() is called
@@ -441,7 +556,7 @@ export class SessionManager {
441
556
  (this as any)._pendingPushPayload = null;
442
557
  }
443
558
 
444
- this._setupClientListeners();
559
+ this._setupClientListeners(client);
445
560
 
446
561
  // Set up CallStateController listeners immediately after client creation
447
562
  // This ensures they're ready before any incoming call events are emitted
@@ -457,16 +572,38 @@ export class SessionManager {
457
572
  console.log('🔧 SessionManager: No _onClientReady callback found');
458
573
  }
459
574
 
575
+ this._throwIfConnectCanceled(connectionGeneration);
576
+
460
577
  // Connect to the platform AFTER processing push notification
461
578
  console.log(
462
579
  'SessionManager: RELEASE DEBUG - About to call connect() after processing push notification'
463
580
  );
464
- 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
+ }
465
588
 
466
589
  // Notify that client is ready for event listeners
467
590
  console.log('🔧 SessionManager: Client connected successfully');
468
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
+
469
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
+ }
470
607
  this._connectionState.next(TelnyxConnectionState.ERROR);
471
608
  throw error;
472
609
  }
@@ -475,12 +612,16 @@ export class SessionManager {
475
612
  /**
476
613
  * Set up event listeners for the Telnyx client
477
614
  */
478
- private _setupClientListeners(): void {
479
- if (!this._telnyxClient) {
615
+ private _setupClientListeners(client: TelnyxSDK.TelnyxRTC): void {
616
+ if (!client) {
480
617
  return;
481
618
  }
482
619
 
483
- this._telnyxClient.on('telnyx.client.ready', () => {
620
+ client.on('telnyx.client.ready', () => {
621
+ if (this._isTeardownActive() || this._telnyxClient !== client) {
622
+ return;
623
+ }
624
+
484
625
  console.log('Telnyx client ready');
485
626
  this._connectionState.next(TelnyxConnectionState.CONNECTED);
486
627
 
@@ -500,7 +641,11 @@ export class SessionManager {
500
641
  }
501
642
  });
502
643
 
503
- 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
+
504
649
  console.error('Telnyx client error:', error);
505
650
  this._connectionState.next(TelnyxConnectionState.ERROR);
506
651
  });
@@ -565,4 +710,54 @@ export class SessionManager {
565
710
  private _generateSessionId(): string {
566
711
  return `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
567
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
+ }
568
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>;
@@ -183,6 +188,48 @@ export class VoicePnBridge {
183
188
  }
184
189
  }
185
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
+
186
233
  /**
187
234
  * Get VoIP token from native storage
188
235
  */
@@ -285,6 +285,17 @@ export class Call {
285
285
  }
286
286
 
287
287
  try {
288
+ if (Platform.OS === 'ios') {
289
+ const { callKitCoordinator } = await import('../callkit/callkit-coordinator');
290
+ if (callKitCoordinator.isAvailable()) {
291
+ const success = await callKitCoordinator.setHeldFromUI(this._telnyxCall, true);
292
+ if (!success) {
293
+ throw new Error('CallKit failed to hold call');
294
+ }
295
+ return;
296
+ }
297
+ }
298
+
288
299
  await this._telnyxCall.hold();
289
300
  } catch (error) {
290
301
  console.error('Failed to hold call:', error);
@@ -301,6 +312,17 @@ export class Call {
301
312
  }
302
313
 
303
314
  try {
315
+ if (Platform.OS === 'ios') {
316
+ const { callKitCoordinator } = await import('../callkit/callkit-coordinator');
317
+ if (callKitCoordinator.isAvailable()) {
318
+ const success = await callKitCoordinator.setHeldFromUI(this._telnyxCall, false);
319
+ if (!success) {
320
+ throw new Error('CallKit failed to resume call');
321
+ }
322
+ return;
323
+ }
324
+ }
325
+
304
326
  await this._telnyxCall.unhold();
305
327
  } catch (error) {
306
328
  console.error('Failed to resume call:', error);
@@ -406,6 +428,18 @@ export class Call {
406
428
  const telnyxState = this._mapToTelnyxCallState(state);
407
429
  this._callState.next(telnyxState);
408
430
 
431
+ // Keep the dedicated held observable derived from call state. Updating
432
+ // this only after the low-level SDK emits its successful transition
433
+ // preserves the previous value when hold/unhold signaling fails.
434
+ if (telnyxState === TelnyxCallState.HELD) {
435
+ this._isHeld.next(true);
436
+ } else if (
437
+ telnyxState === TelnyxCallState.ACTIVE ||
438
+ CallStateHelpers.isTerminated(telnyxState)
439
+ ) {
440
+ this._isHeld.next(false);
441
+ }
442
+
409
443
  // Start duration timer when call becomes active
410
444
  if (telnyxState === TelnyxCallState.ACTIVE && !this._startTime) {
411
445
  this._startDurationTimer();