@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
@@ -6,7 +6,30 @@ const react_1 = require('react');
6
6
  const react_native_1 = require('react-native');
7
7
  const telnyx_voip_client_1 = require('./telnyx-voip-client');
8
8
  const connection_state_1 = require('./models/connection-state');
9
+ const call_state_1 = require('./models/call-state');
9
10
  const TelnyxVoiceContext_1 = require('./context/TelnyxVoiceContext');
11
+ let sharedBackgroundClient = null;
12
+ let sharedBackgroundDisposePromise = null;
13
+ const disposeSharedBackgroundClient = async (log, expectedClient) => {
14
+ if (sharedBackgroundDisposePromise) {
15
+ await sharedBackgroundDisposePromise;
16
+ }
17
+ const backgroundClient = expectedClient ?? sharedBackgroundClient;
18
+ if (!backgroundClient || (expectedClient && sharedBackgroundClient !== expectedClient)) {
19
+ return;
20
+ }
21
+ sharedBackgroundClient = null;
22
+ log('Disposing background client instance');
23
+ sharedBackgroundDisposePromise = backgroundClient
24
+ .dispose()
25
+ .catch((e) => {
26
+ log('Error disposing background client instance:', e);
27
+ })
28
+ .finally(() => {
29
+ sharedBackgroundDisposePromise = null;
30
+ });
31
+ await sharedBackgroundDisposePromise;
32
+ };
10
33
  /**
11
34
  * A comprehensive wrapper component that handles all Telnyx SDK lifecycle management.
12
35
  *
@@ -39,11 +62,17 @@ const TelnyxVoiceAppComponent = ({
39
62
  const [currentConnectionState, setCurrentConnectionState] = (0, react_1.useState)(
40
63
  voipClient.currentConnectionState
41
64
  );
65
+ // Refs that mirror mutable state for use inside long-lived effects and
66
+ // callbacks. Reading these flags through refs (instead of the state
67
+ // variables) keeps the setup effect's dependency array stable so
68
+ // connection/call subscriptions are not torn down and recreated every
69
+ // time the flags change — fixing the subscription gap described in
70
+ // VSDK-344.
71
+ const isHandlingForegroundCallRef = (0, react_1.useRef)(false);
72
+ const processingPushOnLaunchRef = (0, react_1.useRef)(false);
42
73
  // Refs for tracking state
43
74
  const appStateRef = (0, react_1.useRef)(react_native_1.AppState.currentState);
44
75
  const backgroundDetectorIgnore = (0, react_1.useRef)(false);
45
- // Static background client instance for singleton pattern
46
- const backgroundClientRef = (0, react_1.useRef)(null);
47
76
  const log = (0, react_1.useCallback)(
48
77
  (message, ...args) => {
49
78
  if (debug) {
@@ -59,7 +88,7 @@ const TelnyxVoiceAppComponent = ({
59
88
  appStateRef.current = nextAppState;
60
89
  log(`App state changed from ${previousAppState} to ${nextAppState}`);
61
90
  log(`Background detector ignore flag: ${backgroundDetectorIgnore.current}`);
62
- log(`Handling foreground call: ${isHandlingForegroundCall}`);
91
+ log(`Handling foreground call: ${isHandlingForegroundCallRef.current}`);
63
92
  // Call optional user callback first
64
93
  onAppStateChanged?.(nextAppState);
65
94
  // Only handle background disconnection when actually transitioning from active to background
@@ -87,12 +116,12 @@ const TelnyxVoiceAppComponent = ({
87
116
  await handleAppResumed();
88
117
  }
89
118
  },
90
- [enableAutoReconnect, onAppStateChanged, isHandlingForegroundCall, log]
119
+ [enableAutoReconnect, onAppStateChanged, log]
91
120
  );
92
121
  // Handle app going to background - disconnect like the old implementation
93
122
  const handleAppBackgrounded = (0, react_1.useCallback)(async () => {
94
123
  // Check if we should ignore background detection (e.g., during active calls)
95
- if (backgroundDetectorIgnore.current || isHandlingForegroundCall) {
124
+ if (backgroundDetectorIgnore.current || isHandlingForegroundCallRef.current) {
96
125
  log(
97
126
  'Background detector ignore flag set or handling foreground call - skipping disconnection'
98
127
  );
@@ -140,7 +169,7 @@ const TelnyxVoiceAppComponent = ({
140
169
  } catch (e) {
141
170
  log('Error disconnecting on background:', e);
142
171
  }
143
- }, [voipClient, isHandlingForegroundCall, log]);
172
+ }, [voipClient, log]);
144
173
  // Handle app resuming from background
145
174
  const handleAppResumed = (0, react_1.useCallback)(async () => {
146
175
  log('App resumed - checking reconnection needs');
@@ -148,7 +177,7 @@ const TelnyxVoiceAppComponent = ({
148
177
  // This handles the case where the user accepted a call while the app was backgrounded
149
178
  await checkForInitialPushNotification(true); // Pass true for fromAppResume
150
179
  // If we're ignoring (e.g., from push call) or handling foreground call, don't auto-reconnect
151
- if (backgroundDetectorIgnore.current || isHandlingForegroundCall) {
180
+ if (backgroundDetectorIgnore.current || isHandlingForegroundCallRef.current) {
152
181
  log(
153
182
  'Background detector ignore flag set or handling foreground call - skipping reconnection'
154
183
  );
@@ -176,7 +205,7 @@ const TelnyxVoiceAppComponent = ({
176
205
  if (currentState !== connection_state_1.TelnyxConnectionState.CONNECTED) {
177
206
  await attemptAutoReconnection();
178
207
  }
179
- }, [voipClient, isHandlingForegroundCall, log]);
208
+ }, [voipClient, log]);
180
209
  // Attempt to reconnect using stored credentials
181
210
  const attemptAutoReconnection = (0, react_1.useCallback)(async () => {
182
211
  try {
@@ -296,12 +325,13 @@ const TelnyxVoiceAppComponent = ({
296
325
  const checkForInitialPushNotification = (0, react_1.useCallback)(
297
326
  async (fromAppResume = false) => {
298
327
  log(`checkForInitialPushNotification called${fromAppResume ? ' (from app resume)' : ''}`);
299
- if (processingPushOnLaunch && !fromAppResume) {
328
+ if (processingPushOnLaunchRef.current && !fromAppResume) {
300
329
  log('Already processing push, returning early');
301
330
  return;
302
331
  }
303
332
  if (!fromAppResume) {
304
333
  setProcessingPushOnLaunch(true);
334
+ processingPushOnLaunchRef.current = true;
305
335
  }
306
336
  onPushNotificationProcessingStarted?.();
307
337
  try {
@@ -324,11 +354,20 @@ const TelnyxVoiceAppComponent = ({
324
354
  // Prevent duplicate processing if already connected or connecting.
325
355
  // Since push data is no longer cleared on read, this guard prevents
326
356
  // re-processing when checkForInitialPushNotification fires again on app resume.
327
- if (
357
+ const isConnectedOrConnecting =
328
358
  voipClient.currentConnectionState ===
329
359
  connection_state_1.TelnyxConnectionState.CONNECTED ||
330
- voipClient.currentConnectionState === connection_state_1.TelnyxConnectionState.CONNECTING
331
- ) {
360
+ voipClient.currentConnectionState === connection_state_1.TelnyxConnectionState.CONNECTING;
361
+ const isActiveIOSCallWaitingPush =
362
+ react_native_1.Platform.OS === 'ios' &&
363
+ voipClient.currentConnectionState ===
364
+ connection_state_1.TelnyxConnectionState.CONNECTED &&
365
+ voipClient.currentCalls.some(
366
+ (call) =>
367
+ call.currentState === call_state_1.TelnyxCallState.ACTIVE ||
368
+ call.currentState === call_state_1.TelnyxCallState.HELD
369
+ );
370
+ if (isConnectedOrConnecting && !isActiveIOSCallWaitingPush) {
332
371
  log(
333
372
  `SKIPPING - Already ${voipClient.currentConnectionState}, preventing duplicate processing`
334
373
  );
@@ -336,20 +375,31 @@ const TelnyxVoiceAppComponent = ({
336
375
  }
337
376
  // Set flags to prevent auto-reconnection during push call
338
377
  setIsHandlingForegroundCall(true);
378
+ isHandlingForegroundCallRef.current = true;
339
379
  backgroundDetectorIgnore.current = true;
340
380
  log(`Background detector ignore set to: true at ${new Date().toISOString()}`);
341
381
  log(`Foreground call handling flag set to: true at ${new Date().toISOString()}`);
342
- disposeBackgroundClient();
382
+ await disposeBackgroundClient();
343
383
  // On iOS, coordinate with CallKit using the call_id from push metadata
344
384
  if (react_native_1.Platform.OS === 'ios') {
345
385
  const callId = pushData.metadata?.call_id;
346
386
  if (callId) {
347
387
  const { callKitCoordinator } = require('./callkit/callkit-coordinator');
348
388
  log('Notifying CallKit coordinator about push notification:', callId);
349
- await callKitCoordinator.handleCallKitPushReceived(callId, {
389
+ const processed = await callKitCoordinator.handleCallKitPushReceived(callId, {
350
390
  callData: { source: 'push_notification' },
351
391
  pushData: pushData,
352
392
  });
393
+ if (!processed) {
394
+ // The push was filtered, rejected, or otherwise ignored by
395
+ // CallKit. Reset the foreground-push lifecycle flags so the
396
+ // app does not stay in a state that skips background
397
+ // disconnect/reconnect handling.
398
+ log('CallKit push was not processed - resetting foreground flags');
399
+ setIsHandlingForegroundCall(false);
400
+ isHandlingForegroundCallRef.current = false;
401
+ backgroundDetectorIgnore.current = false;
402
+ }
353
403
  } else {
354
404
  log('No call_id found in push data, falling back to direct handling');
355
405
  await voipClient.handlePushNotification(pushData);
@@ -361,26 +411,18 @@ const TelnyxVoiceAppComponent = ({
361
411
  } catch (e) {
362
412
  log('Error processing initial push notification:', e);
363
413
  setIsHandlingForegroundCall(false);
414
+ isHandlingForegroundCallRef.current = false;
364
415
  } finally {
365
416
  setProcessingPushOnLaunch(false);
417
+ processingPushOnLaunchRef.current = false;
366
418
  onPushNotificationProcessingCompleted?.();
367
419
  }
368
420
  },
369
- [
370
- processingPushOnLaunch,
371
- voipClient,
372
- onPushNotificationProcessingStarted,
373
- onPushNotificationProcessingCompleted,
374
- log,
375
- ]
421
+ [voipClient, onPushNotificationProcessingStarted, onPushNotificationProcessingCompleted, log]
376
422
  );
377
423
  // Dispose background client instance when no longer needed
378
- const disposeBackgroundClient = (0, react_1.useCallback)(() => {
379
- if (backgroundClientRef.current) {
380
- log('Disposing background client instance');
381
- backgroundClientRef.current.dispose();
382
- backgroundClientRef.current = null;
383
- }
424
+ const disposeBackgroundClient = (0, react_1.useCallback)(async () => {
425
+ await disposeSharedBackgroundClient(log);
384
426
  }, [log]);
385
427
  // Create background client for push notification handling
386
428
  const createBackgroundClient = (0, react_1.useCallback)(() => {
@@ -429,17 +471,18 @@ const TelnyxVoiceAppComponent = ({
429
471
  }
430
472
  }
431
473
  log(
432
- `Flag reset check: WebRTC calls=${calls.length}, CallKit processing=${hasCallKitProcessing}, isHandlingForegroundCall=${isHandlingForegroundCall}, backgroundDetectorIgnore=${backgroundDetectorIgnore.current}`
474
+ `Flag reset check: WebRTC calls=${calls.length}, CallKit processing=${hasCallKitProcessing}, isHandlingForegroundCall=${isHandlingForegroundCallRef.current}, backgroundDetectorIgnore=${backgroundDetectorIgnore.current}`
433
475
  );
434
476
  if (
435
477
  !hasActiveWebRTCCalls &&
436
478
  !hasCallKitProcessing &&
437
- (isHandlingForegroundCall || backgroundDetectorIgnore.current)
479
+ (isHandlingForegroundCallRef.current || backgroundDetectorIgnore.current)
438
480
  ) {
439
481
  log(
440
482
  `No active calls and no CallKit processing - resetting ignore flags at ${new Date().toISOString()}`
441
483
  );
442
484
  setIsHandlingForegroundCall(false);
485
+ isHandlingForegroundCallRef.current = false;
443
486
  backgroundDetectorIgnore.current = false;
444
487
  } else if (!hasActiveWebRTCCalls && hasCallKitProcessing) {
445
488
  log(
@@ -450,9 +493,10 @@ const TelnyxVoiceAppComponent = ({
450
493
  }
451
494
  // Also reset processingPushOnLaunch if no calls are active
452
495
  // This ensures the flag doesn't get stuck after call ends
453
- if (calls.length === 0 && processingPushOnLaunch) {
496
+ if (calls.length === 0 && processingPushOnLaunchRef.current) {
454
497
  log('No active calls - resetting processing push flag');
455
498
  setProcessingPushOnLaunch(false);
499
+ processingPushOnLaunchRef.current = false;
456
500
  }
457
501
  });
458
502
  // Listen for immediate call action events from notification buttons (Android only)
@@ -501,7 +545,7 @@ const TelnyxVoiceAppComponent = ({
501
545
  // Handle initial push notification if app was launched from terminated state
502
546
  // Only check if we're not already processing to prevent infinite loops
503
547
  const timeoutId = setTimeout(() => {
504
- if (!processingPushOnLaunch) {
548
+ if (!processingPushOnLaunchRef.current) {
505
549
  checkForInitialPushNotification();
506
550
  }
507
551
  }, 100);
@@ -523,16 +567,9 @@ const TelnyxVoiceAppComponent = ({
523
567
  }
524
568
  clearTimeout(timeoutId);
525
569
  // Clean up background client instance
526
- disposeBackgroundClient();
570
+ void disposeBackgroundClient();
527
571
  };
528
- }, [
529
- voipClient,
530
- handleAppStateChange,
531
- disposeBackgroundClient,
532
- skipWebBackgroundDetection,
533
- isHandlingForegroundCall,
534
- log,
535
- ]);
572
+ }, [voipClient, handleAppStateChange, disposeBackgroundClient, skipWebBackgroundDetection, log]);
536
573
  // Simply return the children wrapped in context provider - all lifecycle management is handled internally
537
574
  return (0, jsx_runtime_1.jsx)(TelnyxVoiceContext_1.TelnyxVoiceProvider, {
538
575
  voipClient: voipClient,
@@ -608,18 +645,23 @@ const initializeAndCreate = async (options) => {
608
645
  */
609
646
  const handleBackgroundPush = async (message) => {
610
647
  console.log('[TelnyxVoiceApp] Background push received:', message);
648
+ let backgroundClient = null;
611
649
  try {
612
650
  // TODO: Initialize push notification service in isolate if needed
651
+ await disposeSharedBackgroundClient(console.log);
613
652
  // Use singleton pattern for background client to prevent multiple instances
614
- let backgroundClient = (0, telnyx_voip_client_1.createBackgroundTelnyxVoipClient)({
653
+ backgroundClient = (0, telnyx_voip_client_1.createBackgroundTelnyxVoipClient)({
615
654
  debug: false,
616
655
  });
656
+ sharedBackgroundClient = backgroundClient;
617
657
  await backgroundClient.handlePushNotification(message);
618
658
  console.log('[TelnyxVoiceApp] Background push processed successfully');
619
- // Clean up the background client
620
- backgroundClient.dispose();
621
659
  } catch (e) {
622
660
  console.log('[TelnyxVoiceApp] Error processing background push:', e);
661
+ } finally {
662
+ if (backgroundClient) {
663
+ await disposeSharedBackgroundClient(console.log, backgroundClient);
664
+ }
623
665
  }
624
666
  };
625
667
  // Create the component with static methods
@@ -2,6 +2,7 @@ import { Observable } from 'rxjs';
2
2
  import { TelnyxConnectionState } from './models/connection-state';
3
3
  import { Call } from './models/call';
4
4
  import { CredentialConfig, TokenConfig } from './models/config';
5
+ import { type CustomHeaders } from './internal/calls/call-state-controller';
5
6
  /**
6
7
  * Configuration options for TelnyxVoipClient
7
8
  */
@@ -28,7 +29,9 @@ export declare class TelnyxVoipClient {
28
29
  private readonly _sessionManager;
29
30
  private readonly _callStateController;
30
31
  private readonly _options;
32
+ private readonly _pendingCallKitAnswers;
31
33
  private _disposed;
34
+ private _disposePromise?;
32
35
  /**
33
36
  * Check if the app was launched from a push notification.
34
37
  *
@@ -102,6 +105,22 @@ export declare class TelnyxVoipClient {
102
105
  * ```
103
106
  */
104
107
  getCall(callId: string): Call | null;
108
+ /**
109
+ * Explicitly set the active call for multi-call scenarios.
110
+ * @param callId The ID of the call to mark as active
111
+ */
112
+ setActiveCall(callId: string): void;
113
+ /**
114
+ * Clear the explicitly selected active call and return to default selection.
115
+ */
116
+ clearActiveCall(): void;
117
+ /**
118
+ * Swap the current active call with a held call.
119
+ * On iOS this is coordinated through CallKit so native and SDK state stay aligned.
120
+ *
121
+ * @param targetCallId ID of the held call to make active
122
+ */
123
+ swapCalls(targetCallId: string): Promise<void>;
105
124
  /**
106
125
  * Current session ID (UUID) for this connection.
107
126
  */
@@ -161,7 +180,7 @@ export declare class TelnyxVoipClient {
161
180
  destination: string,
162
181
  callerName?: string,
163
182
  callerNumber?: string,
164
- customHeaders?: Record<string, string>
183
+ customHeaders?: CustomHeaders
165
184
  ): Promise<Call>;
166
185
  /**
167
186
  * Handle push notification payload.
@@ -197,12 +216,21 @@ export declare class TelnyxVoipClient {
197
216
  * This should be called when the user answers from CallKit before the socket connection is established
198
217
  * @param customHeaders Optional custom headers to include with the answer
199
218
  */
200
- queueAnswerFromCallKit(customHeaders?: Record<string, string>): void;
219
+ queueAnswerFromCallKit(
220
+ callKitUUIDOrHeaders?: string | Record<string, string>,
221
+ customHeaders?: Record<string, string>
222
+ ): void;
201
223
  /**
202
224
  * Queue an end action for when the call invite arrives (for CallKit integration)
203
225
  * This should be called when the user ends from CallKit before the socket connection is established
204
226
  */
205
- queueEndFromCallKit(): void;
227
+ queueEndFromCallKit(callKitUUID?: string): void;
228
+ /**
229
+ * Associate the next push-delivered INVITE with its app-facing CallKit UUID.
230
+ * The underlying signaling call ID remains unchanged.
231
+ * @internal
232
+ */
233
+ setPushNotificationCallKitUUID(callKitUUID: string | null): void;
206
234
  /**
207
235
  * Dispose of the client and clean up all resources.
208
236
  *
@@ -210,7 +238,20 @@ export declare class TelnyxVoipClient {
210
238
  * This is particularly important for background clients that should be
211
239
  * disposed after handling push notifications.
212
240
  */
213
- dispose(): void;
241
+ dispose(): Promise<void>;
242
+ /**
243
+ * Forward answers captured during cold start as soon as SessionManager has
244
+ * created the TelnyxRTC instance. SessionManager invokes its ready callback
245
+ * before connect(), so the UUID-keyed action is present when the INVITE
246
+ * arrives.
247
+ */
248
+ private _flushPendingCallKitAnswers;
249
+ /**
250
+ * Prefer an explicitly supplied token, otherwise hydrate it from PushKit's
251
+ * native storage. PushKit registration starts in AppDelegate before React
252
+ * mounts, so this removes the race between the JS token event and login.
253
+ */
254
+ private _withNativeVoipPushToken;
214
255
  /**
215
256
  * Store credential configuration for automatic reconnection
216
257
  */
@@ -238,7 +279,7 @@ export declare function createTelnyxVoipClient(options?: TelnyxVoipClientOptions
238
279
  * Disposes the current singleton so that a subsequent call to
239
280
  * `createTelnyxVoipClient()` will create a fresh instance.
240
281
  */
241
- export declare function destroyTelnyxVoipClient(): void;
282
+ export declare function destroyTelnyxVoipClient(): Promise<void>;
242
283
  /**
243
284
  * Create a new TelnyxVoipClient instance for background push notification handling.
244
285
  *