@telnyx/react-voice-commons-sdk 1.1.0 → 1.2.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.
- package/CHANGELOG.md +38 -1
- package/TelnyxVoiceCommons.podspec +3 -3
- package/ios/CallKitBridge.m +7 -0
- package/ios/CallKitBridge.swift +239 -91
- package/lib/callkit/callkit-coordinator.d.ts +19 -0
- package/lib/callkit/callkit-coordinator.js +105 -5
- package/lib/index.d.ts +4 -1
- package/lib/index.js +9 -0
- package/lib/internal/calls/call-state-controller.d.ts +1 -0
- package/lib/internal/calls/call-state-controller.js +35 -1
- package/lib/internal/ice-servers-storage.d.ts +9 -0
- package/lib/internal/ice-servers-storage.js +37 -0
- package/lib/internal/sdk-identity.d.ts +4 -0
- package/lib/internal/sdk-identity.js +60 -0
- package/lib/internal/session/session-manager.d.ts +7 -2
- package/lib/internal/session/session-manager.js +35 -4
- package/lib/internal/signaling-environment.d.ts +13 -0
- package/lib/internal/signaling-environment.js +18 -0
- package/lib/models/call.d.ts +23 -2
- package/lib/models/call.js +37 -2
- package/lib/models/config.d.ts +28 -0
- package/lib/telnyx-voip-client.d.ts +4 -2
- package/lib/telnyx-voip-client.js +60 -2
- package/package.json +3 -4
- package/src/callkit/callkit-coordinator.ts +129 -7
- package/src/index.ts +6 -1
- package/src/internal/calls/call-state-controller.ts +41 -2
- package/src/internal/ice-servers-storage.ts +37 -0
- package/src/internal/sdk-identity.ts +6 -0
- package/src/internal/session/session-manager.ts +38 -4
- package/src/internal/signaling-environment.ts +20 -0
- package/src/models/call.ts +44 -2
- package/src/models/config.ts +30 -0
- package/src/telnyx-voip-client.ts +60 -3
- package/src/types/telnyx-sdk.d.ts +67 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,43 @@
|
|
|
1
1
|
# CHANGELOG.md
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## [1.2.0] (2026-10-28)
|
|
4
|
+
|
|
5
|
+
### Enhancement
|
|
6
|
+
|
|
7
|
+
- Expose call-quality metrics through `Call.qualityMetrics$`, `currentQualityMetrics`, and the exported metrics and quality-level types.
|
|
8
|
+
- Add automatic active-call recovery through the Voice SDK, including signaling-health monitoring, ICE restart, RTP verification, and reconnect/reattach fallback.
|
|
9
|
+
- Add `iceServers` to credential and token configurations so applications can provide custom STUN/TURN servers; omitted values use the Voice SDK's Telnyx production defaults.
|
|
10
|
+
- Identify Commons signaling as `webrtc-react-native-commons` with the Commons package version while retaining per-client identity isolation and the existing User-Agent format.
|
|
11
|
+
|
|
12
|
+
### Dependencies
|
|
13
|
+
|
|
14
|
+
- Require `react-native-webrtc ^155.0.1` (WebRTC M155), matching the Voice SDK's media dependency. Applications must target iOS 14 or newer; the Android minimum remains API 24.
|
|
15
|
+
- Require `@telnyx/react-native-voice-sdk ^1.2.0` when publishing so Commons uses the matching signaling security, call recovery, metrics, and ICE-server behavior while staying within the compatible 1.x line.
|
|
16
|
+
- Remove the unused direct `expo` dependency. Commons does not require Expo,
|
|
17
|
+
so this avoids installing Expo SDK 53 alongside an application's Expo SDK.
|
|
18
|
+
|
|
19
|
+
### Security
|
|
20
|
+
|
|
21
|
+
- Use the Voice SDK's platform-default TLS certificate and hostname validation. On iOS, signaling uses `URLSessionWebSocketTask`; Android retains React Native's OkHttp-backed WebSocket.
|
|
22
|
+
|
|
23
|
+
### Bug Fixing
|
|
24
|
+
|
|
25
|
+
- Handle BYE and missed-call pushes that arrive before the incoming WebRTC call exists, dismiss the matching CallKit call, clear pending push state, and show the missed-call notification without treating the original UUID as a collision.
|
|
26
|
+
- Serialize CallKit registration completion with missed-call invalidation so a delayed completion cannot restore an already-ended incoming call; PushKit completion remains exactly once.
|
|
27
|
+
- Keep the active CallKit UUID claimed until WebRTC answering finishes, share concurrent answer work, and reject a second call while another call owns the answering/active slot.
|
|
28
|
+
- Prevent duplicate incoming and reattached-call listeners when the same client-ready callback fires more than once during connection or reconnection.
|
|
29
|
+
|
|
30
|
+
## [1.1.1] (2026-09-11)
|
|
31
|
+
|
|
32
|
+
### Bug Fixing
|
|
33
|
+
|
|
34
|
+
- Improve iOS CallKit audio reliability by routing activation and connected-call verification through a single idempotent recovery path.
|
|
35
|
+
- Recover connected calls when CallKit owns an active play-and-record audio session but the WebRTC audio device remains disabled.
|
|
36
|
+
- Prevent duplicate CallKit setup from disabling WebRTC audio after a call is already active, and harden failure handling when audio recovery does not succeed.
|
|
37
|
+
|
|
38
|
+
### Dependencies
|
|
39
|
+
|
|
40
|
+
- Continues to require `@telnyx/react-native-voice-sdk >= 1.1.0`; no new base Voice SDK release is required.
|
|
4
41
|
|
|
5
42
|
## [1.1.0] (2026-08-07)
|
|
6
43
|
|
|
@@ -9,7 +9,7 @@ Pod::Spec.new do |s|
|
|
|
9
9
|
s.description = package['description']
|
|
10
10
|
s.homepage = package['homepage']
|
|
11
11
|
s.license = package['license']
|
|
12
|
-
s.platforms = { :ios => '
|
|
12
|
+
s.platforms = { :ios => '14.0' }
|
|
13
13
|
s.swift_version = '5.4'
|
|
14
14
|
s.author = package['author']
|
|
15
15
|
s.source = { :git => package['repository']['url'], :tag => "#{s.version}" }
|
|
@@ -22,11 +22,11 @@ Pod::Spec.new do |s|
|
|
|
22
22
|
s.dependency 'React-Core'
|
|
23
23
|
|
|
24
24
|
# WebRTC Framework dependency
|
|
25
|
-
s.dependency 'JitsiWebRTC', '~>
|
|
25
|
+
s.dependency 'JitsiWebRTC', '~> 155.0.0'
|
|
26
26
|
|
|
27
27
|
# iOS Frameworks
|
|
28
28
|
s.frameworks = 'CallKit', 'PushKit', 'AVFoundation'
|
|
29
29
|
|
|
30
30
|
# Compiler flags for Swift
|
|
31
31
|
s.compiler_flags = '-DFOLLY_NO_CONFIG -DFOLLY_MOBILE=1 -DFOLLY_USE_LIBCPP=1'
|
|
32
|
-
end
|
|
32
|
+
end
|
package/ios/CallKitBridge.m
CHANGED
|
@@ -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
|
package/ios/CallKitBridge.swift
CHANGED
|
@@ -156,7 +156,11 @@ import React
|
|
|
156
156
|
"handle": handle,
|
|
157
157
|
"uuid": uuid.uuidString,
|
|
158
158
|
"direction": "outgoing",
|
|
159
|
-
|
|
159
|
+
// VSDK-771 AFK review: align internal source tag with the
|
|
160
|
+
// new prefixed convention so grep-based family completeness
|
|
161
|
+
// (webrtc-react-native / webrtc-react-native-commons) is
|
|
162
|
+
// clean across the SDK. Not consumed by the wire contract.
|
|
163
|
+
"source": "webrtc-react-native",
|
|
160
164
|
]
|
|
161
165
|
|
|
162
166
|
callController.request(transaction) { error in
|
|
@@ -197,7 +201,9 @@ import React
|
|
|
197
201
|
"handle": handle,
|
|
198
202
|
"uuid": uuid.uuidString,
|
|
199
203
|
"direction": "incoming",
|
|
200
|
-
|
|
204
|
+
// VSDK-771 AFK review: align internal source tag with the
|
|
205
|
+
// new prefixed convention. See outgoing branch above.
|
|
206
|
+
"source": "webrtc-react-native",
|
|
201
207
|
"isCallKitRegistered": false,
|
|
202
208
|
]
|
|
203
209
|
|
|
@@ -440,29 +446,31 @@ import React
|
|
|
440
446
|
NSLog("TelnyxVoice: reportCallConnected - fulfilling deferred CXAnswerCallAction")
|
|
441
447
|
pendingAction.fulfill()
|
|
442
448
|
} else {
|
|
443
|
-
|
|
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())
|
|
449
|
+
manager.verifyAudioAfterConnection(for: uuid)
|
|
461
450
|
}
|
|
462
451
|
|
|
463
452
|
resolve(["success": true])
|
|
464
453
|
}
|
|
465
454
|
|
|
455
|
+
#if DEBUG
|
|
456
|
+
@objc func simulateAudioSetupRace(
|
|
457
|
+
_ callUUID: String,
|
|
458
|
+
delayMilliseconds: NSNumber,
|
|
459
|
+
resolver resolve: @escaping RCTPromiseResolveBlock,
|
|
460
|
+
rejecter reject: @escaping RCTPromiseRejectBlock
|
|
461
|
+
) {
|
|
462
|
+
guard let uuid = UUID(uuidString: callUUID) else {
|
|
463
|
+
reject("INVALID_UUID", "Invalid UUID format", nil)
|
|
464
|
+
return
|
|
465
|
+
}
|
|
466
|
+
getCallKitManager().simulateAudioSetupRace(
|
|
467
|
+
for: uuid,
|
|
468
|
+
delay: delayMilliseconds.doubleValue / 1_000
|
|
469
|
+
)
|
|
470
|
+
resolve(["scheduled": true, "delayMilliseconds": delayMilliseconds])
|
|
471
|
+
}
|
|
472
|
+
#endif
|
|
473
|
+
|
|
466
474
|
@objc func reportCallEnded(
|
|
467
475
|
_ callUUID: String, reason: NSNumber,
|
|
468
476
|
resolver resolve: @escaping RCTPromiseResolveBlock,
|
|
@@ -739,11 +747,29 @@ import React
|
|
|
739
747
|
public var callKitProvider: CXProvider?
|
|
740
748
|
public var callKitController: CXCallController?
|
|
741
749
|
private let stateLock = NSRecursiveLock()
|
|
750
|
+
// Keep terminal missed-call invalidation atomic with registration side effects so a
|
|
751
|
+
// delayed CallKit completion cannot restore an already-ended incoming call.
|
|
752
|
+
private let incomingRegistrationQueue = DispatchQueue(
|
|
753
|
+
label: "com.telnyx.react-voice-commons.incoming-registration")
|
|
742
754
|
public lazy var activeCalls = LockedDictionary<UUID, [String: Any]>(lock: stateLock)
|
|
743
755
|
public lazy var pendingAnswerActions = LockedDictionary<UUID, CXAnswerCallAction>(
|
|
744
756
|
lock: stateLock)
|
|
745
757
|
public lazy var pendingHeldActions = LockedDictionary<UUID, CXSetHeldCallAction>(
|
|
746
758
|
lock: stateLock)
|
|
759
|
+
private var audioLifecycleGeneration = 0
|
|
760
|
+
|
|
761
|
+
private func advanceAudioLifecycleGeneration() -> Int {
|
|
762
|
+
stateLock.lock()
|
|
763
|
+
defer { stateLock.unlock() }
|
|
764
|
+
audioLifecycleGeneration += 1
|
|
765
|
+
return audioLifecycleGeneration
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
private func currentAudioLifecycleGeneration() -> Int {
|
|
769
|
+
stateLock.lock()
|
|
770
|
+
defer { stateLock.unlock() }
|
|
771
|
+
return audioLifecycleGeneration
|
|
772
|
+
}
|
|
747
773
|
|
|
748
774
|
fileprivate func markCallKitRegistered(_ callUUID: UUID) {
|
|
749
775
|
guard var callData = activeCalls[callUUID] else {
|
|
@@ -766,6 +792,35 @@ import React
|
|
|
766
792
|
return true
|
|
767
793
|
}
|
|
768
794
|
|
|
795
|
+
@discardableResult
|
|
796
|
+
fileprivate func performIncomingRegistrationIfOwned(
|
|
797
|
+
_ callUUID: UUID,
|
|
798
|
+
token: String,
|
|
799
|
+
effects: () -> Void
|
|
800
|
+
) -> Bool {
|
|
801
|
+
return incomingRegistrationQueue.sync {
|
|
802
|
+
guard let callData = activeCalls[callUUID],
|
|
803
|
+
callData["incomingRegistrationToken"] as? String == token
|
|
804
|
+
else {
|
|
805
|
+
return false
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
effects()
|
|
809
|
+
return true
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
fileprivate func invalidateIncomingRegistration(_ callUUID: UUID) {
|
|
814
|
+
incomingRegistrationQueue.sync {
|
|
815
|
+
guard var callData = activeCalls[callUUID] else {
|
|
816
|
+
return
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
callData.removeValue(forKey: "incomingRegistrationToken")
|
|
820
|
+
activeCalls[callUUID] = callData
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
769
824
|
private override init() {
|
|
770
825
|
super.init()
|
|
771
826
|
// Only setup if explicitly requested via setup() method
|
|
@@ -825,10 +880,6 @@ import React
|
|
|
825
880
|
}
|
|
826
881
|
|
|
827
882
|
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
883
|
// Guard against duplicate provider creation (async setupAutomatically can overwrite
|
|
833
884
|
// the provider created by setupSynchronously for VoIP push)
|
|
834
885
|
guard callKitProvider == nil else {
|
|
@@ -836,9 +887,84 @@ import React
|
|
|
836
887
|
return
|
|
837
888
|
}
|
|
838
889
|
|
|
890
|
+
// Set the initial state only when installing the provider. Re-running setup after
|
|
891
|
+
// CallKit activation must never disable a live WebRTC audio device.
|
|
892
|
+
RTCAudioSession.sharedInstance().useManualAudio = true
|
|
893
|
+
RTCAudioSession.sharedInstance().isAudioEnabled = false
|
|
894
|
+
|
|
839
895
|
_ = installCallKitProvider()
|
|
840
896
|
}
|
|
841
897
|
|
|
898
|
+
fileprivate func activateWebRTCAudio(
|
|
899
|
+
_ audioSession: AVAudioSession = AVAudioSession.sharedInstance(),
|
|
900
|
+
reason: String
|
|
901
|
+
) {
|
|
902
|
+
let rtcAudioSession = RTCAudioSession.sharedInstance()
|
|
903
|
+
rtcAudioSession.lockForConfiguration()
|
|
904
|
+
let wasAudioEnabled = rtcAudioSession.isAudioEnabled
|
|
905
|
+
NSLog("TelnyxVoice: activating WebRTC audio (\(reason)); enabled=\(wasAudioEnabled)")
|
|
906
|
+
let webRTCConfig = RTCAudioSessionConfiguration.webRTC()
|
|
907
|
+
webRTCConfig.categoryOptions = [.duckOthers, .allowBluetooth]
|
|
908
|
+
do {
|
|
909
|
+
try rtcAudioSession.setConfiguration(webRTCConfig)
|
|
910
|
+
} catch {
|
|
911
|
+
NSLog("TelnyxVoice: activateWebRTCAudio configuration (\(reason)) error: \(error)")
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
var activationSucceeded = false
|
|
915
|
+
do {
|
|
916
|
+
try rtcAudioSession.setActive(true)
|
|
917
|
+
activationSucceeded = true
|
|
918
|
+
} catch {
|
|
919
|
+
NSLog("TelnyxVoice: activateWebRTCAudio activation (\(reason)) error: \(error)")
|
|
920
|
+
}
|
|
921
|
+
if activationSucceeded {
|
|
922
|
+
rtcAudioSession.isAudioEnabled = true
|
|
923
|
+
} else if !wasAudioEnabled {
|
|
924
|
+
rtcAudioSession.isAudioEnabled = false
|
|
925
|
+
}
|
|
926
|
+
rtcAudioSession.unlockForConfiguration()
|
|
927
|
+
if activationSucceeded && !wasAudioEnabled {
|
|
928
|
+
rtcAudioSession.audioSessionDidActivate(audioSession)
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
fileprivate func verifyAudioAfterConnection(for callUUID: UUID) {
|
|
933
|
+
// didActivate is asynchronous after CXAnswerCallAction.fulfill(). Verify shortly
|
|
934
|
+
// afterwards so either callback ordering converges on the same idempotent state.
|
|
935
|
+
let verificationGeneration = currentAudioLifecycleGeneration()
|
|
936
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) { [weak self] in
|
|
937
|
+
guard let self,
|
|
938
|
+
self.currentAudioLifecycleGeneration() == verificationGeneration,
|
|
939
|
+
self.activeCalls[callUUID] != nil
|
|
940
|
+
else { return }
|
|
941
|
+
let rtcAudioSession = RTCAudioSession.sharedInstance()
|
|
942
|
+
rtcAudioSession.lockForConfiguration()
|
|
943
|
+
let isAudioEnabled = rtcAudioSession.isAudioEnabled
|
|
944
|
+
rtcAudioSession.unlockForConfiguration()
|
|
945
|
+
if isAudioEnabled {
|
|
946
|
+
NSLog("TelnyxVoice: audio verification passed for \(callUUID)")
|
|
947
|
+
} else if AVAudioSession.sharedInstance().category != .playAndRecord {
|
|
948
|
+
NSLog("TelnyxVoice: audio verification skipped for inactive CallKit session \(callUUID)")
|
|
949
|
+
} else {
|
|
950
|
+
NSLog("TelnyxVoice: audio verification recovering disabled audio for \(callUUID)")
|
|
951
|
+
self.activateWebRTCAudio(reason: "connected-call verification")
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
#if DEBUG
|
|
957
|
+
fileprivate func simulateAudioSetupRace(for callUUID: UUID, delay: TimeInterval) {
|
|
958
|
+
NSLog("TelnyxVoice: [VSUP-226] scheduling late audio reset in \(delay)s")
|
|
959
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
|
960
|
+
let rtcAudioSession = RTCAudioSession.sharedInstance()
|
|
961
|
+
rtcAudioSession.isAudioEnabled = false
|
|
962
|
+
NSLog("TelnyxVoice: [VSUP-226] injected late setup reset; enabled=false")
|
|
963
|
+
self?.verifyAudioAfterConnection(for: callUUID)
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
#endif
|
|
967
|
+
|
|
842
968
|
private func installCallKitProvider() -> CXProvider {
|
|
843
969
|
// Use the localizedName from the app's bundle display name or fallback
|
|
844
970
|
let appName =
|
|
@@ -977,7 +1103,36 @@ import React
|
|
|
977
1103
|
}
|
|
978
1104
|
|
|
979
1105
|
if let activeCall = activeCalls[callUUID] {
|
|
980
|
-
|
|
1106
|
+
let activeSource = activeCall["source"] as? String
|
|
1107
|
+
|
|
1108
|
+
// A missed-call push normally reuses the original incoming
|
|
1109
|
+
// push's call_id. End that still-ringing CallKit call and show
|
|
1110
|
+
// the notification instead of treating it as a UUID collision.
|
|
1111
|
+
if activeSource == "push_notification" || activeSource == "terminated_app_push"
|
|
1112
|
+
|| activeSource == "push"
|
|
1113
|
+
{
|
|
1114
|
+
NSLog(
|
|
1115
|
+
"TelnyxVoice: Missed call matched active incoming push UUID: \(callUUID)"
|
|
1116
|
+
)
|
|
1117
|
+
// Stop the original report callback from persisting or emitting
|
|
1118
|
+
// this call while the missed-call watchdog report is pending.
|
|
1119
|
+
invalidateIncomingRegistration(callUUID)
|
|
1120
|
+
// This is a second PushKit delivery, so report it to CallKit
|
|
1121
|
+
// with a fresh UUID before ending the original ringing call.
|
|
1122
|
+
reportAndEndWatchdogCall(
|
|
1123
|
+
callUUID: UUID(),
|
|
1124
|
+
callerName: callerName,
|
|
1125
|
+
callerNumber: callerNumber,
|
|
1126
|
+
payload: payload,
|
|
1127
|
+
source: "missed_call_matched_watchdog",
|
|
1128
|
+
endedReason: .remoteEnded
|
|
1129
|
+
) {
|
|
1130
|
+
finishMissedCall()
|
|
1131
|
+
}
|
|
1132
|
+
return true
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
guard activeSource == "missed_call_push" else {
|
|
981
1136
|
NSLog("TelnyxVoice: Suppressing missed call notification because UUID belongs to an active call: \(callUUID)")
|
|
982
1137
|
markMissedCallProcessed(id: missedCallId)
|
|
983
1138
|
reportAndEndWatchdogCall(
|
|
@@ -1404,6 +1559,7 @@ import React
|
|
|
1404
1559
|
"CallKitDidPerformEndCallAction", callUUID: action.callUUID,
|
|
1405
1560
|
callData: callData)
|
|
1406
1561
|
|
|
1562
|
+
_ = advanceAudioLifecycleGeneration()
|
|
1407
1563
|
activeCalls.removeValue(forKey: action.callUUID)
|
|
1408
1564
|
pendingAnswerActions.removeValue(forKey: action.callUUID)?.fail()
|
|
1409
1565
|
pendingHeldActions.removeValue(forKey: action.callUUID)?.fail()
|
|
@@ -1442,31 +1598,8 @@ import React
|
|
|
1442
1598
|
|
|
1443
1599
|
public func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
|
|
1444
1600
|
NSLog("TelnyxVoice: Audio session activated by CallKit")
|
|
1445
|
-
|
|
1446
|
-
|
|
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)
|
|
1601
|
+
_ = advanceAudioLifecycleGeneration()
|
|
1602
|
+
activateWebRTCAudio(audioSession, reason: "CallKit didActivate")
|
|
1470
1603
|
|
|
1471
1604
|
// Emit audio session activated event to React Native
|
|
1472
1605
|
CallKitBridge.shared?.emitAudioSessionEvent(
|
|
@@ -1480,6 +1613,7 @@ import React
|
|
|
1480
1613
|
|
|
1481
1614
|
public func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
|
|
1482
1615
|
NSLog("TelnyxVoice: Audio session deactivated by CallKit")
|
|
1616
|
+
_ = advanceAudioLifecycleGeneration()
|
|
1483
1617
|
|
|
1484
1618
|
let rtcAudioSession = RTCAudioSession.sharedInstance()
|
|
1485
1619
|
|
|
@@ -1657,6 +1791,7 @@ import React
|
|
|
1657
1791
|
|
|
1658
1792
|
// Store call data manually and report to CallKit
|
|
1659
1793
|
let isAppRunning = CallKitBridge.shared != nil
|
|
1794
|
+
let registrationToken = UUID().uuidString
|
|
1660
1795
|
callKitManager.activeCalls[callUUID] = [
|
|
1661
1796
|
"caller": callerName,
|
|
1662
1797
|
"handle": callerNumber,
|
|
@@ -1665,6 +1800,7 @@ import React
|
|
|
1665
1800
|
"direction": "incoming",
|
|
1666
1801
|
"source": isAppRunning ? "push_notification" : "terminated_app_push",
|
|
1667
1802
|
"isCallKitRegistered": false,
|
|
1803
|
+
"incomingRegistrationToken": registrationToken,
|
|
1668
1804
|
]
|
|
1669
1805
|
|
|
1670
1806
|
// Report to CallKit immediately
|
|
@@ -1676,56 +1812,68 @@ import React
|
|
|
1676
1812
|
callUpdate.supportsHolding = true
|
|
1677
1813
|
|
|
1678
1814
|
callKitProvider.reportNewIncomingCall(with: callUUID, update: callUpdate) { error in
|
|
1679
|
-
|
|
1680
|
-
let nsError = error as NSError
|
|
1681
|
-
let reason =
|
|
1682
|
-
nsError.domain == CXErrorDomainIncomingCall && nsError.code == 3
|
|
1683
|
-
? "filteredByDoNotDisturb" : "registrationRejected"
|
|
1684
|
-
NSLog(
|
|
1685
|
-
"[TelnyxVoipPushHandler] CallKit incoming registration rejected: domain=\(nsError.domain), code=\(nsError.code), reason=\(reason)"
|
|
1686
|
-
)
|
|
1687
|
-
callKitManager.activeCalls.removeValue(forKey: callUUID)
|
|
1688
|
-
callKitManager.clearPendingPushData(for: callUUID.uuidString)
|
|
1689
|
-
} else {
|
|
1690
|
-
NSLog(
|
|
1691
|
-
"[TelnyxVoipPushHandler] ✅ CallKit call reported successfully via unified path"
|
|
1692
|
-
)
|
|
1693
|
-
|
|
1694
|
-
callKitManager.markCallKitRegistered(callUUID)
|
|
1695
|
-
self.storePendingVoipPush(payload.dictionaryPayload)
|
|
1815
|
+
defer { completion() }
|
|
1696
1816
|
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
// Try to emit push received event if CallKit bridge is available
|
|
1706
|
-
if let callKitBridge = CallKitBridge.shared {
|
|
1707
|
-
let source = isAppRunning ? "push_notification" : "terminated_app_push"
|
|
1708
|
-
callKitBridge.emitCallEvent(
|
|
1709
|
-
"CallKitDidReceivePush", callUUID: callUUID,
|
|
1710
|
-
callData: [
|
|
1711
|
-
"caller": callerName,
|
|
1712
|
-
"handle": callerNumber,
|
|
1713
|
-
"payload": payload.dictionaryPayload,
|
|
1714
|
-
"uuid": callUUID.uuidString,
|
|
1715
|
-
"direction": "incoming",
|
|
1716
|
-
"source": source,
|
|
1717
|
-
])
|
|
1817
|
+
let handled = callKitManager.performIncomingRegistrationIfOwned(
|
|
1818
|
+
callUUID, token: registrationToken
|
|
1819
|
+
) {
|
|
1820
|
+
if let error = error {
|
|
1821
|
+
let nsError = error as NSError
|
|
1822
|
+
let reason =
|
|
1823
|
+
nsError.domain == CXErrorDomainIncomingCall && nsError.code == 3
|
|
1824
|
+
? "filteredByDoNotDisturb" : "registrationRejected"
|
|
1718
1825
|
NSLog(
|
|
1719
|
-
"[TelnyxVoipPushHandler]
|
|
1826
|
+
"[TelnyxVoipPushHandler] CallKit incoming registration rejected: domain=\(nsError.domain), code=\(nsError.code), reason=\(reason)"
|
|
1720
1827
|
)
|
|
1828
|
+
callKitManager.activeCalls.removeValue(forKey: callUUID)
|
|
1829
|
+
callKitManager.clearPendingPushData(for: callUUID.uuidString)
|
|
1721
1830
|
} else {
|
|
1722
1831
|
NSLog(
|
|
1723
|
-
"[TelnyxVoipPushHandler]
|
|
1832
|
+
"[TelnyxVoipPushHandler] ✅ CallKit call reported successfully via unified path"
|
|
1724
1833
|
)
|
|
1834
|
+
|
|
1835
|
+
callKitManager.markCallKitRegistered(callUUID)
|
|
1836
|
+
self.storePendingVoipPush(payload.dictionaryPayload)
|
|
1837
|
+
|
|
1838
|
+
// CRITICAL: Store the CallKit UUID for the React Native CallKitCoordinator
|
|
1839
|
+
// This allows the coordinator to find the existing CallKit call instead of creating a duplicate
|
|
1840
|
+
UserDefaults.standard.set(
|
|
1841
|
+
callUUID.uuidString, forKey: "@pending_callkit_uuid")
|
|
1842
|
+
UserDefaults.standard.synchronize()
|
|
1843
|
+
NSLog(
|
|
1844
|
+
"[TelnyxVoipPushHandler] ✅ Stored CallKit UUID for React Native coordinator: \(callUUID.uuidString)"
|
|
1845
|
+
)
|
|
1846
|
+
|
|
1847
|
+
// Try to emit push received event if CallKit bridge is available
|
|
1848
|
+
if let callKitBridge = CallKitBridge.shared {
|
|
1849
|
+
let source =
|
|
1850
|
+
isAppRunning ? "push_notification" : "terminated_app_push"
|
|
1851
|
+
callKitBridge.emitCallEvent(
|
|
1852
|
+
"CallKitDidReceivePush", callUUID: callUUID,
|
|
1853
|
+
callData: [
|
|
1854
|
+
"caller": callerName,
|
|
1855
|
+
"handle": callerNumber,
|
|
1856
|
+
"payload": payload.dictionaryPayload,
|
|
1857
|
+
"uuid": callUUID.uuidString,
|
|
1858
|
+
"direction": "incoming",
|
|
1859
|
+
"source": source,
|
|
1860
|
+
])
|
|
1861
|
+
NSLog(
|
|
1862
|
+
"[TelnyxVoipPushHandler] ✅ Emitted CallKitDidReceivePush event to React Native"
|
|
1863
|
+
)
|
|
1864
|
+
} else {
|
|
1865
|
+
NSLog(
|
|
1866
|
+
"[TelnyxVoipPushHandler] ⚠️ React Native bridge not ready - event will be emitted when bridge becomes available"
|
|
1867
|
+
)
|
|
1868
|
+
}
|
|
1725
1869
|
}
|
|
1726
1870
|
}
|
|
1727
1871
|
|
|
1728
|
-
|
|
1872
|
+
if !handled {
|
|
1873
|
+
NSLog(
|
|
1874
|
+
"[TelnyxVoipPushHandler] Ignoring stale incoming registration completion for ended call: \(callUUID.uuidString)"
|
|
1875
|
+
)
|
|
1876
|
+
}
|
|
1729
1877
|
}
|
|
1730
1878
|
}
|
|
1731
1879
|
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { Call } from '@telnyx/react-native-voice-sdk';
|
|
2
2
|
import type { TelnyxVoipClient } from '../telnyx-voip-client';
|
|
3
|
+
export type RemoteCallEndedReason = {
|
|
4
|
+
cause?: string;
|
|
5
|
+
causeCode?: number;
|
|
6
|
+
sipCode?: number;
|
|
7
|
+
sipReason?: string;
|
|
8
|
+
sipCallId?: string;
|
|
9
|
+
};
|
|
3
10
|
/**
|
|
4
11
|
* CallKit Coordinator - Manages the proper CallKit-first flow for iOS
|
|
5
12
|
*
|
|
@@ -11,6 +18,8 @@ declare class CallKitCoordinator {
|
|
|
11
18
|
private static instance;
|
|
12
19
|
private callMap;
|
|
13
20
|
private processingCalls;
|
|
21
|
+
private activeOrAnsweringCallUUID;
|
|
22
|
+
private claimActiveCall;
|
|
14
23
|
private endedCalls;
|
|
15
24
|
private connectedCalls;
|
|
16
25
|
private isCallFromPush;
|
|
@@ -80,6 +89,11 @@ declare class CallKitCoordinator {
|
|
|
80
89
|
* before calling this method.
|
|
81
90
|
*/
|
|
82
91
|
handleCallKitPushReceived(callKitUUID: string, event?: any): Promise<boolean>;
|
|
92
|
+
/** End a still-pending PushKit CallKit call after a remote hangup. */
|
|
93
|
+
handlePendingPushCallRemoteEnded(
|
|
94
|
+
callKitUUID: string,
|
|
95
|
+
reason?: RemoteCallEndedReason
|
|
96
|
+
): Promise<boolean>;
|
|
83
97
|
/**
|
|
84
98
|
* Handle push notification answer - when user answers from CallKit but we don't have a WebRTC call yet
|
|
85
99
|
* This is the iOS equivalent of the Android FCM handler
|
|
@@ -130,6 +144,7 @@ declare class CallKitCoordinator {
|
|
|
130
144
|
private getSDKClient;
|
|
131
145
|
private normalizeUUID;
|
|
132
146
|
private actionKey;
|
|
147
|
+
private releaseActiveCall;
|
|
133
148
|
private selectCall;
|
|
134
149
|
private isSelectedCall;
|
|
135
150
|
private recordPendingSwapAction;
|
|
@@ -152,6 +167,10 @@ declare class CallKitCoordinator {
|
|
|
152
167
|
* This helps prevent premature flag resets during CallKit operations
|
|
153
168
|
*/
|
|
154
169
|
hasProcessingCalls(): boolean;
|
|
170
|
+
/** @internal Exposed for deterministic single-active-call regression tests. */
|
|
171
|
+
claimActiveCallForTesting(callKitUUID: string): boolean;
|
|
172
|
+
/** @internal Exposed for deterministic single-active-call regression tests. */
|
|
173
|
+
releaseActiveCallForTesting(callKitUUID: string): void;
|
|
155
174
|
/**
|
|
156
175
|
* Check if there's currently a call from push notification being processed
|
|
157
176
|
* This helps prevent disconnection during push call handling
|