@telnyx/react-voice-commons-sdk 1.1.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.
@@ -60,4 +60,11 @@ RCT_EXTERN_METHOD(updateCall:(NSString *)callUUID
60
60
  RCT_EXTERN_METHOD(getActiveCalls:(RCTPromiseResolveBlock)resolve
61
61
  rejecter:(RCTPromiseRejectBlock)reject)
62
62
 
63
+ #if DEBUG
64
+ RCT_EXTERN_METHOD(simulateAudioSetupRace:(NSString *)callUUID
65
+ delayMilliseconds:(nonnull NSNumber *)delayMilliseconds
66
+ resolver:(RCTPromiseResolveBlock)resolve
67
+ rejecter:(RCTPromiseRejectBlock)reject)
68
+ #endif
69
+
63
70
  @end
@@ -440,29 +440,31 @@ import React
440
440
  NSLog("TelnyxVoice: reportCallConnected - fulfilling deferred CXAnswerCallAction")
441
441
  pendingAction.fulfill()
442
442
  } else {
443
- // Fallback: ensure audio is enabled for non-push or already-fulfilled cases
444
- let rtcAudioSession = RTCAudioSession.sharedInstance()
445
- rtcAudioSession.lockForConfiguration()
446
- let webRTCConfig = RTCAudioSessionConfiguration.webRTC()
447
- webRTCConfig.categoryOptions = [.duckOthers, .allowBluetooth]
448
- do {
449
- try rtcAudioSession.setConfiguration(webRTCConfig)
450
- } catch {
451
- NSLog("TelnyxVoice: reportCallConnected - setConfiguration error: \(error)")
452
- }
453
- do {
454
- try rtcAudioSession.setActive(true)
455
- } catch {
456
- NSLog("TelnyxVoice: reportCallConnected - setActive error: \(error)")
457
- }
458
- rtcAudioSession.isAudioEnabled = true
459
- rtcAudioSession.unlockForConfiguration()
460
- rtcAudioSession.audioSessionDidActivate(AVAudioSession.sharedInstance())
443
+ manager.verifyAudioAfterConnection(for: uuid)
461
444
  }
462
445
 
463
446
  resolve(["success": true])
464
447
  }
465
448
 
449
+ #if DEBUG
450
+ @objc func simulateAudioSetupRace(
451
+ _ callUUID: String,
452
+ delayMilliseconds: NSNumber,
453
+ resolver resolve: @escaping RCTPromiseResolveBlock,
454
+ rejecter reject: @escaping RCTPromiseRejectBlock
455
+ ) {
456
+ guard let uuid = UUID(uuidString: callUUID) else {
457
+ reject("INVALID_UUID", "Invalid UUID format", nil)
458
+ return
459
+ }
460
+ getCallKitManager().simulateAudioSetupRace(
461
+ for: uuid,
462
+ delay: delayMilliseconds.doubleValue / 1_000
463
+ )
464
+ resolve(["scheduled": true, "delayMilliseconds": delayMilliseconds])
465
+ }
466
+ #endif
467
+
466
468
  @objc func reportCallEnded(
467
469
  _ callUUID: String, reason: NSNumber,
468
470
  resolver resolve: @escaping RCTPromiseResolveBlock,
@@ -744,6 +746,20 @@ import React
744
746
  lock: stateLock)
745
747
  public lazy var pendingHeldActions = LockedDictionary<UUID, CXSetHeldCallAction>(
746
748
  lock: stateLock)
749
+ private var audioLifecycleGeneration = 0
750
+
751
+ private func advanceAudioLifecycleGeneration() -> Int {
752
+ stateLock.lock()
753
+ defer { stateLock.unlock() }
754
+ audioLifecycleGeneration += 1
755
+ return audioLifecycleGeneration
756
+ }
757
+
758
+ private func currentAudioLifecycleGeneration() -> Int {
759
+ stateLock.lock()
760
+ defer { stateLock.unlock() }
761
+ return audioLifecycleGeneration
762
+ }
747
763
 
748
764
  fileprivate func markCallKitRegistered(_ callUUID: UUID) {
749
765
  guard var callData = activeCalls[callUUID] else {
@@ -825,10 +841,6 @@ import React
825
841
  }
826
842
 
827
843
  private func setupCallKit() {
828
- // ALWAYS configure WebRTC audio, even if provider already exists
829
- RTCAudioSession.sharedInstance().useManualAudio = true
830
- RTCAudioSession.sharedInstance().isAudioEnabled = false
831
-
832
844
  // Guard against duplicate provider creation (async setupAutomatically can overwrite
833
845
  // the provider created by setupSynchronously for VoIP push)
834
846
  guard callKitProvider == nil else {
@@ -836,9 +848,84 @@ import React
836
848
  return
837
849
  }
838
850
 
851
+ // Set the initial state only when installing the provider. Re-running setup after
852
+ // CallKit activation must never disable a live WebRTC audio device.
853
+ RTCAudioSession.sharedInstance().useManualAudio = true
854
+ RTCAudioSession.sharedInstance().isAudioEnabled = false
855
+
839
856
  _ = installCallKitProvider()
840
857
  }
841
858
 
859
+ fileprivate func activateWebRTCAudio(
860
+ _ audioSession: AVAudioSession = AVAudioSession.sharedInstance(),
861
+ reason: String
862
+ ) {
863
+ let rtcAudioSession = RTCAudioSession.sharedInstance()
864
+ rtcAudioSession.lockForConfiguration()
865
+ let wasAudioEnabled = rtcAudioSession.isAudioEnabled
866
+ NSLog("TelnyxVoice: activating WebRTC audio (\(reason)); enabled=\(wasAudioEnabled)")
867
+ let webRTCConfig = RTCAudioSessionConfiguration.webRTC()
868
+ webRTCConfig.categoryOptions = [.duckOthers, .allowBluetooth]
869
+ do {
870
+ try rtcAudioSession.setConfiguration(webRTCConfig)
871
+ } catch {
872
+ NSLog("TelnyxVoice: activateWebRTCAudio configuration (\(reason)) error: \(error)")
873
+ }
874
+
875
+ var activationSucceeded = false
876
+ do {
877
+ try rtcAudioSession.setActive(true)
878
+ activationSucceeded = true
879
+ } catch {
880
+ NSLog("TelnyxVoice: activateWebRTCAudio activation (\(reason)) error: \(error)")
881
+ }
882
+ if activationSucceeded {
883
+ rtcAudioSession.isAudioEnabled = true
884
+ } else if !wasAudioEnabled {
885
+ rtcAudioSession.isAudioEnabled = false
886
+ }
887
+ rtcAudioSession.unlockForConfiguration()
888
+ if activationSucceeded && !wasAudioEnabled {
889
+ rtcAudioSession.audioSessionDidActivate(audioSession)
890
+ }
891
+ }
892
+
893
+ fileprivate func verifyAudioAfterConnection(for callUUID: UUID) {
894
+ // didActivate is asynchronous after CXAnswerCallAction.fulfill(). Verify shortly
895
+ // afterwards so either callback ordering converges on the same idempotent state.
896
+ let verificationGeneration = currentAudioLifecycleGeneration()
897
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) { [weak self] in
898
+ guard let self,
899
+ self.currentAudioLifecycleGeneration() == verificationGeneration,
900
+ self.activeCalls[callUUID] != nil
901
+ else { return }
902
+ let rtcAudioSession = RTCAudioSession.sharedInstance()
903
+ rtcAudioSession.lockForConfiguration()
904
+ let isAudioEnabled = rtcAudioSession.isAudioEnabled
905
+ rtcAudioSession.unlockForConfiguration()
906
+ if isAudioEnabled {
907
+ NSLog("TelnyxVoice: audio verification passed for \(callUUID)")
908
+ } else if AVAudioSession.sharedInstance().category != .playAndRecord {
909
+ NSLog("TelnyxVoice: audio verification skipped for inactive CallKit session \(callUUID)")
910
+ } else {
911
+ NSLog("TelnyxVoice: audio verification recovering disabled audio for \(callUUID)")
912
+ self.activateWebRTCAudio(reason: "connected-call verification")
913
+ }
914
+ }
915
+ }
916
+
917
+ #if DEBUG
918
+ fileprivate func simulateAudioSetupRace(for callUUID: UUID, delay: TimeInterval) {
919
+ NSLog("TelnyxVoice: [VSUP-226] scheduling late audio reset in \(delay)s")
920
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
921
+ let rtcAudioSession = RTCAudioSession.sharedInstance()
922
+ rtcAudioSession.isAudioEnabled = false
923
+ NSLog("TelnyxVoice: [VSUP-226] injected late setup reset; enabled=false")
924
+ self?.verifyAudioAfterConnection(for: callUUID)
925
+ }
926
+ }
927
+ #endif
928
+
842
929
  private func installCallKitProvider() -> CXProvider {
843
930
  // Use the localizedName from the app's bundle display name or fallback
844
931
  let appName =
@@ -1404,6 +1491,7 @@ import React
1404
1491
  "CallKitDidPerformEndCallAction", callUUID: action.callUUID,
1405
1492
  callData: callData)
1406
1493
 
1494
+ _ = advanceAudioLifecycleGeneration()
1407
1495
  activeCalls.removeValue(forKey: action.callUUID)
1408
1496
  pendingAnswerActions.removeValue(forKey: action.callUUID)?.fail()
1409
1497
  pendingHeldActions.removeValue(forKey: action.callUUID)?.fail()
@@ -1442,31 +1530,8 @@ import React
1442
1530
 
1443
1531
  public func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
1444
1532
  NSLog("TelnyxVoice: Audio session activated by CallKit")
1445
-
1446
- let rtcAudioSession = RTCAudioSession.sharedInstance()
1447
-
1448
- // Step 1: Configure (matches native iOS SDK setupCorrectAudioConfiguration)
1449
- rtcAudioSession.lockForConfiguration()
1450
- let webRTCConfig = RTCAudioSessionConfiguration.webRTC()
1451
- webRTCConfig.categoryOptions = [.duckOthers, .allowBluetooth]
1452
- do {
1453
- try rtcAudioSession.setConfiguration(webRTCConfig)
1454
- } catch {
1455
- NSLog("TelnyxVoice: didActivateAudioSession - setConfiguration error: \(error)")
1456
- }
1457
- rtcAudioSession.unlockForConfiguration()
1458
-
1459
- // Step 2: Activate (matches native iOS SDK setAudioSessionActive)
1460
- rtcAudioSession.lockForConfiguration()
1461
- do {
1462
- try rtcAudioSession.setActive(true)
1463
- } catch {
1464
- NSLog("TelnyxVoice: didActivateAudioSession - setActive error: \(error)")
1465
- }
1466
- rtcAudioSession.isAudioEnabled = true
1467
- rtcAudioSession.unlockForConfiguration()
1468
-
1469
- rtcAudioSession.audioSessionDidActivate(audioSession)
1533
+ _ = advanceAudioLifecycleGeneration()
1534
+ activateWebRTCAudio(audioSession, reason: "CallKit didActivate")
1470
1535
 
1471
1536
  // Emit audio session activated event to React Native
1472
1537
  CallKitBridge.shared?.emitAudioSessionEvent(
@@ -1480,6 +1545,7 @@ import React
1480
1545
 
1481
1546
  public func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
1482
1547
  NSLog("TelnyxVoice: Audio session deactivated by CallKit")
1548
+ _ = advanceAudioLifecycleGeneration()
1483
1549
 
1484
1550
  let rtcAudioSession = RTCAudioSession.sharedInstance()
1485
1551
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telnyx/react-voice-commons-sdk",
3
- "version": "1.1.0",
3
+ "version": "1.1.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",
@@ -86,7 +86,7 @@
86
86
  },
87
87
  "dependencies": {
88
88
  "@react-native-community/eslint-config": "^3.2.0",
89
- "@telnyx/react-native-voice-sdk": ">=1.0.0",
89
+ "@telnyx/react-native-voice-sdk": ">=1.1.0",
90
90
  "eventemitter3": "^5.0.1",
91
91
  "expo": "~53.0.22",
92
92
  "react-native-url-polyfill": "^3.0.0",