@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
@@ -1,14 +1,22 @@
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
 
10
11
  const USE_TRICKLE_ICE_STORAGE_KEY = '@use_trickle_ice';
12
+ const PUSH_WHEN_ACTIVE_STORAGE_KEY = '@push_when_active';
11
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
+ }
12
20
 
13
21
  /**
14
22
  * Configuration options for TelnyxVoipClient
@@ -39,7 +47,9 @@ export class TelnyxVoipClient {
39
47
  private readonly _sessionManager: SessionManager;
40
48
  private readonly _callStateController: CallStateController;
41
49
  private readonly _options: Required<TelnyxVoipClientOptions>;
50
+ private readonly _pendingCallKitAnswers = new Map<string, PendingCallKitAnswer>();
42
51
  private _disposed = false;
52
+ private _disposePromise?: Promise<void>;
43
53
 
44
54
  /**
45
55
  * Check if the app was launched from a push notification.
@@ -86,6 +96,7 @@ export class TelnyxVoipClient {
86
96
  console.log(
87
97
  '🔧 TelnyxVoipClient: Client ready, initializing call state controller listeners'
88
98
  );
99
+ this._flushPendingCallKitAnswers();
89
100
  this._callStateController.initializeClientListeners();
90
101
  });
91
102
 
@@ -188,6 +199,71 @@ export class TelnyxVoipClient {
188
199
  return this._callStateController.getCall(callId);
189
200
  }
190
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
+
191
267
  /**
192
268
  * Current session ID (UUID) for this connection.
193
269
  */
@@ -225,10 +301,12 @@ export class TelnyxVoipClient {
225
301
  console.log('TelnyxVoipClient: Logging in with credentials for user:', config.sipUser);
226
302
  }
227
303
 
228
- const loginConfig = {
304
+ const loginConfig = await this._withNativeVoipPushToken({
229
305
  ...config,
230
306
  useTrickleIce: config.useTrickleIce ?? this._options.useTrickleIce,
231
- };
307
+ });
308
+
309
+ await VoicePnBridge.setIncomingCallRingtone(loginConfig.incomingCallRingtone);
232
310
 
233
311
  // Store credentials for future reconnection
234
312
  await this._storeCredentials(loginConfig);
@@ -257,10 +335,12 @@ export class TelnyxVoipClient {
257
335
  console.log('TelnyxVoipClient: Logging in with token');
258
336
  }
259
337
 
260
- const loginConfig = {
338
+ const loginConfig = await this._withNativeVoipPushToken({
261
339
  ...config,
262
340
  useTrickleIce: config.useTrickleIce ?? this._options.useTrickleIce,
263
- };
341
+ });
342
+
343
+ await VoicePnBridge.setIncomingCallRingtone(loginConfig.incomingCallRingtone);
264
344
 
265
345
  // Store token for future reconnection
266
346
  await this._storeToken(loginConfig);
@@ -310,20 +390,30 @@ export class TelnyxVoipClient {
310
390
  const storedCredentialToken = await AsyncStorage.getItem('@credential_token');
311
391
  const storedPushToken = await AsyncStorage.getItem('@push_token');
312
392
  const storedUseTrickleIce = await AsyncStorage.getItem(USE_TRICKLE_ICE_STORAGE_KEY);
393
+ const storedPushWhenActive = await AsyncStorage.getItem(PUSH_WHEN_ACTIVE_STORAGE_KEY);
313
394
  const storedMissedCallNotifications = await AsyncStorage.getItem(
314
395
  MISSED_CALL_NOTIFICATIONS_STORAGE_KEY
315
396
  );
316
397
  const useTrickleIce =
317
398
  storedUseTrickleIce === null ? this._options.useTrickleIce : storedUseTrickleIce === 'true';
399
+ const pushWhenActive = storedPushWhenActive === 'true';
318
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
+ }
319
408
 
320
409
  // Check if we have credential-based authentication data
321
410
  if (storedUsername && storedPassword) {
322
411
  // Create credential config from stored data
323
412
  const { createCredentialConfig } = require('./models/config');
324
413
  const config = createCredentialConfig(storedUsername, storedPassword, {
325
- pushNotificationDeviceToken: storedPushToken,
414
+ pushNotificationDeviceToken,
326
415
  useTrickleIce,
416
+ pushWhenActive,
327
417
  enableMissedCallNotifications,
328
418
  });
329
419
 
@@ -343,8 +433,9 @@ export class TelnyxVoipClient {
343
433
  // Create token config from stored data
344
434
  const { createTokenConfig } = require('./models/config');
345
435
  const config = createTokenConfig(storedCredentialToken, {
346
- pushNotificationDeviceToken: storedPushToken,
436
+ pushNotificationDeviceToken,
347
437
  useTrickleIce,
438
+ pushWhenActive,
348
439
  enableMissedCallNotifications,
349
440
  });
350
441
 
@@ -386,7 +477,7 @@ export class TelnyxVoipClient {
386
477
  destination: string,
387
478
  callerName?: string,
388
479
  callerNumber?: string,
389
- customHeaders?: Record<string, string>
480
+ customHeaders?: CustomHeaders
390
481
  ): Promise<Call> {
391
482
  this._throwIfDisposed();
392
483
 
@@ -484,20 +575,35 @@ export class TelnyxVoipClient {
484
575
  * This should be called when the user answers from CallKit before the socket connection is established
485
576
  * @param customHeaders Optional custom headers to include with the answer
486
577
  */
487
- queueAnswerFromCallKit(customHeaders: Record<string, string> = {}): void {
578
+ queueAnswerFromCallKit(
579
+ callKitUUIDOrHeaders?: string | Record<string, string>,
580
+ customHeaders: Record<string, string> = {}
581
+ ): void {
488
582
  this._throwIfDisposed();
489
583
 
490
584
  if (this._options.debug) {
491
- console.log('TelnyxVoipClient: Queuing answer action from CallKit', customHeaders);
585
+ console.log('TelnyxVoipClient: Queuing answer action from CallKit', {
586
+ callKitUUIDOrHeaders,
587
+ customHeaders,
588
+ });
492
589
  }
493
590
 
494
591
  const telnyxClient = this._sessionManager.telnyxClient;
495
592
  if (telnyxClient && typeof (telnyxClient as any).queueAnswerFromCallKit === 'function') {
496
- (telnyxClient as any).queueAnswerFromCallKit(customHeaders);
593
+ (telnyxClient as any).queueAnswerFromCallKit(callKitUUIDOrHeaders, customHeaders);
497
594
  } else {
498
- console.warn(
499
- 'TelnyxVoipClient: TelnyxRTC client not available or method not found for queueAnswerFromCallKit'
500
- );
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
+ });
501
607
  }
502
608
  }
503
609
 
@@ -505,7 +611,7 @@ export class TelnyxVoipClient {
505
611
  * Queue an end action for when the call invite arrives (for CallKit integration)
506
612
  * This should be called when the user ends from CallKit before the socket connection is established
507
613
  */
508
- queueEndFromCallKit(): void {
614
+ queueEndFromCallKit(callKitUUID?: string): void {
509
615
  this._throwIfDisposed();
510
616
 
511
617
  if (this._options.debug) {
@@ -514,7 +620,7 @@ export class TelnyxVoipClient {
514
620
 
515
621
  const telnyxClient = this._sessionManager.telnyxClient;
516
622
  if (telnyxClient && typeof (telnyxClient as any).queueEndFromCallKit === 'function') {
517
- (telnyxClient as any).queueEndFromCallKit();
623
+ (telnyxClient as any).queueEndFromCallKit(callKitUUID);
518
624
  } else {
519
625
  console.warn(
520
626
  'TelnyxVoipClient: TelnyxRTC client not available or method not found for queueEndFromCallKit'
@@ -522,6 +628,21 @@ export class TelnyxVoipClient {
522
628
  }
523
629
  }
524
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
+
525
646
  // ========== Lifecycle Methods ==========
526
647
 
527
648
  /**
@@ -531,9 +652,9 @@ export class TelnyxVoipClient {
531
652
  * This is particularly important for background clients that should be
532
653
  * disposed after handling push notifications.
533
654
  */
534
- dispose(): void {
535
- if (this._disposed) {
536
- return;
655
+ async dispose(): Promise<void> {
656
+ if (this._disposePromise) {
657
+ return this._disposePromise;
537
658
  }
538
659
 
539
660
  if (this._options.debug) {
@@ -541,12 +662,73 @@ export class TelnyxVoipClient {
541
662
  }
542
663
 
543
664
  this._disposed = true;
544
- this._callStateController.dispose();
545
- 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;
546
675
  }
547
676
 
548
677
  // ========== Private Methods ==========
549
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
+
550
732
  /**
551
733
  * Store credential configuration for automatic reconnection
552
734
  */
@@ -560,6 +742,10 @@ export class TelnyxVoipClient {
560
742
  USE_TRICKLE_ICE_STORAGE_KEY,
561
743
  String(config.useTrickleIce ?? false)
562
744
  );
745
+ await AsyncStorage.setItem(
746
+ PUSH_WHEN_ACTIVE_STORAGE_KEY,
747
+ String(config.pushWhenActive ?? false)
748
+ );
563
749
  await AsyncStorage.setItem(
564
750
  MISSED_CALL_NOTIFICATIONS_STORAGE_KEY,
565
751
  String(config.enableMissedCallNotifications ?? false)
@@ -595,6 +781,10 @@ export class TelnyxVoipClient {
595
781
  USE_TRICKLE_ICE_STORAGE_KEY,
596
782
  String(config.useTrickleIce ?? false)
597
783
  );
784
+ await AsyncStorage.setItem(
785
+ PUSH_WHEN_ACTIVE_STORAGE_KEY,
786
+ String(config.pushWhenActive ?? false)
787
+ );
598
788
  await AsyncStorage.setItem(
599
789
  MISSED_CALL_NOTIFICATIONS_STORAGE_KEY,
600
790
  String(config.enableMissedCallNotifications ?? false)
@@ -654,10 +844,12 @@ export function createTelnyxVoipClient(options?: TelnyxVoipClientOptions): Telny
654
844
  * Disposes the current singleton so that a subsequent call to
655
845
  * `createTelnyxVoipClient()` will create a fresh instance.
656
846
  */
657
- export function destroyTelnyxVoipClient(): void {
658
- if (_sharedInstance) {
659
- _sharedInstance.dispose();
847
+ export async function destroyTelnyxVoipClient(): Promise<void> {
848
+ const sharedInstance = _sharedInstance;
849
+
850
+ if (sharedInstance) {
660
851
  _sharedInstance = null;
852
+ await sharedInstance.dispose();
661
853
  }
662
854
  }
663
855
 
@@ -9,7 +9,7 @@ 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;
@@ -34,6 +34,7 @@ declare module '@telnyx/react-native-voice-sdk' {
34
34
  debug?: boolean;
35
35
  logLevel?: string;
36
36
  pushNotificationDeviceToken?: string;
37
+ pushWhenActive?: boolean;
37
38
  enableMissedCallNotifications?: boolean;
38
39
  useTrickleIce?: boolean;
39
40
  enableCallReports?: boolean;