@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
@@ -1,12 +1,23 @@
1
1
  import { Observable } from 'rxjs';
2
+ import { Platform } from 'react-native';
2
3
  import { TelnyxConnectionState } from './models/connection-state';
3
4
  import { Call } from './models/call';
4
5
  import { TelnyxCallState } from './models/call-state';
5
6
  import { Config, CredentialConfig, TokenConfig, validateConfig } from './models/config';
6
7
  import { SessionManager } from './internal/session/session-manager';
7
- import { CallStateController } from './internal/calls/call-state-controller';
8
+ import { CallStateController, type CustomHeaders } from './internal/calls/call-state-controller';
8
9
  import { VoicePnBridge } from './internal/voice-pn-bridge';
9
10
 
11
+ const USE_TRICKLE_ICE_STORAGE_KEY = '@use_trickle_ice';
12
+ const PUSH_WHEN_ACTIVE_STORAGE_KEY = '@push_when_active';
13
+ const MISSED_CALL_NOTIFICATIONS_STORAGE_KEY = '@enable_missed_call_notifications';
14
+ const LEGACY_CALLKIT_ANSWER_KEY = '__legacy_callkit_answer__';
15
+
16
+ interface PendingCallKitAnswer {
17
+ callKitUUIDOrHeaders?: string | Record<string, string>;
18
+ customHeaders: Record<string, string>;
19
+ }
20
+
10
21
  /**
11
22
  * Configuration options for TelnyxVoipClient
12
23
  */
@@ -16,6 +27,9 @@ export interface TelnyxVoipClientOptions {
16
27
 
17
28
  /** Enable debug logging */
18
29
  debug?: boolean;
30
+
31
+ /** Enable Trickle ICE for calls created by this client */
32
+ useTrickleIce?: boolean;
19
33
  }
20
34
 
21
35
  /**
@@ -33,7 +47,9 @@ export class TelnyxVoipClient {
33
47
  private readonly _sessionManager: SessionManager;
34
48
  private readonly _callStateController: CallStateController;
35
49
  private readonly _options: Required<TelnyxVoipClientOptions>;
50
+ private readonly _pendingCallKitAnswers = new Map<string, PendingCallKitAnswer>();
36
51
  private _disposed = false;
52
+ private _disposePromise?: Promise<void>;
37
53
 
38
54
  /**
39
55
  * Check if the app was launched from a push notification.
@@ -67,6 +83,7 @@ export class TelnyxVoipClient {
67
83
  this._options = {
68
84
  enableAppStateManagement: true,
69
85
  debug: false,
86
+ useTrickleIce: false,
70
87
  ...options,
71
88
  };
72
89
 
@@ -79,6 +96,7 @@ export class TelnyxVoipClient {
79
96
  console.log(
80
97
  '🔧 TelnyxVoipClient: Client ready, initializing call state controller listeners'
81
98
  );
99
+ this._flushPendingCallKitAnswers();
82
100
  this._callStateController.initializeClientListeners();
83
101
  });
84
102
 
@@ -181,6 +199,71 @@ export class TelnyxVoipClient {
181
199
  return this._callStateController.getCall(callId);
182
200
  }
183
201
 
202
+ /**
203
+ * Explicitly set the active call for multi-call scenarios.
204
+ * @param callId The ID of the call to mark as active
205
+ */
206
+ setActiveCall(callId: string): void {
207
+ this._throwIfDisposed();
208
+ this._callStateController.setActiveCall(callId);
209
+ }
210
+
211
+ /**
212
+ * Clear the explicitly selected active call and return to default selection.
213
+ */
214
+ clearActiveCall(): void {
215
+ this._throwIfDisposed();
216
+ this._callStateController.clearActiveCall();
217
+ }
218
+
219
+ /**
220
+ * Swap the current active call with a held call.
221
+ * On iOS this is coordinated through CallKit so native and SDK state stay aligned.
222
+ *
223
+ * @param targetCallId ID of the held call to make active
224
+ */
225
+ async swapCalls(targetCallId: string): Promise<void> {
226
+ this._throwIfDisposed();
227
+
228
+ const activeCall = this.currentActiveCall;
229
+ const heldCall = this.getCall(targetCallId);
230
+
231
+ if (!activeCall || !heldCall || activeCall.callId === heldCall.callId) {
232
+ throw new Error('An active call and a different held call are required to swap calls');
233
+ }
234
+ if (activeCall.currentState !== TelnyxCallState.ACTIVE) {
235
+ throw new Error(`Cannot swap active call in state: ${activeCall.currentState}`);
236
+ }
237
+ if (heldCall.currentState !== TelnyxCallState.HELD) {
238
+ throw new Error(`Cannot swap target call in state: ${heldCall.currentState}`);
239
+ }
240
+
241
+ if (Platform.OS === 'ios') {
242
+ const { callKitCoordinator } = await import('./callkit/callkit-coordinator');
243
+ if (callKitCoordinator.isAvailable()) {
244
+ const success = await callKitCoordinator.swapCallsFromUI(
245
+ activeCall.telnyxCall,
246
+ heldCall.telnyxCall
247
+ );
248
+ if (!success) {
249
+ throw new Error('CallKit failed to swap calls');
250
+ }
251
+ return;
252
+ }
253
+ }
254
+
255
+ await activeCall.hold();
256
+ try {
257
+ await heldCall.resume();
258
+ this.setActiveCall(heldCall.callId);
259
+ } catch (error) {
260
+ await activeCall.resume().catch((resumeError) => {
261
+ console.error('Failed to restore active call after swap failure', resumeError);
262
+ });
263
+ throw error;
264
+ }
265
+ }
266
+
184
267
  /**
185
268
  * Current session ID (UUID) for this connection.
186
269
  */
@@ -218,10 +301,17 @@ export class TelnyxVoipClient {
218
301
  console.log('TelnyxVoipClient: Logging in with credentials for user:', config.sipUser);
219
302
  }
220
303
 
304
+ const loginConfig = await this._withNativeVoipPushToken({
305
+ ...config,
306
+ useTrickleIce: config.useTrickleIce ?? this._options.useTrickleIce,
307
+ });
308
+
309
+ await VoicePnBridge.setIncomingCallRingtone(loginConfig.incomingCallRingtone);
310
+
221
311
  // Store credentials for future reconnection
222
- await this._storeCredentials(config);
312
+ await this._storeCredentials(loginConfig);
223
313
 
224
- await this._sessionManager.connectWithCredential(config);
314
+ await this._sessionManager.connectWithCredential(loginConfig);
225
315
  }
226
316
 
227
317
  /**
@@ -245,10 +335,17 @@ export class TelnyxVoipClient {
245
335
  console.log('TelnyxVoipClient: Logging in with token');
246
336
  }
247
337
 
338
+ const loginConfig = await this._withNativeVoipPushToken({
339
+ ...config,
340
+ useTrickleIce: config.useTrickleIce ?? this._options.useTrickleIce,
341
+ });
342
+
343
+ await VoicePnBridge.setIncomingCallRingtone(loginConfig.incomingCallRingtone);
344
+
248
345
  // Store token for future reconnection
249
- await this._storeToken(config);
346
+ await this._storeToken(loginConfig);
250
347
 
251
- await this._sessionManager.connectWithToken(config);
348
+ await this._sessionManager.connectWithToken(loginConfig);
252
349
  }
253
350
 
254
351
  /**
@@ -292,13 +389,32 @@ export class TelnyxVoipClient {
292
389
  const storedPassword = await AsyncStorage.getItem('@telnyx_password');
293
390
  const storedCredentialToken = await AsyncStorage.getItem('@credential_token');
294
391
  const storedPushToken = await AsyncStorage.getItem('@push_token');
392
+ const storedUseTrickleIce = await AsyncStorage.getItem(USE_TRICKLE_ICE_STORAGE_KEY);
393
+ const storedPushWhenActive = await AsyncStorage.getItem(PUSH_WHEN_ACTIVE_STORAGE_KEY);
394
+ const storedMissedCallNotifications = await AsyncStorage.getItem(
395
+ MISSED_CALL_NOTIFICATIONS_STORAGE_KEY
396
+ );
397
+ const useTrickleIce =
398
+ storedUseTrickleIce === null ? this._options.useTrickleIce : storedUseTrickleIce === 'true';
399
+ const pushWhenActive = storedPushWhenActive === 'true';
400
+ const enableMissedCallNotifications = storedMissedCallNotifications === 'true';
401
+ const nativePushToken =
402
+ Platform.OS === 'ios' ? (await VoicePnBridge.getVoipToken())?.trim() : null;
403
+ const pushNotificationDeviceToken = nativePushToken || storedPushToken;
404
+
405
+ if (nativePushToken && nativePushToken !== storedPushToken) {
406
+ await AsyncStorage.setItem('@push_token', nativePushToken);
407
+ }
295
408
 
296
409
  // Check if we have credential-based authentication data
297
410
  if (storedUsername && storedPassword) {
298
411
  // Create credential config from stored data
299
412
  const { createCredentialConfig } = require('./models/config');
300
413
  const config = createCredentialConfig(storedUsername, storedPassword, {
301
- pushNotificationDeviceToken: storedPushToken,
414
+ pushNotificationDeviceToken,
415
+ useTrickleIce,
416
+ pushWhenActive,
417
+ enableMissedCallNotifications,
302
418
  });
303
419
 
304
420
  if (this._options.debug) {
@@ -317,7 +433,10 @@ export class TelnyxVoipClient {
317
433
  // Create token config from stored data
318
434
  const { createTokenConfig } = require('./models/config');
319
435
  const config = createTokenConfig(storedCredentialToken, {
320
- pushNotificationDeviceToken: storedPushToken,
436
+ pushNotificationDeviceToken,
437
+ useTrickleIce,
438
+ pushWhenActive,
439
+ enableMissedCallNotifications,
321
440
  });
322
441
 
323
442
  if (this._options.debug) {
@@ -358,7 +477,7 @@ export class TelnyxVoipClient {
358
477
  destination: string,
359
478
  callerName?: string,
360
479
  callerNumber?: string,
361
- customHeaders?: Record<string, string>
480
+ customHeaders?: CustomHeaders
362
481
  ): Promise<Call> {
363
482
  this._throwIfDisposed();
364
483
 
@@ -456,20 +575,35 @@ export class TelnyxVoipClient {
456
575
  * This should be called when the user answers from CallKit before the socket connection is established
457
576
  * @param customHeaders Optional custom headers to include with the answer
458
577
  */
459
- queueAnswerFromCallKit(customHeaders: Record<string, string> = {}): void {
578
+ queueAnswerFromCallKit(
579
+ callKitUUIDOrHeaders?: string | Record<string, string>,
580
+ customHeaders: Record<string, string> = {}
581
+ ): void {
460
582
  this._throwIfDisposed();
461
583
 
462
584
  if (this._options.debug) {
463
- console.log('TelnyxVoipClient: Queuing answer action from CallKit', customHeaders);
585
+ console.log('TelnyxVoipClient: Queuing answer action from CallKit', {
586
+ callKitUUIDOrHeaders,
587
+ customHeaders,
588
+ });
464
589
  }
465
590
 
466
591
  const telnyxClient = this._sessionManager.telnyxClient;
467
592
  if (telnyxClient && typeof (telnyxClient as any).queueAnswerFromCallKit === 'function') {
468
- (telnyxClient as any).queueAnswerFromCallKit(customHeaders);
593
+ (telnyxClient as any).queueAnswerFromCallKit(callKitUUIDOrHeaders, customHeaders);
469
594
  } else {
470
- console.warn(
471
- 'TelnyxVoipClient: TelnyxRTC client not available or method not found for queueAnswerFromCallKit'
472
- );
595
+ const normalizedUUID =
596
+ typeof callKitUUIDOrHeaders === 'string' ? callKitUUIDOrHeaders.toLowerCase() : undefined;
597
+ const actionKey = normalizedUUID ?? LEGACY_CALLKIT_ANSWER_KEY;
598
+ let retainedUUIDOrHeaders: string | Record<string, string> | undefined = normalizedUUID;
599
+ if (callKitUUIDOrHeaders && typeof callKitUUIDOrHeaders !== 'string') {
600
+ retainedUUIDOrHeaders = { ...callKitUUIDOrHeaders };
601
+ }
602
+
603
+ this._pendingCallKitAnswers.set(actionKey, {
604
+ callKitUUIDOrHeaders: retainedUUIDOrHeaders,
605
+ customHeaders: { ...customHeaders },
606
+ });
473
607
  }
474
608
  }
475
609
 
@@ -477,7 +611,7 @@ export class TelnyxVoipClient {
477
611
  * Queue an end action for when the call invite arrives (for CallKit integration)
478
612
  * This should be called when the user ends from CallKit before the socket connection is established
479
613
  */
480
- queueEndFromCallKit(): void {
614
+ queueEndFromCallKit(callKitUUID?: string): void {
481
615
  this._throwIfDisposed();
482
616
 
483
617
  if (this._options.debug) {
@@ -486,7 +620,7 @@ export class TelnyxVoipClient {
486
620
 
487
621
  const telnyxClient = this._sessionManager.telnyxClient;
488
622
  if (telnyxClient && typeof (telnyxClient as any).queueEndFromCallKit === 'function') {
489
- (telnyxClient as any).queueEndFromCallKit();
623
+ (telnyxClient as any).queueEndFromCallKit(callKitUUID);
490
624
  } else {
491
625
  console.warn(
492
626
  'TelnyxVoipClient: TelnyxRTC client not available or method not found for queueEndFromCallKit'
@@ -494,6 +628,21 @@ export class TelnyxVoipClient {
494
628
  }
495
629
  }
496
630
 
631
+ /**
632
+ * Associate the next push-delivered INVITE with its app-facing CallKit UUID.
633
+ * The underlying signaling call ID remains unchanged.
634
+ * @internal
635
+ */
636
+ setPushNotificationCallKitUUID(callKitUUID: string | null): void {
637
+ const telnyxClient = this._sessionManager.telnyxClient;
638
+ if (
639
+ telnyxClient &&
640
+ typeof (telnyxClient as any).setPushNotificationCallKitUUID === 'function'
641
+ ) {
642
+ (telnyxClient as any).setPushNotificationCallKitUUID(callKitUUID);
643
+ }
644
+ }
645
+
497
646
  // ========== Lifecycle Methods ==========
498
647
 
499
648
  /**
@@ -503,9 +652,9 @@ export class TelnyxVoipClient {
503
652
  * This is particularly important for background clients that should be
504
653
  * disposed after handling push notifications.
505
654
  */
506
- dispose(): void {
507
- if (this._disposed) {
508
- return;
655
+ async dispose(): Promise<void> {
656
+ if (this._disposePromise) {
657
+ return this._disposePromise;
509
658
  }
510
659
 
511
660
  if (this._options.debug) {
@@ -513,12 +662,73 @@ export class TelnyxVoipClient {
513
662
  }
514
663
 
515
664
  this._disposed = true;
516
- this._callStateController.dispose();
517
- this._sessionManager.dispose();
665
+ this._disposePromise = (async () => {
666
+ try {
667
+ await this._sessionManager.dispose();
668
+ } finally {
669
+ this._pendingCallKitAnswers.clear();
670
+ this._callStateController.dispose();
671
+ }
672
+ })();
673
+
674
+ return this._disposePromise;
518
675
  }
519
676
 
520
677
  // ========== Private Methods ==========
521
678
 
679
+ /**
680
+ * Forward answers captured during cold start as soon as SessionManager has
681
+ * created the TelnyxRTC instance. SessionManager invokes its ready callback
682
+ * before connect(), so the UUID-keyed action is present when the INVITE
683
+ * arrives.
684
+ */
685
+ private _flushPendingCallKitAnswers(): void {
686
+ const telnyxClient = this._sessionManager.telnyxClient;
687
+ if (!telnyxClient || typeof (telnyxClient as any).queueAnswerFromCallKit !== 'function') {
688
+ return;
689
+ }
690
+
691
+ for (const [actionKey, pendingAnswer] of this._pendingCallKitAnswers) {
692
+ try {
693
+ (telnyxClient as any).queueAnswerFromCallKit(
694
+ pendingAnswer.callKitUUIDOrHeaders,
695
+ pendingAnswer.customHeaders
696
+ );
697
+ this._pendingCallKitAnswers.delete(actionKey);
698
+ } catch (error) {
699
+ console.error('TelnyxVoipClient: Failed to restore pending CallKit answer', {
700
+ actionKey,
701
+ error,
702
+ });
703
+ }
704
+ }
705
+ }
706
+
707
+ /**
708
+ * Prefer an explicitly supplied token, otherwise hydrate it from PushKit's
709
+ * native storage. PushKit registration starts in AppDelegate before React
710
+ * mounts, so this removes the race between the JS token event and login.
711
+ */
712
+ private async _withNativeVoipPushToken<T extends Config>(config: T): Promise<T> {
713
+ if (Platform.OS !== 'ios' || config.pushNotificationDeviceToken?.trim()) {
714
+ return config;
715
+ }
716
+
717
+ const nativePushToken = (await VoicePnBridge.getVoipToken())?.trim();
718
+ if (!nativePushToken) {
719
+ return config;
720
+ }
721
+
722
+ if (this._options.debug) {
723
+ console.log('TelnyxVoipClient: Using PushKit token from native storage');
724
+ }
725
+
726
+ return {
727
+ ...config,
728
+ pushNotificationDeviceToken: nativePushToken,
729
+ };
730
+ }
731
+
522
732
  /**
523
733
  * Store credential configuration for automatic reconnection
524
734
  */
@@ -528,6 +738,18 @@ export class TelnyxVoipClient {
528
738
 
529
739
  await AsyncStorage.setItem('@telnyx_username', config.sipUser);
530
740
  await AsyncStorage.setItem('@telnyx_password', config.sipPassword);
741
+ await AsyncStorage.setItem(
742
+ USE_TRICKLE_ICE_STORAGE_KEY,
743
+ String(config.useTrickleIce ?? false)
744
+ );
745
+ await AsyncStorage.setItem(
746
+ PUSH_WHEN_ACTIVE_STORAGE_KEY,
747
+ String(config.pushWhenActive ?? false)
748
+ );
749
+ await AsyncStorage.setItem(
750
+ MISSED_CALL_NOTIFICATIONS_STORAGE_KEY,
751
+ String(config.enableMissedCallNotifications ?? false)
752
+ );
531
753
 
532
754
  if (config.pushNotificationDeviceToken) {
533
755
  await AsyncStorage.setItem('@push_token', config.pushNotificationDeviceToken);
@@ -555,6 +777,18 @@ export class TelnyxVoipClient {
555
777
  const AsyncStorage = require('@react-native-async-storage/async-storage').default;
556
778
 
557
779
  await AsyncStorage.setItem('@credential_token', config.token);
780
+ await AsyncStorage.setItem(
781
+ USE_TRICKLE_ICE_STORAGE_KEY,
782
+ String(config.useTrickleIce ?? false)
783
+ );
784
+ await AsyncStorage.setItem(
785
+ PUSH_WHEN_ACTIVE_STORAGE_KEY,
786
+ String(config.pushWhenActive ?? false)
787
+ );
788
+ await AsyncStorage.setItem(
789
+ MISSED_CALL_NOTIFICATIONS_STORAGE_KEY,
790
+ String(config.enableMissedCallNotifications ?? false)
791
+ );
558
792
 
559
793
  if (config.pushNotificationDeviceToken) {
560
794
  await AsyncStorage.setItem('@push_token', config.pushNotificationDeviceToken);
@@ -610,10 +844,12 @@ export function createTelnyxVoipClient(options?: TelnyxVoipClientOptions): Telny
610
844
  * Disposes the current singleton so that a subsequent call to
611
845
  * `createTelnyxVoipClient()` will create a fresh instance.
612
846
  */
613
- export function destroyTelnyxVoipClient(): void {
614
- if (_sharedInstance) {
615
- _sharedInstance.dispose();
847
+ export async function destroyTelnyxVoipClient(): Promise<void> {
848
+ const sharedInstance = _sharedInstance;
849
+
850
+ if (sharedInstance) {
616
851
  _sharedInstance = null;
852
+ await sharedInstance.dispose();
617
853
  }
618
854
  }
619
855
 
@@ -9,11 +9,19 @@ declare module '@telnyx/react-native-voice-sdk' {
9
9
  export interface CallOptions {
10
10
  callerIdName?: string;
11
11
  callerIdNumber?: string;
12
- customHeaders?: Record<string, string>;
12
+ customHeaders?: { name: string; value: string }[];
13
13
  clientState?: string;
14
14
  destinationNumber?: string;
15
15
  audio?: boolean;
16
16
  video?: boolean;
17
+ peerConnectionOptions?: {
18
+ prefetchIceCandidates?: boolean;
19
+ useTrickleIce?: boolean;
20
+ iceServers?: any[];
21
+ iceTransportPolicy?: string;
22
+ bundlePolicy?: string;
23
+ rtcpMuxPolicy?: string;
24
+ };
17
25
  }
18
26
 
19
27
  export interface ClientOptions {
@@ -26,6 +34,9 @@ declare module '@telnyx/react-native-voice-sdk' {
26
34
  debug?: boolean;
27
35
  logLevel?: string;
28
36
  pushNotificationDeviceToken?: string;
37
+ pushWhenActive?: boolean;
38
+ enableMissedCallNotifications?: boolean;
39
+ useTrickleIce?: boolean;
29
40
  enableCallReports?: boolean;
30
41
  callReportInterval?: number;
31
42
  callReportLogLevel?: string;