@telnyx/react-voice-commons-sdk 1.1.1 → 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.swift +126 -44
- 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.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
|
|
|
@@ -741,6 +747,10 @@ import React
|
|
|
741
747
|
public var callKitProvider: CXProvider?
|
|
742
748
|
public var callKitController: CXCallController?
|
|
743
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")
|
|
744
754
|
public lazy var activeCalls = LockedDictionary<UUID, [String: Any]>(lock: stateLock)
|
|
745
755
|
public lazy var pendingAnswerActions = LockedDictionary<UUID, CXAnswerCallAction>(
|
|
746
756
|
lock: stateLock)
|
|
@@ -782,6 +792,35 @@ import React
|
|
|
782
792
|
return true
|
|
783
793
|
}
|
|
784
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
|
+
|
|
785
824
|
private override init() {
|
|
786
825
|
super.init()
|
|
787
826
|
// Only setup if explicitly requested via setup() method
|
|
@@ -1064,7 +1103,36 @@ import React
|
|
|
1064
1103
|
}
|
|
1065
1104
|
|
|
1066
1105
|
if let activeCall = activeCalls[callUUID] {
|
|
1067
|
-
|
|
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 {
|
|
1068
1136
|
NSLog("TelnyxVoice: Suppressing missed call notification because UUID belongs to an active call: \(callUUID)")
|
|
1069
1137
|
markMissedCallProcessed(id: missedCallId)
|
|
1070
1138
|
reportAndEndWatchdogCall(
|
|
@@ -1723,6 +1791,7 @@ import React
|
|
|
1723
1791
|
|
|
1724
1792
|
// Store call data manually and report to CallKit
|
|
1725
1793
|
let isAppRunning = CallKitBridge.shared != nil
|
|
1794
|
+
let registrationToken = UUID().uuidString
|
|
1726
1795
|
callKitManager.activeCalls[callUUID] = [
|
|
1727
1796
|
"caller": callerName,
|
|
1728
1797
|
"handle": callerNumber,
|
|
@@ -1731,6 +1800,7 @@ import React
|
|
|
1731
1800
|
"direction": "incoming",
|
|
1732
1801
|
"source": isAppRunning ? "push_notification" : "terminated_app_push",
|
|
1733
1802
|
"isCallKitRegistered": false,
|
|
1803
|
+
"incomingRegistrationToken": registrationToken,
|
|
1734
1804
|
]
|
|
1735
1805
|
|
|
1736
1806
|
// Report to CallKit immediately
|
|
@@ -1742,56 +1812,68 @@ import React
|
|
|
1742
1812
|
callUpdate.supportsHolding = true
|
|
1743
1813
|
|
|
1744
1814
|
callKitProvider.reportNewIncomingCall(with: callUUID, update: callUpdate) { error in
|
|
1745
|
-
|
|
1746
|
-
let nsError = error as NSError
|
|
1747
|
-
let reason =
|
|
1748
|
-
nsError.domain == CXErrorDomainIncomingCall && nsError.code == 3
|
|
1749
|
-
? "filteredByDoNotDisturb" : "registrationRejected"
|
|
1750
|
-
NSLog(
|
|
1751
|
-
"[TelnyxVoipPushHandler] CallKit incoming registration rejected: domain=\(nsError.domain), code=\(nsError.code), reason=\(reason)"
|
|
1752
|
-
)
|
|
1753
|
-
callKitManager.activeCalls.removeValue(forKey: callUUID)
|
|
1754
|
-
callKitManager.clearPendingPushData(for: callUUID.uuidString)
|
|
1755
|
-
} else {
|
|
1756
|
-
NSLog(
|
|
1757
|
-
"[TelnyxVoipPushHandler] ✅ CallKit call reported successfully via unified path"
|
|
1758
|
-
)
|
|
1759
|
-
|
|
1760
|
-
callKitManager.markCallKitRegistered(callUUID)
|
|
1761
|
-
self.storePendingVoipPush(payload.dictionaryPayload)
|
|
1815
|
+
defer { completion() }
|
|
1762
1816
|
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
// Try to emit push received event if CallKit bridge is available
|
|
1772
|
-
if let callKitBridge = CallKitBridge.shared {
|
|
1773
|
-
let source = isAppRunning ? "push_notification" : "terminated_app_push"
|
|
1774
|
-
callKitBridge.emitCallEvent(
|
|
1775
|
-
"CallKitDidReceivePush", callUUID: callUUID,
|
|
1776
|
-
callData: [
|
|
1777
|
-
"caller": callerName,
|
|
1778
|
-
"handle": callerNumber,
|
|
1779
|
-
"payload": payload.dictionaryPayload,
|
|
1780
|
-
"uuid": callUUID.uuidString,
|
|
1781
|
-
"direction": "incoming",
|
|
1782
|
-
"source": source,
|
|
1783
|
-
])
|
|
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"
|
|
1784
1825
|
NSLog(
|
|
1785
|
-
"[TelnyxVoipPushHandler]
|
|
1826
|
+
"[TelnyxVoipPushHandler] CallKit incoming registration rejected: domain=\(nsError.domain), code=\(nsError.code), reason=\(reason)"
|
|
1786
1827
|
)
|
|
1828
|
+
callKitManager.activeCalls.removeValue(forKey: callUUID)
|
|
1829
|
+
callKitManager.clearPendingPushData(for: callUUID.uuidString)
|
|
1787
1830
|
} else {
|
|
1788
1831
|
NSLog(
|
|
1789
|
-
"[TelnyxVoipPushHandler]
|
|
1832
|
+
"[TelnyxVoipPushHandler] ✅ CallKit call reported successfully via unified path"
|
|
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)"
|
|
1790
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
|
+
}
|
|
1791
1869
|
}
|
|
1792
1870
|
}
|
|
1793
1871
|
|
|
1794
|
-
|
|
1872
|
+
if !handled {
|
|
1873
|
+
NSLog(
|
|
1874
|
+
"[TelnyxVoipPushHandler] Ignoring stale incoming registration completion for ended call: \(callUUID.uuidString)"
|
|
1875
|
+
)
|
|
1876
|
+
}
|
|
1795
1877
|
}
|
|
1796
1878
|
}
|
|
1797
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
|
|
@@ -64,6 +64,13 @@ const voice_pn_bridge_1 = require('../internal/voice-pn-bridge');
|
|
|
64
64
|
* guidelines for proper CallKit integration.
|
|
65
65
|
*/
|
|
66
66
|
class CallKitCoordinator {
|
|
67
|
+
claimActiveCall(callKitUUID) {
|
|
68
|
+
if (this.activeOrAnsweringCallUUID !== null && this.activeOrAnsweringCallUUID !== callKitUUID) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
this.activeOrAnsweringCallUUID = callKitUUID;
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
67
74
|
static getInstance() {
|
|
68
75
|
if (!CallKitCoordinator.instance) {
|
|
69
76
|
CallKitCoordinator.instance = new CallKitCoordinator();
|
|
@@ -76,6 +83,7 @@ class CallKitCoordinator {
|
|
|
76
83
|
// Tracks action+UUID pairs so an end action is not dropped merely because
|
|
77
84
|
// an answer or held action for the same call is still completing.
|
|
78
85
|
this.processingCalls = new Set();
|
|
86
|
+
this.activeOrAnsweringCallUUID = null;
|
|
79
87
|
// Tracks calls that have already been ended in CallKit to prevent duplicate reports
|
|
80
88
|
this.endedCalls = new Set();
|
|
81
89
|
// Tracks calls that have already been reported as connected to prevent duplicate reports
|
|
@@ -187,6 +195,16 @@ class CallKitCoordinator {
|
|
|
187
195
|
displayName || destinationNumber
|
|
188
196
|
);
|
|
189
197
|
if (success) {
|
|
198
|
+
if (!this.claimActiveCall(callKitUUID)) {
|
|
199
|
+
console.warn('CallKitCoordinator: Another call is already active or answering');
|
|
200
|
+
await callkit_1.default.reportCallEnded(callKitUUID, callkit_1.CallEndReason.Failed);
|
|
201
|
+
try {
|
|
202
|
+
await call.hangup();
|
|
203
|
+
} finally {
|
|
204
|
+
this.cleanupCall(callKitUUID);
|
|
205
|
+
}
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
190
208
|
this.callMap.set(callKitUUID, call);
|
|
191
209
|
this.setupWebRTCCallListeners(call, callKitUUID);
|
|
192
210
|
call._callKitUUID = callKitUUID;
|
|
@@ -208,6 +226,10 @@ class CallKitCoordinator {
|
|
|
208
226
|
console.warn('CallKitCoordinator: Cannot answer call - no CallKit UUID found');
|
|
209
227
|
return false;
|
|
210
228
|
}
|
|
229
|
+
if (!this.claimActiveCall(callKitUUID)) {
|
|
230
|
+
console.warn('CallKitCoordinator: Another call is already active or answering');
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
211
233
|
console.log(
|
|
212
234
|
'CallKitCoordinator: Answering call from UI using CallKit answer simulation',
|
|
213
235
|
callKitUUID
|
|
@@ -236,6 +258,8 @@ class CallKitCoordinator {
|
|
|
236
258
|
this.isCallFromPush = this.pendingPushCallUUIDs.size > 0;
|
|
237
259
|
await this.clearMatchingPendingVoipPush(callKitUUID);
|
|
238
260
|
console.log('CallKitCoordinator: CallKit answer success');
|
|
261
|
+
} else {
|
|
262
|
+
this.releaseActiveCall(callKitUUID);
|
|
239
263
|
}
|
|
240
264
|
return success;
|
|
241
265
|
} catch (error) {
|
|
@@ -391,6 +415,21 @@ class CallKitCoordinator {
|
|
|
391
415
|
console.log('CallKitCoordinator: Answer action already being processed, skipping duplicate');
|
|
392
416
|
return;
|
|
393
417
|
}
|
|
418
|
+
if (!this.claimActiveCall(callKitUUID)) {
|
|
419
|
+
console.warn(
|
|
420
|
+
'CallKitCoordinator: Rejecting answer because another call owns the active slot'
|
|
421
|
+
);
|
|
422
|
+
await callkit_1.default.reportCallEnded(callKitUUID, callkit_1.CallEndReason.Failed);
|
|
423
|
+
const rejectedCall = this.callMap.get(callKitUUID);
|
|
424
|
+
if (rejectedCall) {
|
|
425
|
+
try {
|
|
426
|
+
await rejectedCall.hangup();
|
|
427
|
+
} finally {
|
|
428
|
+
this.cleanupCall(callKitUUID);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
394
433
|
const call = this.callMap.get(callKitUUID);
|
|
395
434
|
if (!call) {
|
|
396
435
|
console.warn('CallKitCoordinator: No WebRTC call found for CallKit answer action', {
|
|
@@ -399,7 +438,10 @@ class CallKitCoordinator {
|
|
|
399
438
|
availableWebRTCCallIds: Array.from(this.callMap.values()).map((c) => c.callId),
|
|
400
439
|
});
|
|
401
440
|
console.log('CallKitCoordinator: No WebRTC call found, handling as push notification');
|
|
402
|
-
await this.handlePushNotificationAnswer(callKitUUID, event);
|
|
441
|
+
const queued = await this.handlePushNotificationAnswer(callKitUUID, event);
|
|
442
|
+
if (!queued) {
|
|
443
|
+
this.releaseActiveCall(callKitUUID);
|
|
444
|
+
}
|
|
403
445
|
return;
|
|
404
446
|
}
|
|
405
447
|
console.log('CallKitCoordinator: Processing CallKit answer action', {
|
|
@@ -675,6 +717,31 @@ class CallKitCoordinator {
|
|
|
675
717
|
return false;
|
|
676
718
|
}
|
|
677
719
|
}
|
|
720
|
+
/** End a still-pending PushKit CallKit call after a remote hangup. */
|
|
721
|
+
async handlePendingPushCallRemoteEnded(callKitUUID, reason) {
|
|
722
|
+
callKitUUID = this.normalizeUUID(callKitUUID);
|
|
723
|
+
// Do not let an unrelated/stale socket BYE terminate another CallKit call.
|
|
724
|
+
// The UUID is added here only after native CallKit registration succeeds.
|
|
725
|
+
if (!this.pendingPushCallUUIDs.has(callKitUUID)) {
|
|
726
|
+
console.log('CallKitCoordinator: Ignoring remote-ended event for unknown push UUID', {
|
|
727
|
+
callKitUUID,
|
|
728
|
+
reason,
|
|
729
|
+
});
|
|
730
|
+
return false;
|
|
731
|
+
}
|
|
732
|
+
console.log('CallKitCoordinator: Ending pending push call after remote hangup', {
|
|
733
|
+
callKitUUID,
|
|
734
|
+
reason,
|
|
735
|
+
});
|
|
736
|
+
if (!this.endedCalls.has(callKitUUID)) {
|
|
737
|
+
await callkit_1.default.reportCallEnded(callKitUUID, callkit_1.CallEndReason.RemoteEnded);
|
|
738
|
+
this.endedCalls.add(callKitUUID);
|
|
739
|
+
}
|
|
740
|
+
this.cleanupCall(callKitUUID);
|
|
741
|
+
await this.clearMatchingPendingVoipPush(callKitUUID);
|
|
742
|
+
await this.checkBackgroundDisconnection();
|
|
743
|
+
return true;
|
|
744
|
+
}
|
|
678
745
|
/**
|
|
679
746
|
* Handle push notification answer - when user answers from CallKit but we don't have a WebRTC call yet
|
|
680
747
|
* This is the iOS equivalent of the Android FCM handler
|
|
@@ -698,7 +765,7 @@ class CallKitCoordinator {
|
|
|
698
765
|
// checkForInitialPushNotification() will run after setVoipClient()
|
|
699
766
|
// and will find the push data still intact, call handleCallKitPushReceived()
|
|
700
767
|
// which queues the matching auto-answer.
|
|
701
|
-
return;
|
|
768
|
+
return false;
|
|
702
769
|
}
|
|
703
770
|
// Queue one UUID-targeted answer. If the push is already being
|
|
704
771
|
// processed, its eventual INVITE will consume this action. Otherwise
|
|
@@ -706,19 +773,20 @@ class CallKitCoordinator {
|
|
|
706
773
|
voipClient.queueAnswerFromCallKit(callKitUUID);
|
|
707
774
|
if (this.pendingPushCallUUIDs.has(callKitUUID)) {
|
|
708
775
|
this.autoAnswerCallUUIDs.delete(callKitUUID);
|
|
709
|
-
return;
|
|
776
|
+
return true;
|
|
710
777
|
}
|
|
711
|
-
await this.handleCallKitPushReceived(callKitUUID, event);
|
|
712
|
-
return;
|
|
778
|
+
return await this.handleCallKitPushReceived(callKitUUID, event);
|
|
713
779
|
}
|
|
714
780
|
// For other platforms (shouldn't happen on iOS)
|
|
715
781
|
console.error('CallKitCoordinator: ❌ Unsupported platform for push notification handling');
|
|
716
782
|
await callkit_1.default.reportCallEnded(callKitUUID, callkit_1.CallEndReason.Failed);
|
|
783
|
+
return false;
|
|
717
784
|
} catch (error) {
|
|
718
785
|
console.error('CallKitCoordinator: ❌ Error handling push notification answer:', error);
|
|
719
786
|
// Report the call as failed to CallKit
|
|
720
787
|
await callkit_1.default.reportCallEnded(callKitUUID, callkit_1.CallEndReason.Failed);
|
|
721
788
|
this.cleanupCall(callKitUUID);
|
|
789
|
+
return false;
|
|
722
790
|
}
|
|
723
791
|
}
|
|
724
792
|
/**
|
|
@@ -764,6 +832,18 @@ class CallKitCoordinator {
|
|
|
764
832
|
});
|
|
765
833
|
switch (state) {
|
|
766
834
|
case 'active':
|
|
835
|
+
if (!this.claimActiveCall(callKitUUID)) {
|
|
836
|
+
console.warn(
|
|
837
|
+
'CallKitCoordinator: Ending active call because another call owns the slot'
|
|
838
|
+
);
|
|
839
|
+
await callkit_1.default.reportCallEnded(callKitUUID, callkit_1.CallEndReason.Failed);
|
|
840
|
+
try {
|
|
841
|
+
await call.hangup();
|
|
842
|
+
} finally {
|
|
843
|
+
this.cleanupCall(callKitUUID);
|
|
844
|
+
}
|
|
845
|
+
break;
|
|
846
|
+
}
|
|
767
847
|
this.selectCall(call);
|
|
768
848
|
this.pendingPushCallUUIDs.delete(callKitUUID);
|
|
769
849
|
this.autoAnswerCallUUIDs.delete(callKitUUID);
|
|
@@ -838,6 +918,9 @@ class CallKitCoordinator {
|
|
|
838
918
|
this.connectedCalls.delete(callKitUUID);
|
|
839
919
|
this.pendingPushCallUUIDs.delete(callKitUUID);
|
|
840
920
|
this.autoAnswerCallUUIDs.delete(callKitUUID);
|
|
921
|
+
if (this.activeOrAnsweringCallUUID === callKitUUID) {
|
|
922
|
+
this.activeOrAnsweringCallUUID = null;
|
|
923
|
+
}
|
|
841
924
|
// Get the call before removing it
|
|
842
925
|
const call = this.callMap.get(callKitUUID);
|
|
843
926
|
// Clean up state listeners
|
|
@@ -967,6 +1050,12 @@ class CallKitCoordinator {
|
|
|
967
1050
|
actionKey(action, callKitUUID) {
|
|
968
1051
|
return `${action}:${this.normalizeUUID(callKitUUID)}`;
|
|
969
1052
|
}
|
|
1053
|
+
releaseActiveCall(callKitUUID) {
|
|
1054
|
+
callKitUUID = this.normalizeUUID(callKitUUID);
|
|
1055
|
+
if (this.activeOrAnsweringCallUUID === callKitUUID) {
|
|
1056
|
+
this.activeOrAnsweringCallUUID = null;
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
970
1059
|
selectCall(call) {
|
|
971
1060
|
const callKitUUID = this.getCallKitUUID(call);
|
|
972
1061
|
const wrapperCall = this.voipClient?.findCallByTelnyxCall(call);
|
|
@@ -1174,6 +1263,9 @@ class CallKitCoordinator {
|
|
|
1174
1263
|
resetFlags() {
|
|
1175
1264
|
console.log('CallKitCoordinator: Resetting coordinator flags');
|
|
1176
1265
|
this.isCallFromPush = this.pendingPushCallUUIDs.size > 0;
|
|
1266
|
+
if (this.callMap.size === 0) {
|
|
1267
|
+
this.activeOrAnsweringCallUUID = null;
|
|
1268
|
+
}
|
|
1177
1269
|
console.log('CallKitCoordinator: ✅ Coordinator flags reset');
|
|
1178
1270
|
}
|
|
1179
1271
|
/**
|
|
@@ -1187,6 +1279,14 @@ class CallKitCoordinator {
|
|
|
1187
1279
|
// call arrives during push notification handling.
|
|
1188
1280
|
return this.processingCalls.size > 0 || this.isCallFromPush;
|
|
1189
1281
|
}
|
|
1282
|
+
/** @internal Exposed for deterministic single-active-call regression tests. */
|
|
1283
|
+
claimActiveCallForTesting(callKitUUID) {
|
|
1284
|
+
return this.claimActiveCall(callKitUUID);
|
|
1285
|
+
}
|
|
1286
|
+
/** @internal Exposed for deterministic single-active-call regression tests. */
|
|
1287
|
+
releaseActiveCallForTesting(callKitUUID) {
|
|
1288
|
+
this.releaseActiveCall(callKitUUID);
|
|
1289
|
+
}
|
|
1190
1290
|
/**
|
|
1191
1291
|
* Check if there's currently a call from push notification being processed
|
|
1192
1292
|
* This helps prevent disconnection during push call handling
|
package/lib/index.d.ts
CHANGED
|
@@ -38,8 +38,11 @@ export {
|
|
|
38
38
|
createCredentialConfig,
|
|
39
39
|
createTokenConfig,
|
|
40
40
|
} from './models/config';
|
|
41
|
-
export type { Config, CredentialConfig, TokenConfig } from './models/config';
|
|
41
|
+
export type { Config, CredentialConfig, TokenConfig, TelnyxEnvironment } from './models/config';
|
|
42
|
+
export type { TelnyxIceServer } from '@telnyx/react-native-voice-sdk';
|
|
42
43
|
export type { Call as TelnyxCall } from '@telnyx/react-native-voice-sdk';
|
|
44
|
+
export { CallQualityLevel } from '@telnyx/react-native-voice-sdk';
|
|
45
|
+
export type { CallQualityMetrics, QualityStatsRecord } from '@telnyx/react-native-voice-sdk';
|
|
43
46
|
export * from './callkit';
|
|
44
47
|
export { VoicePnBridge } from './internal/voice-pn-bridge';
|
|
45
48
|
export { useAppReadyNotifier } from './hooks/useAppReadyNotifier';
|
package/lib/index.js
CHANGED
|
@@ -39,6 +39,7 @@ var __exportStar =
|
|
|
39
39
|
Object.defineProperty(exports, '__esModule', { value: true });
|
|
40
40
|
exports.useAppReadyNotifier =
|
|
41
41
|
exports.VoicePnBridge =
|
|
42
|
+
exports.CallQualityLevel =
|
|
42
43
|
exports.createTokenConfig =
|
|
43
44
|
exports.createCredentialConfig =
|
|
44
45
|
exports.validateTokenConfig =
|
|
@@ -214,6 +215,14 @@ Object.defineProperty(exports, 'createTokenConfig', {
|
|
|
214
215
|
return config_1.createTokenConfig;
|
|
215
216
|
},
|
|
216
217
|
});
|
|
218
|
+
// Re-export call quality metrics types from the underlying SDK
|
|
219
|
+
var react_native_voice_sdk_1 = require('@telnyx/react-native-voice-sdk');
|
|
220
|
+
Object.defineProperty(exports, 'CallQualityLevel', {
|
|
221
|
+
enumerable: true,
|
|
222
|
+
get: function () {
|
|
223
|
+
return react_native_voice_sdk_1.CallQualityLevel;
|
|
224
|
+
},
|
|
225
|
+
});
|
|
217
226
|
// Export CallKit functionality
|
|
218
227
|
__exportStar(require('./callkit'), exports);
|
|
219
228
|
// Native push notification helpers
|
|
@@ -122,6 +122,7 @@ export declare class CallStateController {
|
|
|
122
122
|
private _removeClientListeners;
|
|
123
123
|
private _handleTelnyxIncomingCall;
|
|
124
124
|
private _handleTelnyxReattachedCall;
|
|
125
|
+
private _handleRemoteCallEnded;
|
|
125
126
|
private _handleTelnyxCallStateChanged;
|
|
126
127
|
private _handleTelnyxCallRemoved;
|
|
127
128
|
/**
|