@telnyx/react-voice-commons-sdk 0.1.7 → 0.1.8-beta.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # CHANGELOG.md
2
2
 
3
+ ## [0.1.8-beta.1](https://github.com/team-telnyx/react-native-voice-commons/releases/tag/0.1.8-beta.1) (2026-03-04)
4
+
5
+ ### Bug Fixing
6
+
7
+ • Fixed duplicate CXProvider overwrite causing `CXEndCallAction` error 4 (unknownCallUUID) — guard `setupCallKit()` to prevent async `setupAutomatically()` from overwriting the provider created by `setupSynchronously()` during VoIP push handling
8
+ • Fixed intermittent audio loss on push notification calls — defer `CXAnswerCallAction.fulfill()` until `reportCallConnected()` when the WebRTC peer connection is ready
9
+ • Aligned iOS audio session handling with native Telnyx iOS SDK pattern (`RTCAudioSessionConfiguration.webRTC()` with `lockForConfiguration`/`unlockForConfiguration`)
10
+ • Fixed endCall not dismissing CallKit UI — check `endCall()` return value and fallback to `reportCallEnded()` when CXEndCallAction fails
11
+ • Fixed push notification answer crash when voipClient is not yet initialized — defer to `checkForInitialPushNotification()` instead of failing the call
12
+ • Added `voipClient.queueAnswerFromCallKit()` call when handling push notification answer for auto-answer on INVITE arrival
13
+
3
14
  ## [0.1.7](https://github.com/team-telnyx/react-native-voice-commons/releases/tag/0.1.7) (2026-02-20)
4
15
 
5
16
  ### Enhancement
package/README.md CHANGED
@@ -32,15 +32,30 @@ The `@telnyx/react-voice-commons-sdk` library provides:
32
32
  Integrate the library using the `TelnyxVoiceApp` component for automatic lifecycle management:
33
33
 
34
34
  ```tsx
35
- import { TelnyxVoiceApp, createTelnyxVoipClient } from '@telnyx/react-voice-commons-sdk';
35
+ import {
36
+ TelnyxVoiceApp,
37
+ TelnyxVoipClient,
38
+ createTelnyxVoipClient,
39
+ } from '@telnyx/react-voice-commons-sdk';
36
40
 
37
- // Create the VoIP client instance
41
+ // Create the VoIP client instance (singleton — safe to call inside a component body)
38
42
  const voipClient = createTelnyxVoipClient({
39
43
  enableAppStateManagement: true, // Optional: Enable automatic app state management (default: true)
40
44
  debug: true, // Optional: Enable debug logging
41
45
  });
42
46
 
43
47
  export default function App() {
48
+ // Skip auto-login if the app was launched from a push notification —
49
+ // the SDK handles login internally via the push notification flow.
50
+ React.useEffect(() => {
51
+ TelnyxVoipClient.isLaunchedFromPushNotification().then((isFromPush) => {
52
+ if (!isFromPush) {
53
+ // Safe to auto-login
54
+ voipClient.loginFromStoredConfig();
55
+ }
56
+ });
57
+ }, []);
58
+
44
59
  return (
45
60
  <TelnyxVoiceApp voipClient={voipClient} enableAutoReconnect={false} debug={true}>
46
61
  <YourAppContent />
@@ -54,10 +69,16 @@ export default function App() {
54
69
  ### 1. VoIP Client Configuration
55
70
 
56
71
  ```tsx
72
+ // createTelnyxVoipClient is a singleton — repeated calls return the same instance.
73
+ // This makes it safe to call inside a React component body without re-creating on every render.
57
74
  const voipClient = createTelnyxVoipClient({
58
75
  enableAppStateManagement: true, // Optional: Enable automatic app state management (default: true)
59
76
  debug: true, // Optional: Enable debug logging
60
77
  });
78
+
79
+ // If you need to tear down and recreate the client (e.g., on logout):
80
+ import { destroyTelnyxVoipClient } from '@telnyx/react-voice-commons-sdk';
81
+ destroyTelnyxVoipClient(); // Disposes the singleton; next createTelnyxVoipClient() call creates a fresh instance
61
82
  ```
62
83
 
63
84
  **Configuration Options Explained:**
@@ -73,6 +94,7 @@ The `TelnyxVoiceApp` component handles:
73
94
  - Push notification processing from terminated state
74
95
  - Login state management with automatic reconnection
75
96
  - Background client management for push notifications
97
+ - **Automatic CallKit coordinator wiring** — the `voipClient` is set on the CallKit coordinator on mount, so you don't need to call `setVoipClient()` manually
76
98
 
77
99
  ### 3. Reactive State Management
78
100
 
@@ -416,9 +438,18 @@ npx expo run:ios
416
438
 
417
439
  ### Common Integration Issues
418
440
 
419
- ### Double Login
441
+ ### Double Login on Cold-Start
442
+
443
+ When the app is launched from a push notification, the SDK handles login internally. If your app also auto-logs in on mount, both will race and the push flow breaks. Use `isLaunchedFromPushNotification()` to guard your auto-login:
444
+
445
+ ```tsx
446
+ const isFromPush = await TelnyxVoipClient.isLaunchedFromPushNotification();
447
+ if (!isFromPush) {
448
+ voipClient.loginFromStoredConfig();
449
+ }
450
+ ```
420
451
 
421
- Ensure you're not calling login methods manually when using `TelnyxVoiceApp` with auto-reconnection enabled.
452
+ Also ensure you're not calling login methods manually when using `TelnyxVoiceApp` with auto-reconnection enabled.
422
453
 
423
454
  ### Background Disconnection
424
455
 
@@ -457,7 +488,6 @@ useEffect(() => {
457
488
  const subscription = voipClient.connectionState$.subscribe(handleStateChange);
458
489
  return () => subscription.unsubscribe();
459
490
  }, []);
460
- }, []);
461
491
  ```
462
492
 
463
493
  ## Documentation
@@ -205,6 +205,33 @@ import React
205
205
  }
206
206
 
207
207
  provider.reportOutgoingCall(with: uuid, connectedAt: Date())
208
+
209
+ // Fulfill deferred CXAnswerCallAction now that peer connection is ready
210
+ if let pendingAction = manager.pendingAnswerAction {
211
+ NSLog("TelnyxVoice: reportCallConnected - fulfilling deferred CXAnswerCallAction")
212
+ pendingAction.fulfill()
213
+ manager.pendingAnswerAction = nil
214
+ } else {
215
+ // Fallback: ensure audio is enabled for non-push or already-fulfilled cases
216
+ let rtcAudioSession = RTCAudioSession.sharedInstance()
217
+ rtcAudioSession.lockForConfiguration()
218
+ let webRTCConfig = RTCAudioSessionConfiguration.webRTC()
219
+ webRTCConfig.categoryOptions = [.duckOthers, .allowBluetooth]
220
+ do {
221
+ try rtcAudioSession.setConfiguration(webRTCConfig)
222
+ } catch {
223
+ NSLog("TelnyxVoice: reportCallConnected - setConfiguration error: \(error)")
224
+ }
225
+ do {
226
+ try rtcAudioSession.setActive(true)
227
+ } catch {
228
+ NSLog("TelnyxVoice: reportCallConnected - setActive error: \(error)")
229
+ }
230
+ rtcAudioSession.isAudioEnabled = true
231
+ rtcAudioSession.unlockForConfiguration()
232
+ rtcAudioSession.audioSessionDidActivate(AVAudioSession.sharedInstance())
233
+ }
234
+
208
235
  resolve(["success": true])
209
236
  }
210
237
 
@@ -276,6 +303,7 @@ import React
276
303
  public var callKitProvider: CXProvider?
277
304
  public var callKitController: CXCallController?
278
305
  public var activeCalls: [UUID: [String: Any]] = [:]
306
+ public var pendingAnswerAction: CXAnswerCallAction?
279
307
 
280
308
  private override init() {
281
309
  super.init()
@@ -327,12 +355,16 @@ import React
327
355
  }
328
356
 
329
357
  private func setupCallKit() {
330
- // CRITICAL: Configure WebRTC for manual audio control BEFORE CallKit setup
358
+ // ALWAYS configure WebRTC audio, even if provider already exists
331
359
  RTCAudioSession.sharedInstance().useManualAudio = true
332
- RTCAudioSession.sharedInstance().isAudioEnabled = false // MUST be false initially!
333
- NSLog(
334
- "🎧 TelnyxVoice: WebRTC configured for manual audio control (audio DISABLED until CallKit activates)"
335
- )
360
+ RTCAudioSession.sharedInstance().isAudioEnabled = false
361
+
362
+ // Guard against duplicate provider creation (async setupAutomatically can overwrite
363
+ // the provider created by setupSynchronously for VoIP push)
364
+ guard callKitProvider == nil else {
365
+ NSLog("TelnyxVoice: setupCallKit() - provider already exists, skipping to prevent overwrite")
366
+ return
367
+ }
336
368
 
337
369
  // Use the localizedName from the app's bundle display name or fallback
338
370
  let appName =
@@ -351,11 +383,7 @@ import React
351
383
  callKitProvider?.setDelegate(self, queue: nil)
352
384
  callKitController = CXCallController()
353
385
 
354
- NSLog(
355
- "📞 TelnyxVoice: CallKit provider instance: \(String(describing: callKitProvider))")
356
- NSLog(
357
- "📞 TelnyxVoice: CallKit controller instance: \(String(describing: callKitController))"
358
- )
386
+ NSLog("TelnyxVoice: CallKit provider and controller created")
359
387
  }
360
388
 
361
389
 
@@ -519,9 +547,9 @@ import React
519
547
  callData: activeCalls[action.callUUID])
520
548
  }
521
549
 
522
- NSLog("📞 TelnyxVoice: Fulfilling CXAnswerCallAction for call UUID: \(action.callUUID)")
523
- action.fulfill()
524
- NSLog("📞 TelnyxVoice: ✅ CXAnswerCallAction fulfilled successfully")
550
+ // Defer action.fulfill() until reportCallConnected when peer connection is ready
551
+ NSLog("TelnyxVoice: Deferring CXAnswerCallAction.fulfill() until peer connection is ready")
552
+ self.pendingAnswerAction = action
525
553
  }
526
554
 
527
555
  public func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
@@ -552,58 +580,67 @@ import React
552
580
  }
553
581
 
554
582
  public func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
555
- NSLog(
556
- "🎧🎧🎧 TelnyxVoice: AUDIO SESSION ACTIVATED BY CALLKIT - USER ANSWERED THE CALL! 🎧🎧🎧")
557
- NSLog("🎧 Provider: \(provider)")
583
+ NSLog("TelnyxVoice: Audio session activated by CallKit")
558
584
 
585
+ let rtcAudioSession = RTCAudioSession.sharedInstance()
559
586
 
560
- // CRITICAL: Activate WebRTC audio session
561
- RTCAudioSession.sharedInstance().audioSessionDidActivate(audioSession)
562
- RTCAudioSession.sharedInstance().isAudioEnabled = true
563
- NSLog("🎧 TelnyxVoice: WebRTC RTCAudioSession activated and audio enabled")
564
-
587
+ // Step 1: Configure (matches native iOS SDK setupCorrectAudioConfiguration)
588
+ rtcAudioSession.lockForConfiguration()
589
+ let webRTCConfig = RTCAudioSessionConfiguration.webRTC()
590
+ webRTCConfig.categoryOptions = [.duckOthers, .allowBluetooth]
565
591
  do {
566
- // Configure audio session for VoIP with proper routing
567
- try audioSession.setCategory(
568
- .playAndRecord, mode: .voiceChat,
569
- options: [.allowBluetooth, .allowBluetoothA2DP])
570
- try audioSession.setActive(true)
571
-
572
- // Emit audio session activated event to React Native
573
- CallKitBridge.shared?.emitAudioSessionEvent(
574
- "AudioSessionActivated",
575
- data: [
576
- "category": audioSession.category.rawValue,
577
- "mode": audioSession.mode.rawValue,
578
- "isActive": true,
579
- ])
580
-
581
- NSLog(
582
- "🎧 SUCCESS: Audio session ACTIVE for VoIP - Category: \(audioSession.category.rawValue), Mode: \(audioSession.mode.rawValue)"
583
- )
592
+ try rtcAudioSession.setConfiguration(webRTCConfig)
584
593
  } catch {
585
- NSLog("❌ FAILED: Audio session configuration error: \(error)")
594
+ NSLog("TelnyxVoice: didActivateAudioSession - setConfiguration error: \(error)")
595
+ }
596
+ rtcAudioSession.unlockForConfiguration()
586
597
 
587
- // Emit audio session failed event to React Native
588
- CallKitBridge.shared?.emitAudioSessionEvent(
589
- "AudioSessionFailed",
590
- data: [
591
- "error": error.localizedDescription
592
- ])
598
+ // Step 2: Activate (matches native iOS SDK setAudioSessionActive)
599
+ rtcAudioSession.lockForConfiguration()
600
+ do {
601
+ try rtcAudioSession.setActive(true)
602
+ } catch {
603
+ NSLog("TelnyxVoice: didActivateAudioSession - setActive error: \(error)")
593
604
  }
605
+ rtcAudioSession.isAudioEnabled = true
606
+ rtcAudioSession.unlockForConfiguration()
607
+
608
+ rtcAudioSession.audioSessionDidActivate(audioSession)
609
+
610
+ // Emit audio session activated event to React Native
611
+ CallKitBridge.shared?.emitAudioSessionEvent(
612
+ "AudioSessionActivated",
613
+ data: [
614
+ "category": audioSession.category.rawValue,
615
+ "mode": audioSession.mode.rawValue,
616
+ "isActive": true,
617
+ ])
594
618
  }
595
619
 
596
620
  public func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
597
- NSLog("🔇🔇🔇 TelnyxVoice: AUDIO SESSION DEACTIVATED BY CALLKIT 🔇🔇🔇")
598
- NSLog("🔇 Provider: \(provider)")
599
- NSLog(
600
- "🔇 Audio session details: active=\(audioSession.isOtherAudioPlaying), category=\(audioSession.category.rawValue), mode=\(audioSession.mode.rawValue)"
601
- )
621
+ NSLog("TelnyxVoice: Audio session deactivated by CallKit")
602
622
 
603
- // CRITICAL: Deactivate WebRTC audio session (matches Flutter implementation)
604
- RTCAudioSession.sharedInstance().audioSessionDidDeactivate(audioSession)
605
- RTCAudioSession.sharedInstance().isAudioEnabled = false
606
- NSLog("🔇 TelnyxVoice: WebRTC RTCAudioSession deactivated and audio disabled")
623
+ let rtcAudioSession = RTCAudioSession.sharedInstance()
624
+
625
+ // Reset audio config (matches native iOS SDK resetAudioConfiguration)
626
+ let avAudioSession = AVAudioSession.sharedInstance()
627
+ do {
628
+ try avAudioSession.setCategory(.playback, mode: .default, options: [.mixWithOthers])
629
+ } catch {
630
+ NSLog("TelnyxVoice: didDeactivateAudioSession - setCategory error: \(error)")
631
+ }
632
+
633
+ // Deactivate (matches native iOS SDK setAudioSessionActive(false))
634
+ rtcAudioSession.lockForConfiguration()
635
+ do {
636
+ try rtcAudioSession.setActive(false)
637
+ } catch {
638
+ NSLog("TelnyxVoice: didDeactivateAudioSession - setActive(false) error: \(error)")
639
+ }
640
+ rtcAudioSession.isAudioEnabled = false
641
+ rtcAudioSession.unlockForConfiguration()
642
+
643
+ rtcAudioSession.audioSessionDidDeactivate(audioSession)
607
644
 
608
645
  // Emit audio session deactivated event to React Native
609
646
  CallKitBridge.shared?.emitAudioSessionEvent(
@@ -228,21 +228,26 @@ class CallKitCoordinator {
228
228
  'CallKitCoordinator: Ending call from UI - dismissing CallKit and hanging up WebRTC call',
229
229
  callKitUUID
230
230
  );
231
- // Mark as processing to prevent duplicate actions
232
- this.processingCalls.add(callKitUUID);
231
+ // Track this call as ended to prevent duplicate end actions
232
+ this.endedCalls.add(callKitUUID);
233
233
  try {
234
- // End the call in CallKit and hang up the WebRTC call
235
- await callkit_1.default.endCall(callKitUUID);
234
+ // End the call in CallKit - endCall returns false on failure (doesn't throw)
235
+ const endCallSuccess = await callkit_1.default.endCall(callKitUUID);
236
+ if (!endCallSuccess) {
237
+ // Fallback: use reportCallEnded to dismiss CallKit UI when endCall fails
238
+ // (e.g., unknownCallUUID error from duplicate CXProvider)
239
+ await callkit_1.default.reportCallEnded(callKitUUID, callkit_1.CallEndReason.RemoteEnded);
240
+ }
241
+ this.isCallFromPush = false;
236
242
  call.hangup();
237
- // Clean up the mappings
238
243
  this.cleanupCall(callKitUUID);
239
244
  return true;
240
245
  } catch (error) {
241
246
  console.error('CallKitCoordinator: Error ending call from UI', error);
242
- call.hangup(); // Ensure WebRTC call is ended
247
+ this.isCallFromPush = false;
248
+ call.hangup();
249
+ this.cleanupCall(callKitUUID);
243
250
  return false;
244
- } finally {
245
- this.processingCalls.delete(callKitUUID);
246
251
  }
247
252
  }
248
253
  /**
@@ -303,10 +308,21 @@ class CallKitCoordinator {
303
308
  } else {
304
309
  console.log('CallKitCoordinator: Outgoing call, skipping answer and CONNECTING state');
305
310
  }
311
+ // Clear push data now that answer action is fulfilled
312
+ try {
313
+ await voice_pn_bridge_1.VoicePnBridge.clearPendingVoipPush();
314
+ console.log('CallKitCoordinator: Cleared pending VoIP push after answer fulfilled');
315
+ } catch (clearErr) {
316
+ console.error('CallKitCoordinator: Error clearing push data after answer:', clearErr);
317
+ }
306
318
  } catch (error) {
307
319
  console.error('CallKitCoordinator: Error processing CallKit answer', error);
308
320
  await callkit_1.default.reportCallEnded(callKitUUID, callkit_1.CallEndReason.Failed);
309
321
  this.cleanupCall(callKitUUID);
322
+ // Clear push data even on error to prevent stale state
323
+ try {
324
+ await voice_pn_bridge_1.VoicePnBridge.clearPendingVoipPush();
325
+ } catch (_) {}
310
326
  } finally {
311
327
  this.processingCalls.delete(callKitUUID);
312
328
  }
@@ -352,6 +368,11 @@ class CallKitCoordinator {
352
368
  } finally {
353
369
  this.processingCalls.delete(callKitUUID);
354
370
  this.cleanupCall(callKitUUID);
371
+ // Clear push data now that end action is fulfilled
372
+ try {
373
+ await voice_pn_bridge_1.VoicePnBridge.clearPendingVoipPush();
374
+ console.log('CallKitCoordinator: Cleared pending VoIP push after end fulfilled');
375
+ } catch (_) {}
355
376
  // Check if app is in background and no more calls - disconnect client
356
377
  await this.checkBackgroundDisconnection();
357
378
  }
@@ -456,18 +477,22 @@ class CallKitCoordinator {
456
477
  // Set auto-answer flag so when the WebRTC call comes in, it will be answered automatically
457
478
  this.shouldAutoAnswerNextCall = true;
458
479
  console.log('CallKitCoordinator: ✅ Set auto-answer flag for next incoming call');
459
- // Get VoIP client and trigger reconnection
480
+ // Try to get VoIP client - it may not be wired yet if user answered
481
+ // from CallKit before React Native finished initializing
460
482
  const voipClient = this.getSDKClient();
461
483
  if (!voipClient) {
462
- console.error(
463
- 'CallKitCoordinator: No VoIP client available - cannot reconnect for push notification'
464
- );
465
- await callkit_1.default.reportCallEnded(callKitUUID, callkit_1.CallEndReason.Failed);
466
- this.cleanupCall(callKitUUID);
484
+ // voipClient not ready yet - DON'T fail the call.
485
+ // shouldAutoAnswerNextCall is already set to true above.
486
+ // checkForInitialPushNotification() will run after setVoipClient()
487
+ // and will find the push data still intact, call handleCallKitPushReceived()
488
+ // which checks shouldAutoAnswerNextCall and queues the auto-answer.
467
489
  return;
468
490
  }
491
+ // voipClient is available - queue the answer action on the TelnyxRTC client
492
+ // so when the INVITE arrives after WebSocket login, processInvite() sees
493
+ // pendingAnswerAction=true and auto-answers the call.
494
+ voipClient.queueAnswerFromCallKit();
469
495
  // Get the real push data that was stored by the VoIP push handler
470
- console.log('CallKitCoordinator: 🔍 Getting real push data from VoicePnBridge...');
471
496
  let realPushData = null;
472
497
  try {
473
498
  const pendingPushJson = await voice_pn_bridge_1.VoicePnBridge.getPendingVoipPush();
@@ -506,6 +531,11 @@ class CallKitCoordinator {
506
531
  // Set the pending push action to be handled when app comes to foreground
507
532
  await voice_pn_bridge_1.VoicePnBridge.setPendingPushAction(pushAction, pushMetadata);
508
533
  console.log('CallKitCoordinator: ✅ Set pending push action');
534
+ // Clear push data now that push notification answer is handled
535
+ try {
536
+ await voice_pn_bridge_1.VoicePnBridge.clearPendingVoipPush();
537
+ console.log('CallKitCoordinator: Cleared pending VoIP push after push answer handled');
538
+ } catch (_) {}
509
539
  return;
510
540
  }
511
541
  // For other platforms (shouldn't happen on iOS)
@@ -533,6 +563,11 @@ class CallKitCoordinator {
533
563
  this.voipClient.queueEndFromCallKit();
534
564
  // Clean up push notification state
535
565
  await this.cleanupPushNotificationState();
566
+ // Clear push data now that rejection is handled
567
+ try {
568
+ await voice_pn_bridge_1.VoicePnBridge.clearPendingVoipPush();
569
+ console.log('CallKitCoordinator: Cleared pending VoIP push after rejection handled');
570
+ } catch (_) {}
536
571
  console.log('CallKitCoordinator: 🎯 Push notification rejection handling complete');
537
572
  return;
538
573
  }
@@ -561,16 +596,11 @@ class CallKitCoordinator {
561
596
  if (!this.connectedCalls.has(callKitUUID)) {
562
597
  console.log('CallKitCoordinator: WebRTC call active - reporting connected to CallKit');
563
598
  try {
564
- // Report as connected (CallKit call already answered in UI flow)
565
599
  await callkit_1.default.reportCallConnected(callKitUUID);
566
- console.log(
567
- 'CallKitCoordinator: Call reported as connected to CallKit ',
568
- callKitUUID
569
- );
570
- this.connectedCalls.add(callKitUUID);
571
600
  } catch (error) {
572
601
  console.error('CallKitCoordinator: Error reporting call connected:', error);
573
602
  }
603
+ this.connectedCalls.add(callKitUUID);
574
604
  }
575
605
  break;
576
606
  case 'ended':
@@ -738,7 +768,11 @@ class CallKitCoordinator {
738
768
  * This helps prevent premature flag resets during CallKit operations
739
769
  */
740
770
  hasProcessingCalls() {
741
- return this.processingCalls.size > 0;
771
+ // Also return true when isCallFromPush is set — this prevents the
772
+ // calls$ subscription in TelnyxVoiceApp from resetting protection flags
773
+ // (isHandlingForegroundCall, backgroundDetectorIgnore) before the WebRTC
774
+ // call arrives during push notification handling.
775
+ return this.processingCalls.size > 0 || this.isCallFromPush;
742
776
  }
743
777
  /**
744
778
  * Check if there's currently a call from push notification being processed
@@ -274,8 +274,14 @@ const TelnyxVoiceAppComponent = ({
274
274
  return null;
275
275
  }
276
276
  log('Found pending VoIP push data:', voipPayload);
277
- await VoicePnBridge.clearPendingVoipPush();
278
- log('Cleared pending VoIP push data after retrieval');
277
+ // Do NOT clear push data here. Let it persist until the answer/end action
278
+ // is fulfilled in the CallKit coordinator. This prevents a race condition in
279
+ // Expo apps where the RN bridge mounts immediately on push notification —
280
+ // the push data would be consumed and cleared before the user answers,
281
+ // leaving the coordinator with nothing to work with.
282
+ // For non-Expo apps (RN mounts after answer), the coordinator's
283
+ // handlePushNotificationAnswer/Reject clears the data before
284
+ // checkForInitialPushNotification ever runs, so no loop occurs.
279
285
  return { action: 'incoming_call', metadata: voipPayload.metadata, from_notification: true };
280
286
  } catch (parseError) {
281
287
  log('Error parsing VoIP push JSON:', parseError);
@@ -315,11 +321,17 @@ const TelnyxVoiceAppComponent = ({
315
321
  return;
316
322
  }
317
323
  log('Processing initial push notification...');
318
- // Prevent duplicate processing if already connected
324
+ // Prevent duplicate processing if already connected or connecting.
325
+ // Since push data is no longer cleared on read, this guard prevents
326
+ // re-processing when checkForInitialPushNotification fires again on app resume.
319
327
  if (
320
- voipClient.currentConnectionState === connection_state_1.TelnyxConnectionState.CONNECTED
328
+ voipClient.currentConnectionState ===
329
+ connection_state_1.TelnyxConnectionState.CONNECTED ||
330
+ voipClient.currentConnectionState === connection_state_1.TelnyxConnectionState.CONNECTING
321
331
  ) {
322
- log('SKIPPING - Already connected, preventing duplicate processing');
332
+ log(
333
+ `SKIPPING - Already ${voipClient.currentConnectionState}, preventing duplicate processing`
334
+ );
323
335
  return;
324
336
  }
325
337
  // Set flags to prevent auto-reconnection during push call
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telnyx/react-voice-commons-sdk",
3
- "version": "0.1.7",
3
+ "version": "0.1.8-beta.1",
4
4
  "description": "A high-level, state-agnostic, drop-in module for the Telnyx React Native SDK that simplifies WebRTC voice calling integration",
5
5
  "main": "lib/index.js",
6
6
  "module": "lib/index.js",
@@ -218,24 +218,30 @@ class CallKitCoordinator {
218
218
  callKitUUID
219
219
  );
220
220
 
221
- // Mark as processing to prevent duplicate actions
222
- this.processingCalls.add(callKitUUID);
221
+ // Track this call as ended to prevent duplicate end actions
222
+ this.endedCalls.add(callKitUUID);
223
223
 
224
224
  try {
225
- // End the call in CallKit and hang up the WebRTC call
226
- await CallKit.endCall(callKitUUID);
227
- call.hangup();
225
+ // End the call in CallKit - endCall returns false on failure (doesn't throw)
226
+ const endCallSuccess = await CallKit.endCall(callKitUUID);
228
227
 
229
- // Clean up the mappings
228
+ if (!endCallSuccess) {
229
+ // Fallback: use reportCallEnded to dismiss CallKit UI when endCall fails
230
+ // (e.g., unknownCallUUID error from duplicate CXProvider)
231
+ await CallKit.reportCallEnded(callKitUUID, CallEndReason.RemoteEnded);
232
+ }
233
+
234
+ this.isCallFromPush = false;
235
+ call.hangup();
230
236
  this.cleanupCall(callKitUUID);
231
237
 
232
238
  return true;
233
239
  } catch (error) {
234
240
  console.error('CallKitCoordinator: Error ending call from UI', error);
235
- call.hangup(); // Ensure WebRTC call is ended
241
+ this.isCallFromPush = false;
242
+ call.hangup();
243
+ this.cleanupCall(callKitUUID);
236
244
  return false;
237
- } finally {
238
- this.processingCalls.delete(callKitUUID);
239
245
  }
240
246
  }
241
247
 
@@ -306,10 +312,21 @@ class CallKitCoordinator {
306
312
  } else {
307
313
  console.log('CallKitCoordinator: Outgoing call, skipping answer and CONNECTING state');
308
314
  }
315
+ // Clear push data now that answer action is fulfilled
316
+ try {
317
+ await VoicePnBridge.clearPendingVoipPush();
318
+ console.log('CallKitCoordinator: Cleared pending VoIP push after answer fulfilled');
319
+ } catch (clearErr) {
320
+ console.error('CallKitCoordinator: Error clearing push data after answer:', clearErr);
321
+ }
309
322
  } catch (error) {
310
323
  console.error('CallKitCoordinator: Error processing CallKit answer', error);
311
324
  await CallKit.reportCallEnded(callKitUUID, CallEndReason.Failed);
312
325
  this.cleanupCall(callKitUUID);
326
+ // Clear push data even on error to prevent stale state
327
+ try {
328
+ await VoicePnBridge.clearPendingVoipPush();
329
+ } catch (_) {}
313
330
  } finally {
314
331
  this.processingCalls.delete(callKitUUID);
315
332
  }
@@ -366,6 +383,12 @@ class CallKitCoordinator {
366
383
  this.processingCalls.delete(callKitUUID);
367
384
  this.cleanupCall(callKitUUID);
368
385
 
386
+ // Clear push data now that end action is fulfilled
387
+ try {
388
+ await VoicePnBridge.clearPendingVoipPush();
389
+ console.log('CallKitCoordinator: Cleared pending VoIP push after end fulfilled');
390
+ } catch (_) {}
391
+
369
392
  // Check if app is in background and no more calls - disconnect client
370
393
  await this.checkBackgroundDisconnection();
371
394
  }
@@ -489,19 +512,24 @@ class CallKitCoordinator {
489
512
  this.shouldAutoAnswerNextCall = true;
490
513
  console.log('CallKitCoordinator: ✅ Set auto-answer flag for next incoming call');
491
514
 
492
- // Get VoIP client and trigger reconnection
515
+ // Try to get VoIP client - it may not be wired yet if user answered
516
+ // from CallKit before React Native finished initializing
493
517
  const voipClient = this.getSDKClient();
494
518
  if (!voipClient) {
495
- console.error(
496
- 'CallKitCoordinator: No VoIP client available - cannot reconnect for push notification'
497
- );
498
- await CallKit.reportCallEnded(callKitUUID, CallEndReason.Failed);
499
- this.cleanupCall(callKitUUID);
519
+ // voipClient not ready yet - DON'T fail the call.
520
+ // shouldAutoAnswerNextCall is already set to true above.
521
+ // checkForInitialPushNotification() will run after setVoipClient()
522
+ // and will find the push data still intact, call handleCallKitPushReceived()
523
+ // which checks shouldAutoAnswerNextCall and queues the auto-answer.
500
524
  return;
501
525
  }
502
526
 
527
+ // voipClient is available - queue the answer action on the TelnyxRTC client
528
+ // so when the INVITE arrives after WebSocket login, processInvite() sees
529
+ // pendingAnswerAction=true and auto-answers the call.
530
+ voipClient.queueAnswerFromCallKit();
531
+
503
532
  // Get the real push data that was stored by the VoIP push handler
504
- console.log('CallKitCoordinator: 🔍 Getting real push data from VoicePnBridge...');
505
533
  let realPushData = null;
506
534
  try {
507
535
  const pendingPushJson = await VoicePnBridge.getPendingVoipPush();
@@ -544,6 +572,12 @@ class CallKitCoordinator {
544
572
  await VoicePnBridge.setPendingPushAction(pushAction, pushMetadata);
545
573
  console.log('CallKitCoordinator: ✅ Set pending push action');
546
574
 
575
+ // Clear push data now that push notification answer is handled
576
+ try {
577
+ await VoicePnBridge.clearPendingVoipPush();
578
+ console.log('CallKitCoordinator: Cleared pending VoIP push after push answer handled');
579
+ } catch (_) {}
580
+
547
581
  return;
548
582
  }
549
583
 
@@ -577,6 +611,12 @@ class CallKitCoordinator {
577
611
  // Clean up push notification state
578
612
  await this.cleanupPushNotificationState();
579
613
 
614
+ // Clear push data now that rejection is handled
615
+ try {
616
+ await VoicePnBridge.clearPendingVoipPush();
617
+ console.log('CallKitCoordinator: Cleared pending VoIP push after rejection handled');
618
+ } catch (_) {}
619
+
580
620
  console.log('CallKitCoordinator: 🎯 Push notification rejection handling complete');
581
621
  return;
582
622
  }
@@ -609,17 +649,11 @@ class CallKitCoordinator {
609
649
  console.log('CallKitCoordinator: WebRTC call active - reporting connected to CallKit');
610
650
 
611
651
  try {
612
- // Report as connected (CallKit call already answered in UI flow)
613
652
  await CallKit.reportCallConnected(callKitUUID);
614
- console.log(
615
- 'CallKitCoordinator: Call reported as connected to CallKit ',
616
- callKitUUID
617
- );
618
-
619
- this.connectedCalls.add(callKitUUID);
620
653
  } catch (error) {
621
654
  console.error('CallKitCoordinator: Error reporting call connected:', error);
622
655
  }
656
+ this.connectedCalls.add(callKitUUID);
623
657
  }
624
658
  break;
625
659
 
@@ -815,7 +849,11 @@ class CallKitCoordinator {
815
849
  * This helps prevent premature flag resets during CallKit operations
816
850
  */
817
851
  hasProcessingCalls(): boolean {
818
- return this.processingCalls.size > 0;
852
+ // Also return true when isCallFromPush is set — this prevents the
853
+ // calls$ subscription in TelnyxVoiceApp from resetting protection flags
854
+ // (isHandlingForegroundCall, backgroundDetectorIgnore) before the WebRTC
855
+ // call arrives during push notification handling.
856
+ return this.processingCalls.size > 0 || this.isCallFromPush;
819
857
  }
820
858
 
821
859
  /**
@@ -374,8 +374,14 @@ const TelnyxVoiceAppComponent: React.FC<TelnyxVoiceAppProps> = ({
374
374
 
375
375
  log('Found pending VoIP push data:', voipPayload);
376
376
 
377
- await VoicePnBridge.clearPendingVoipPush();
378
- log('Cleared pending VoIP push data after retrieval');
377
+ // Do NOT clear push data here. Let it persist until the answer/end action
378
+ // is fulfilled in the CallKit coordinator. This prevents a race condition in
379
+ // Expo apps where the RN bridge mounts immediately on push notification —
380
+ // the push data would be consumed and cleared before the user answers,
381
+ // leaving the coordinator with nothing to work with.
382
+ // For non-Expo apps (RN mounts after answer), the coordinator's
383
+ // handlePushNotificationAnswer/Reject clears the data before
384
+ // checkForInitialPushNotification ever runs, so no loop occurs.
379
385
 
380
386
  return { action: 'incoming_call', metadata: voipPayload.metadata, from_notification: true };
381
387
  } catch (parseError) {
@@ -424,9 +430,14 @@ const TelnyxVoiceAppComponent: React.FC<TelnyxVoiceAppProps> = ({
424
430
 
425
431
  log('Processing initial push notification...');
426
432
 
427
- // Prevent duplicate processing if already connected
428
- if (voipClient.currentConnectionState === TelnyxConnectionState.CONNECTED) {
429
- log('SKIPPING - Already connected, preventing duplicate processing');
433
+ // Prevent duplicate processing if already connected or connecting.
434
+ // Since push data is no longer cleared on read, this guard prevents
435
+ // re-processing when checkForInitialPushNotification fires again on app resume.
436
+ if (
437
+ voipClient.currentConnectionState === TelnyxConnectionState.CONNECTED ||
438
+ voipClient.currentConnectionState === TelnyxConnectionState.CONNECTING
439
+ ) {
440
+ log(`SKIPPING - Already ${voipClient.currentConnectionState}, preventing duplicate processing`);
430
441
  return;
431
442
  }
432
443