@telnyx/react-voice-commons-sdk 1.0.0 → 1.1.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 (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 +27 -1
  8. package/ios/CallKitBridge.swift +640 -125
  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 +7 -5
  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
@@ -60,6 +60,7 @@ const pkg = __importStar(require('../../../package.json'));
60
60
  const connection_state_1 = require('../../models/connection-state');
61
61
  const config_1 = require('../../models/config');
62
62
  const USE_TRICKLE_ICE_STORAGE_KEY = '@use_trickle_ice';
63
+ const PUSH_WHEN_ACTIVE_STORAGE_KEY = '@push_when_active';
63
64
  const MISSED_CALL_NOTIFICATIONS_STORAGE_KEY = '@enable_missed_call_notifications';
64
65
  /**
65
66
  * Manages the connection lifecycle to the Telnyx platform.
@@ -73,6 +74,8 @@ class SessionManager {
73
74
  connection_state_1.TelnyxConnectionState.DISCONNECTED
74
75
  );
75
76
  this._disposed = false;
77
+ this._disposing = false;
78
+ this._connectionGeneration = 0;
76
79
  this._sessionId = this._generateSessionId();
77
80
  }
78
81
  /**
@@ -119,21 +122,19 @@ class SessionManager {
119
122
  * Connect using credential authentication
120
123
  */
121
124
  async connectWithCredential(config) {
122
- if (this._disposed) {
123
- throw new Error('SessionManager has been disposed');
124
- }
125
+ this._assertCanStartConnection();
125
126
  this._currentConfig = config;
126
127
  await this._connect();
128
+ this._assertCanStartConnection();
127
129
  }
128
130
  /**
129
131
  * Connect using token authentication
130
132
  */
131
133
  async connectWithToken(config) {
132
- if (this._disposed) {
133
- throw new Error('SessionManager has been disposed');
134
- }
134
+ this._assertCanStartConnection();
135
135
  this._currentConfig = config;
136
136
  await this._connect();
137
+ this._assertCanStartConnection();
137
138
  }
138
139
  /**
139
140
  * Disconnect from the Telnyx platform.
@@ -149,6 +150,7 @@ class SessionManager {
149
150
  if (this._disposed) {
150
151
  return;
151
152
  }
153
+ this._connectionGeneration += 1;
152
154
  this._currentConfig = undefined;
153
155
  this._connectionState.next(connection_state_1.TelnyxConnectionState.DISCONNECTED);
154
156
  if (this._onDisconnect) {
@@ -159,11 +161,7 @@ class SessionManager {
159
161
  }
160
162
  }
161
163
  if (this._telnyxClient) {
162
- try {
163
- await this._telnyxClient.disconnect();
164
- } catch (error) {
165
- console.error('Error during disconnect:', error);
166
- }
164
+ await this._disconnectAndForgetClient(this._telnyxClient, 'Error during disconnect:');
167
165
  }
168
166
  }
169
167
  /**
@@ -173,6 +171,7 @@ class SessionManager {
173
171
  */
174
172
  disablePushNotifications() {
175
173
  if (
174
+ !this._isTeardownActive() &&
176
175
  this._telnyxClient &&
177
176
  this.currentState === connection_state_1.TelnyxConnectionState.CONNECTED
178
177
  ) {
@@ -186,7 +185,7 @@ class SessionManager {
186
185
  * Handle push notification with stored config
187
186
  */
188
187
  handlePushNotificationWithConfig(pushMetaData, config) {
189
- if (this._disposed) {
188
+ if (this._isTeardownActive()) {
190
189
  return;
191
190
  }
192
191
  this._currentConfig = config;
@@ -198,7 +197,7 @@ class SessionManager {
198
197
  * Handle push notification (async version)
199
198
  */
200
199
  async handlePushNotification(payload) {
201
- if (this._disposed) {
200
+ if (this._isTeardownActive()) {
202
201
  return;
203
202
  }
204
203
  console.log(
@@ -207,19 +206,33 @@ class SessionManager {
207
206
  );
208
207
  // Store the push notification payload for when the client is created
209
208
  this._pendingPushPayload = payload;
210
- // Each push always rebuilds the TelnyxRTC client. The voice_sdk_id is
211
- // baked into the WebSocket URL at socket-open time and can't be mutated
212
- // mid-flight; reusing a connection from a previous push means the
213
- // gateway routes THIS push's INVITE to a different (correctly-stamped)
214
- // client and we sit on the wrong socket forever.
215
- if (this._telnyxClient) {
216
- try {
217
- await this._telnyxClient.disconnect();
218
- } catch (err) {
219
- console.warn('SessionManager: disconnect of prior client threw:', err);
209
+ // A second-call push must stay on the client that owns the active/held
210
+ // call. Tearing it down destroys the first signaling session and clears
211
+ // the call collection before CallKit can complete Hold & Accept.
212
+ if (this._telnyxClient && this._hasActiveOrHeldCall(this._telnyxClient)) {
213
+ const actualPayload = this._extractPushPayload(payload);
214
+ const processVoIPNotification = this._telnyxClient.processVoIPNotification;
215
+ if (typeof processVoIPNotification !== 'function') {
216
+ throw new Error('TelnyxRTC client cannot process an active-call VoIP notification');
220
217
  }
218
+ console.log(
219
+ 'SessionManager: Preserving active client while processing second-call push notification'
220
+ );
221
+ processVoIPNotification.call(this._telnyxClient, actualPayload);
222
+ this._pendingPushPayload = null;
223
+ return;
224
+ }
225
+ // The cold/no-active-call path still rebuilds the client so the socket is
226
+ // stamped with the push voice_sdk_id before connecting.
227
+ if (this._telnyxClient) {
228
+ await this._disconnectAndForgetClient(
229
+ this._telnyxClient,
230
+ 'SessionManager: disconnect of prior client threw:'
231
+ );
232
+ }
233
+ if (this._isTeardownActive()) {
234
+ return;
221
235
  }
222
- this._telnyxClient = undefined;
223
236
  if (this.currentState !== connection_state_1.TelnyxConnectionState.DISCONNECTED) {
224
237
  this._connectionState.next(connection_state_1.TelnyxConnectionState.DISCONNECTED);
225
238
  }
@@ -237,11 +250,16 @@ class SessionManager {
237
250
  const storedCredentialToken = await AsyncStorage.getItem('@credential_token');
238
251
  const storedPushToken = await AsyncStorage.getItem('@push_token');
239
252
  const storedUseTrickleIce = await AsyncStorage.getItem(USE_TRICKLE_ICE_STORAGE_KEY);
253
+ const storedPushWhenActive = await AsyncStorage.getItem(PUSH_WHEN_ACTIVE_STORAGE_KEY);
240
254
  const storedMissedCallNotifications = await AsyncStorage.getItem(
241
255
  MISSED_CALL_NOTIFICATIONS_STORAGE_KEY
242
256
  );
243
257
  const useTrickleIce = storedUseTrickleIce === 'true';
258
+ const pushWhenActive = storedPushWhenActive === 'true';
244
259
  const enableMissedCallNotifications = storedMissedCallNotifications === 'true';
260
+ if (this._isTeardownActive()) {
261
+ return;
262
+ }
245
263
  // Check if we have credential-based authentication data
246
264
  if (storedUsername && storedPassword) {
247
265
  console.log('SessionManager: RELEASE DEBUG - Found stored credentials, creating config');
@@ -249,6 +267,7 @@ class SessionManager {
249
267
  this._currentConfig = createCredentialConfig(storedUsername, storedPassword, {
250
268
  pushNotificationDeviceToken: storedPushToken,
251
269
  useTrickleIce,
270
+ pushWhenActive,
252
271
  enableMissedCallNotifications,
253
272
  });
254
273
  }
@@ -259,6 +278,7 @@ class SessionManager {
259
278
  this._currentConfig = createTokenConfig(storedCredentialToken, {
260
279
  pushNotificationDeviceToken: storedPushToken,
261
280
  useTrickleIce,
281
+ pushWhenActive,
262
282
  enableMissedCallNotifications,
263
283
  });
264
284
  }
@@ -273,6 +293,9 @@ class SessionManager {
273
293
  console.warn('SessionManager: Failed to load stored config for push notification:', error);
274
294
  }
275
295
  }
296
+ if (this._isTeardownActive()) {
297
+ return;
298
+ }
276
299
  // If we already have a client, process the push notification immediately
277
300
  if (this._telnyxClient) {
278
301
  console.log(
@@ -317,10 +340,16 @@ class SessionManager {
317
340
  );
318
341
  try {
319
342
  await this._connect();
343
+ if (this._isTeardownActive()) {
344
+ return;
345
+ }
320
346
  console.log(
321
347
  'SessionManager: RELEASE DEBUG - Successfully connected after push notification trigger'
322
348
  );
323
349
  } catch (error) {
350
+ if (this._isTeardownActive()) {
351
+ return;
352
+ }
324
353
  console.error(
325
354
  'SessionManager: Failed to connect after push notification trigger:',
326
355
  error
@@ -340,77 +369,138 @@ class SessionManager {
340
369
  }
341
370
  console.log('SessionManager: RELEASE DEBUG - Push notification handling complete');
342
371
  }
372
+ _hasActiveOrHeldCall(client) {
373
+ const calls =
374
+ typeof client.getActiveCalls === 'function'
375
+ ? client.getActiveCalls()
376
+ : Array.from(client.calls?.values?.() || []);
377
+ return calls.some((call) => call?.state === 'active' || call?.state === 'held');
378
+ }
343
379
  /**
344
380
  * Dispose of the session manager and clean up resources
345
381
  */
346
- dispose() {
382
+ async dispose() {
347
383
  if (this._disposed) {
348
384
  return;
349
385
  }
350
- this._disposed = true;
351
- this.disconnect();
352
- this._connectionState.complete();
386
+ if (!this._disposePromise) {
387
+ this._disposePromise = this._dispose();
388
+ }
389
+ await this._disposePromise;
353
390
  }
354
391
  /**
355
392
  * Internal method to establish connection with or without push notification handling
356
393
  */
357
394
  async _connect() {
358
- if (!this._currentConfig) {
395
+ const previousConnect = this._connectPromise;
396
+ if (previousConnect) {
397
+ try {
398
+ await previousConnect;
399
+ } catch (error) {
400
+ if (this._isTeardownActive()) {
401
+ throw error;
402
+ }
403
+ }
404
+ }
405
+ this._assertCanStartConnection();
406
+ const config = this._currentConfig;
407
+ if (!config) {
359
408
  throw new Error('No configuration provided');
360
409
  }
410
+ const connectionGeneration = (this._connectionGeneration += 1);
411
+ const connectPromise = this._runConnect(config, connectionGeneration);
412
+ this._connectPromise = connectPromise;
413
+ try {
414
+ await connectPromise;
415
+ } finally {
416
+ if (this._connectPromise === connectPromise) {
417
+ this._connectPromise = undefined;
418
+ }
419
+ }
420
+ }
421
+ async _dispose() {
422
+ this._disposing = true;
423
+ this._connectionGeneration += 1;
424
+ const inFlightConnect = this._connectPromise;
425
+ await this.disconnect();
426
+ if (inFlightConnect) {
427
+ try {
428
+ await inFlightConnect;
429
+ } catch {
430
+ // A connect canceled by disposal is expected; the cleanup path runs in _runConnect.
431
+ }
432
+ }
433
+ const client = this._telnyxClient;
434
+ if (client) {
435
+ await this._disconnectAndForgetClient(client, 'Error during dispose disconnect:');
436
+ }
437
+ this._currentConfig = undefined;
438
+ this._pendingPushPayload = null;
439
+ this._disposed = true;
440
+ this._disposing = false;
441
+ this._connectionState.complete();
442
+ }
443
+ async _runConnect(config, connectionGeneration) {
444
+ this._throwIfConnectCanceled(connectionGeneration);
361
445
  this._connectionState.next(connection_state_1.TelnyxConnectionState.CONNECTING);
446
+ let client;
447
+ let canceledConnectCleanupCompleted = false;
362
448
  try {
363
449
  // Clean up existing client
364
450
  if (this._telnyxClient) {
365
- await this._telnyxClient.disconnect();
451
+ await this._disconnectClient(this._telnyxClient, 'Error during disconnect:');
366
452
  }
453
+ this._throwIfConnectCanceled(connectionGeneration);
367
454
  // Create new client instance with authentication options
368
455
  let clientOptions;
369
- if ((0, config_1.isCredentialConfig)(this._currentConfig)) {
456
+ if ((0, config_1.isCredentialConfig)(config)) {
370
457
  clientOptions = {
371
- login: this._currentConfig.sipUser,
372
- password: this._currentConfig.sipPassword,
373
- logLevel: this._currentConfig.debug ? 'debug' : 'warn',
374
- debug: this._currentConfig.debug ?? false,
375
- pushNotificationDeviceToken: this._currentConfig.pushNotificationDeviceToken,
376
- enableMissedCallNotifications: this._currentConfig.enableMissedCallNotifications ?? false,
377
- useTrickleIce: this._currentConfig.useTrickleIce,
378
- enableCallReports: this._currentConfig.enableCallReports,
379
- callReportInterval: this._currentConfig.callReportInterval,
380
- callReportLogLevel: this._currentConfig.callReportLogLevel,
381
- callReportMaxLogEntries: this._currentConfig.callReportMaxLogEntries,
458
+ login: config.sipUser,
459
+ password: config.sipPassword,
460
+ logLevel: config.debug ? 'debug' : 'warn',
461
+ debug: config.debug ?? false,
462
+ pushNotificationDeviceToken: config.pushNotificationDeviceToken,
463
+ pushWhenActive: config.pushWhenActive,
464
+ enableMissedCallNotifications: config.enableMissedCallNotifications ?? false,
465
+ useTrickleIce: config.useTrickleIce,
466
+ enableCallReports: config.enableCallReports,
467
+ callReportInterval: config.callReportInterval,
468
+ callReportLogLevel: config.callReportLogLevel,
469
+ callReportMaxLogEntries: config.callReportMaxLogEntries,
382
470
  sdkVersion: pkg.version,
383
471
  };
384
472
  console.log(
385
473
  '🔧 SessionManager: Creating TelnyxRTC with credential config, logLevel:',
386
474
  clientOptions.logLevel,
387
475
  'pushToken:',
388
- !!this._currentConfig.pushNotificationDeviceToken
476
+ !!config.pushNotificationDeviceToken
389
477
  );
390
- } else if ((0, config_1.isTokenConfig)(this._currentConfig)) {
478
+ } else if ((0, config_1.isTokenConfig)(config)) {
391
479
  clientOptions = {
392
- login_token: this._currentConfig.token,
393
- logLevel: this._currentConfig.debug ? 'debug' : 'warn',
394
- debug: this._currentConfig.debug ?? false,
395
- pushNotificationDeviceToken: this._currentConfig.pushNotificationDeviceToken,
396
- enableMissedCallNotifications: this._currentConfig.enableMissedCallNotifications ?? false,
397
- useTrickleIce: this._currentConfig.useTrickleIce,
398
- enableCallReports: this._currentConfig.enableCallReports,
399
- callReportInterval: this._currentConfig.callReportInterval,
400
- callReportLogLevel: this._currentConfig.callReportLogLevel,
401
- callReportMaxLogEntries: this._currentConfig.callReportMaxLogEntries,
480
+ login_token: config.token,
481
+ logLevel: config.debug ? 'debug' : 'warn',
482
+ debug: config.debug ?? false,
483
+ pushNotificationDeviceToken: config.pushNotificationDeviceToken,
484
+ pushWhenActive: config.pushWhenActive,
485
+ enableMissedCallNotifications: config.enableMissedCallNotifications ?? false,
486
+ useTrickleIce: config.useTrickleIce,
487
+ enableCallReports: config.enableCallReports,
488
+ callReportInterval: config.callReportInterval,
489
+ callReportLogLevel: config.callReportLogLevel,
490
+ callReportMaxLogEntries: config.callReportMaxLogEntries,
402
491
  sdkVersion: pkg.version,
403
492
  };
404
493
  console.log(
405
494
  '🔧 SessionManager: Creating TelnyxRTC with token config, logLevel:',
406
495
  clientOptions.logLevel,
407
496
  'pushToken:',
408
- !!this._currentConfig.pushNotificationDeviceToken
497
+ !!config.pushNotificationDeviceToken
409
498
  );
410
499
  } else {
411
500
  throw new Error('Invalid configuration type');
412
501
  }
413
- this._telnyxClient = new TelnyxSDK.TelnyxRTC(clientOptions);
502
+ client = new TelnyxSDK.TelnyxRTC(clientOptions);
503
+ this._telnyxClient = client;
414
504
  // CRITICAL: Process any pending push notification payload BEFORE connecting
415
505
  // This ensures voice_sdk_id and other payload variables are set before connect() is called
416
506
  const pendingPushPayload = this._pendingPushPayload;
@@ -437,7 +527,7 @@ class SessionManager {
437
527
  // Clear the pending payload
438
528
  this._pendingPushPayload = null;
439
529
  }
440
- this._setupClientListeners();
530
+ this._setupClientListeners(client);
441
531
  // Set up CallStateController listeners immediately after client creation
442
532
  // This ensures they're ready before any incoming call events are emitted
443
533
  console.log(
@@ -451,15 +541,34 @@ class SessionManager {
451
541
  } else {
452
542
  console.log('🔧 SessionManager: No _onClientReady callback found');
453
543
  }
544
+ this._throwIfConnectCanceled(connectionGeneration);
454
545
  // Connect to the platform AFTER processing push notification
455
546
  console.log(
456
547
  'SessionManager: RELEASE DEBUG - About to call connect() after processing push notification'
457
548
  );
458
- await this._telnyxClient.connect();
549
+ await client.connect();
550
+ if (this._isConnectCanceled(connectionGeneration)) {
551
+ await this._disconnectAndForgetClient(client, 'Error during dispose disconnect:');
552
+ canceledConnectCleanupCompleted = true;
553
+ throw this._createConnectCanceledError();
554
+ }
459
555
  // Notify that client is ready for event listeners
460
556
  console.log('🔧 SessionManager: Client connected successfully');
461
557
  } catch (error) {
558
+ if (this._isConnectCanceled(connectionGeneration)) {
559
+ if (client && !canceledConnectCleanupCompleted) {
560
+ await this._disconnectAndForgetClient(client, 'Error during dispose disconnect:');
561
+ }
562
+ throw this._createConnectCanceledError();
563
+ }
462
564
  console.error('Connection failed:', error);
565
+ if (client) {
566
+ try {
567
+ await this._disconnectAndForgetClient(client, 'Error during failed connect cleanup:');
568
+ } catch (cleanupError) {
569
+ console.error('Error during failed connect cleanup:', cleanupError);
570
+ }
571
+ }
463
572
  this._connectionState.next(connection_state_1.TelnyxConnectionState.ERROR);
464
573
  throw error;
465
574
  }
@@ -467,11 +576,14 @@ class SessionManager {
467
576
  /**
468
577
  * Set up event listeners for the Telnyx client
469
578
  */
470
- _setupClientListeners() {
471
- if (!this._telnyxClient) {
579
+ _setupClientListeners(client) {
580
+ if (!client) {
472
581
  return;
473
582
  }
474
- this._telnyxClient.on('telnyx.client.ready', () => {
583
+ client.on('telnyx.client.ready', () => {
584
+ if (this._isTeardownActive() || this._telnyxClient !== client) {
585
+ return;
586
+ }
475
587
  console.log('Telnyx client ready');
476
588
  this._connectionState.next(connection_state_1.TelnyxConnectionState.CONNECTED);
477
589
  // Ensure CallStateController listeners are set up when client becomes ready
@@ -489,7 +601,10 @@ class SessionManager {
489
601
  console.log('🔧 SessionManager: No _onClientReady callback found in client ready event');
490
602
  }
491
603
  });
492
- this._telnyxClient.on('telnyx.client.error', (error) => {
604
+ client.on('telnyx.client.error', (error) => {
605
+ if (this._isTeardownActive() || this._telnyxClient !== client) {
606
+ return;
607
+ }
493
608
  console.error('Telnyx client error:', error);
494
609
  this._connectionState.next(connection_state_1.TelnyxConnectionState.ERROR);
495
610
  });
@@ -545,5 +660,42 @@ class SessionManager {
545
660
  _generateSessionId() {
546
661
  return `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
547
662
  }
663
+ _assertCanStartConnection() {
664
+ if (this._isTeardownActive()) {
665
+ throw new Error('SessionManager has been disposed');
666
+ }
667
+ }
668
+ _isTeardownActive() {
669
+ return this._disposed || this._disposing;
670
+ }
671
+ _isConnectCanceled(connectionGeneration) {
672
+ return this._isTeardownActive() || this._connectionGeneration !== connectionGeneration;
673
+ }
674
+ _throwIfConnectCanceled(connectionGeneration) {
675
+ if (this._isConnectCanceled(connectionGeneration)) {
676
+ throw this._createConnectCanceledError();
677
+ }
678
+ }
679
+ _createConnectCanceledError() {
680
+ return new Error(
681
+ this._isTeardownActive()
682
+ ? 'SessionManager has been disposed'
683
+ : 'SessionManager connection has been canceled'
684
+ );
685
+ }
686
+ async _disconnectClient(client, errorMessage) {
687
+ try {
688
+ await client.disconnect();
689
+ } catch (error) {
690
+ console.error(errorMessage, error);
691
+ throw error;
692
+ }
693
+ }
694
+ async _disconnectAndForgetClient(client, errorMessage) {
695
+ await this._disconnectClient(client, errorMessage);
696
+ if (this._telnyxClient === client) {
697
+ this._telnyxClient = undefined;
698
+ }
699
+ }
548
700
  }
549
701
  exports.SessionManager = SessionManager;
@@ -25,6 +25,9 @@ export interface VoicePnBridgeInterface {
25
25
  ): Promise<boolean>;
26
26
  hideOngoingCallNotification(): Promise<boolean>;
27
27
  hideIncomingCallNotification(): Promise<boolean>;
28
+ setIncomingCallRingtone(resourceName: string | null): Promise<boolean>;
29
+ getPendingCallKitAnswer(): Promise<string | null>;
30
+ clearPendingCallKitAnswer(): Promise<boolean>;
28
31
  getVoipToken(): Promise<string | null>;
29
32
  getPendingVoipPush(): Promise<string | null>;
30
33
  clearPendingVoipPush(): Promise<boolean>;
@@ -91,6 +94,21 @@ export declare class VoicePnBridge {
91
94
  * Useful for dismissing notifications when call is answered/rejected in app
92
95
  */
93
96
  static hideIncomingCallNotification(): Promise<boolean>;
97
+ /**
98
+ * Configure Android's incoming-call ringtone using an app resource in `res/raw`.
99
+ * Pass no value to use the device's default phone ringtone.
100
+ */
101
+ static setIncomingCallRingtone(resourceName?: string): Promise<boolean>;
102
+ /**
103
+ * Get pending CallKit answer UUID from native storage (iOS only).
104
+ * When the user answers a CallKit call before JS listeners are ready,
105
+ * the native side persists the answer UUID in UserDefaults so JS can detect it.
106
+ */
107
+ static getPendingCallKitAnswer(): Promise<string | null>;
108
+ /**
109
+ * Clear pending CallKit answer from native storage (iOS only)
110
+ */
111
+ static clearPendingCallKitAnswer(): Promise<boolean>;
94
112
  /**
95
113
  * Get VoIP token from native storage
96
114
  */
@@ -124,6 +124,45 @@ class VoicePnBridge {
124
124
  return false;
125
125
  }
126
126
  }
127
+ /**
128
+ * Configure Android's incoming-call ringtone using an app resource in `res/raw`.
129
+ * Pass no value to use the device's default phone ringtone.
130
+ */
131
+ static async setIncomingCallRingtone(resourceName) {
132
+ if (react_native_1.Platform.OS !== 'android') return true;
133
+ try {
134
+ return await NativeBridge.setIncomingCallRingtone(resourceName?.trim() || null);
135
+ } catch (error) {
136
+ console.error('VoicePnBridge: Error setting incoming call ringtone:', error);
137
+ return false;
138
+ }
139
+ }
140
+ /**
141
+ * Get pending CallKit answer UUID from native storage (iOS only).
142
+ * When the user answers a CallKit call before JS listeners are ready,
143
+ * the native side persists the answer UUID in UserDefaults so JS can detect it.
144
+ */
145
+ static async getPendingCallKitAnswer() {
146
+ if (react_native_1.Platform.OS !== 'ios') return null;
147
+ try {
148
+ return await NativeBridge.getPendingCallKitAnswer();
149
+ } catch (error) {
150
+ console.error('VoicePnBridge: Error getting pending CallKit answer:', error);
151
+ return null;
152
+ }
153
+ }
154
+ /**
155
+ * Clear pending CallKit answer from native storage (iOS only)
156
+ */
157
+ static async clearPendingCallKitAnswer() {
158
+ if (react_native_1.Platform.OS !== 'ios') return true;
159
+ try {
160
+ return await NativeBridge.clearPendingCallKitAnswer();
161
+ } catch (error) {
162
+ console.error('VoicePnBridge: Error clearing pending CallKit answer:', error);
163
+ return false;
164
+ }
165
+ }
127
166
  /**
128
167
  * Get VoIP token from native storage
129
168
  */
@@ -314,6 +314,18 @@ class Call {
314
314
  throw new Error(`Cannot hold call in state: ${this.currentState}`);
315
315
  }
316
316
  try {
317
+ if (react_native_1.Platform.OS === 'ios') {
318
+ const { callKitCoordinator } = await Promise.resolve().then(() =>
319
+ __importStar(require('../callkit/callkit-coordinator'))
320
+ );
321
+ if (callKitCoordinator.isAvailable()) {
322
+ const success = await callKitCoordinator.setHeldFromUI(this._telnyxCall, true);
323
+ if (!success) {
324
+ throw new Error('CallKit failed to hold call');
325
+ }
326
+ return;
327
+ }
328
+ }
317
329
  await this._telnyxCall.hold();
318
330
  } catch (error) {
319
331
  console.error('Failed to hold call:', error);
@@ -328,6 +340,18 @@ class Call {
328
340
  throw new Error(`Cannot resume call in state: ${this.currentState}`);
329
341
  }
330
342
  try {
343
+ if (react_native_1.Platform.OS === 'ios') {
344
+ const { callKitCoordinator } = await Promise.resolve().then(() =>
345
+ __importStar(require('../callkit/callkit-coordinator'))
346
+ );
347
+ if (callKitCoordinator.isAvailable()) {
348
+ const success = await callKitCoordinator.setHeldFromUI(this._telnyxCall, false);
349
+ if (!success) {
350
+ throw new Error('CallKit failed to resume call');
351
+ }
352
+ return;
353
+ }
354
+ }
331
355
  await this._telnyxCall.unhold();
332
356
  } catch (error) {
333
357
  console.error('Failed to resume call:', error);
@@ -422,6 +446,17 @@ class Call {
422
446
  this._telnyxCall.on('telnyx.call.state', (call, state) => {
423
447
  const telnyxState = this._mapToTelnyxCallState(state);
424
448
  this._callState.next(telnyxState);
449
+ // Keep the dedicated held observable derived from call state. Updating
450
+ // this only after the low-level SDK emits its successful transition
451
+ // preserves the previous value when hold/unhold signaling fails.
452
+ if (telnyxState === call_state_1.TelnyxCallState.HELD) {
453
+ this._isHeld.next(true);
454
+ } else if (
455
+ telnyxState === call_state_1.TelnyxCallState.ACTIVE ||
456
+ call_state_1.CallStateHelpers.isTerminated(telnyxState)
457
+ ) {
458
+ this._isHeld.next(false);
459
+ }
425
460
  // Start duration timer when call becomes active
426
461
  if (telnyxState === call_state_1.TelnyxCallState.ACTIVE && !this._startTime) {
427
462
  this._startDurationTimer();
@@ -7,8 +7,12 @@ export interface CredentialConfig {
7
7
  sipPassword: string;
8
8
  debug?: boolean;
9
9
  pushNotificationDeviceToken?: string;
10
+ /** Receive PushKit calls while the WebSocket session is active. Default: false */
11
+ pushWhenActive?: boolean;
10
12
  /** Enable native missed call push notifications. Default: false */
11
13
  enableMissedCallNotifications?: boolean;
14
+ /** Android only: name of a bundled `res/raw` ringtone (without its file extension). Falls back to the device ringtone. */
15
+ incomingCallRingtone?: string;
12
16
  /** Enable Trickle ICE. Default: false */
13
17
  useTrickleIce?: boolean;
14
18
  /** Enable automatic call quality reporting. Default: true */
@@ -28,8 +32,12 @@ export interface TokenConfig {
28
32
  token: string;
29
33
  debug?: boolean;
30
34
  pushNotificationDeviceToken?: string;
35
+ /** Receive PushKit calls while the WebSocket session is active. Default: false */
36
+ pushWhenActive?: boolean;
31
37
  /** Enable native missed call push notifications. Default: false */
32
38
  enableMissedCallNotifications?: boolean;
39
+ /** Android only: name of a bundled `res/raw` ringtone (without its file extension). Falls back to the device ringtone. */
40
+ incomingCallRingtone?: string;
33
41
  /** Enable Trickle ICE. Default: false */
34
42
  useTrickleIce?: boolean;
35
43
  /** Enable automatic call quality reporting. Default: true */
@@ -73,6 +81,7 @@ export declare function validateConfig(config: Config): string[];
73
81
  * @param options - Optional configuration settings
74
82
  * @param options.debug - Enable debug logging (sets SDK logLevel to 'debug')
75
83
  * @param options.pushNotificationDeviceToken - Device token for push notifications
84
+ * @param options.pushWhenActive - Keep active PushKit devices eligible for incoming calls
76
85
  * @returns Complete credential configuration object
77
86
  */
78
87
  export declare function createCredentialConfig(
@@ -87,6 +96,7 @@ export declare function createCredentialConfig(
87
96
  * @param options - Optional configuration settings
88
97
  * @param options.debug - Enable debug logging (sets SDK logLevel to 'debug')
89
98
  * @param options.pushNotificationDeviceToken - Device token for push notifications
99
+ * @param options.pushWhenActive - Keep active PushKit devices eligible for incoming calls
90
100
  * @returns Complete token configuration object
91
101
  */
92
102
  export declare function createTokenConfig(
@@ -62,6 +62,7 @@ function validateConfig(config) {
62
62
  * @param options - Optional configuration settings
63
63
  * @param options.debug - Enable debug logging (sets SDK logLevel to 'debug')
64
64
  * @param options.pushNotificationDeviceToken - Device token for push notifications
65
+ * @param options.pushWhenActive - Keep active PushKit devices eligible for incoming calls
65
66
  * @returns Complete credential configuration object
66
67
  */
67
68
  function createCredentialConfig(sipUser, sipPassword, options) {
@@ -79,6 +80,7 @@ function createCredentialConfig(sipUser, sipPassword, options) {
79
80
  * @param options - Optional configuration settings
80
81
  * @param options.debug - Enable debug logging (sets SDK logLevel to 'debug')
81
82
  * @param options.pushNotificationDeviceToken - Device token for push notifications
83
+ * @param options.pushWhenActive - Keep active PushKit devices eligible for incoming calls
82
84
  * @returns Complete token configuration object
83
85
  */
84
86
  function createTokenConfig(sipToken, options) {