@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
|
@@ -61,11 +61,14 @@ const connection_state_1 = require('./models/connection-state');
|
|
|
61
61
|
const call_state_1 = require('./models/call-state');
|
|
62
62
|
const config_1 = require('./models/config');
|
|
63
63
|
const session_manager_1 = require('./internal/session/session-manager');
|
|
64
|
+
const signaling_environment_1 = require('./internal/signaling-environment');
|
|
64
65
|
const call_state_controller_1 = require('./internal/calls/call-state-controller');
|
|
65
66
|
const voice_pn_bridge_1 = require('./internal/voice-pn-bridge');
|
|
67
|
+
const ice_servers_storage_1 = require('./internal/ice-servers-storage');
|
|
66
68
|
const USE_TRICKLE_ICE_STORAGE_KEY = '@use_trickle_ice';
|
|
67
69
|
const PUSH_WHEN_ACTIVE_STORAGE_KEY = '@push_when_active';
|
|
68
70
|
const MISSED_CALL_NOTIFICATIONS_STORAGE_KEY = '@enable_missed_call_notifications';
|
|
71
|
+
const QUALITY_METRICS_STORAGE_KEY = '@enable_quality_metrics';
|
|
69
72
|
const LEGACY_CALLKIT_ANSWER_KEY = '__legacy_callkit_answer__';
|
|
70
73
|
/**
|
|
71
74
|
* The main public interface for the react-voice-commons module.
|
|
@@ -111,10 +114,13 @@ class TelnyxVoipClient {
|
|
|
111
114
|
enableAppStateManagement: true,
|
|
112
115
|
debug: false,
|
|
113
116
|
useTrickleIce: false,
|
|
117
|
+
environment: 'production',
|
|
114
118
|
...options,
|
|
115
119
|
};
|
|
116
120
|
// Initialize core components
|
|
117
|
-
this._sessionManager = new session_manager_1.SessionManager(
|
|
121
|
+
this._sessionManager = new session_manager_1.SessionManager({
|
|
122
|
+
defaultEnvironment: this._options.environment,
|
|
123
|
+
});
|
|
118
124
|
this._callStateController = new call_state_controller_1.CallStateController(
|
|
119
125
|
this._sessionManager
|
|
120
126
|
);
|
|
@@ -308,6 +314,7 @@ class TelnyxVoipClient {
|
|
|
308
314
|
const loginConfig = await this._withNativeVoipPushToken({
|
|
309
315
|
...config,
|
|
310
316
|
useTrickleIce: config.useTrickleIce ?? this._options.useTrickleIce,
|
|
317
|
+
environment: config.environment ?? this._options.environment,
|
|
311
318
|
});
|
|
312
319
|
await voice_pn_bridge_1.VoicePnBridge.setIncomingCallRingtone(loginConfig.incomingCallRingtone);
|
|
313
320
|
// Store credentials for future reconnection
|
|
@@ -335,6 +342,7 @@ class TelnyxVoipClient {
|
|
|
335
342
|
const loginConfig = await this._withNativeVoipPushToken({
|
|
336
343
|
...config,
|
|
337
344
|
useTrickleIce: config.useTrickleIce ?? this._options.useTrickleIce,
|
|
345
|
+
environment: config.environment ?? this._options.environment,
|
|
338
346
|
});
|
|
339
347
|
await voice_pn_bridge_1.VoicePnBridge.setIncomingCallRingtone(loginConfig.incomingCallRingtone);
|
|
340
348
|
// Store token for future reconnection
|
|
@@ -362,7 +370,7 @@ class TelnyxVoipClient {
|
|
|
362
370
|
* This method is used for auto-reconnection scenarios where the app
|
|
363
371
|
* comes back to the foreground and needs to restore the connection.
|
|
364
372
|
*
|
|
365
|
-
* @returns
|
|
373
|
+
* @returns Whether reconnection was successful.
|
|
366
374
|
*/
|
|
367
375
|
async loginFromStoredConfig() {
|
|
368
376
|
this._throwIfDisposed();
|
|
@@ -381,10 +389,22 @@ class TelnyxVoipClient {
|
|
|
381
389
|
const storedMissedCallNotifications = await AsyncStorage.getItem(
|
|
382
390
|
MISSED_CALL_NOTIFICATIONS_STORAGE_KEY
|
|
383
391
|
);
|
|
392
|
+
const storedEnvironment = await AsyncStorage.getItem(
|
|
393
|
+
signaling_environment_1.SIGNALING_ENVIRONMENT_STORAGE_KEY
|
|
394
|
+
);
|
|
395
|
+
const storedQualityMetrics = await AsyncStorage.getItem(QUALITY_METRICS_STORAGE_KEY);
|
|
396
|
+
const iceServers = (0, ice_servers_storage_1.parseStoredIceServers)(
|
|
397
|
+
await AsyncStorage.getItem(ice_servers_storage_1.ICE_SERVERS_STORAGE_KEY)
|
|
398
|
+
);
|
|
384
399
|
const useTrickleIce =
|
|
385
400
|
storedUseTrickleIce === null ? this._options.useTrickleIce : storedUseTrickleIce === 'true';
|
|
386
401
|
const pushWhenActive = storedPushWhenActive === 'true';
|
|
387
402
|
const enableMissedCallNotifications = storedMissedCallNotifications === 'true';
|
|
403
|
+
const enableQualityMetrics = storedQualityMetrics === 'true';
|
|
404
|
+
const environment = (0, signaling_environment_1.resolveStoredEnvironment)(
|
|
405
|
+
storedEnvironment,
|
|
406
|
+
this._options.environment
|
|
407
|
+
);
|
|
388
408
|
const nativePushToken =
|
|
389
409
|
react_native_1.Platform.OS === 'ios'
|
|
390
410
|
? (await voice_pn_bridge_1.VoicePnBridge.getVoipToken())?.trim()
|
|
@@ -402,6 +422,9 @@ class TelnyxVoipClient {
|
|
|
402
422
|
useTrickleIce,
|
|
403
423
|
pushWhenActive,
|
|
404
424
|
enableMissedCallNotifications,
|
|
425
|
+
enableQualityMetrics,
|
|
426
|
+
environment,
|
|
427
|
+
iceServers,
|
|
405
428
|
});
|
|
406
429
|
if (this._options.debug) {
|
|
407
430
|
console.log(
|
|
@@ -421,6 +444,9 @@ class TelnyxVoipClient {
|
|
|
421
444
|
useTrickleIce,
|
|
422
445
|
pushWhenActive,
|
|
423
446
|
enableMissedCallNotifications,
|
|
447
|
+
enableQualityMetrics,
|
|
448
|
+
environment,
|
|
449
|
+
iceServers,
|
|
424
450
|
});
|
|
425
451
|
if (this._options.debug) {
|
|
426
452
|
console.log('TelnyxVoipClient: Reconnecting with stored token');
|
|
@@ -682,6 +708,22 @@ class TelnyxVoipClient {
|
|
|
682
708
|
MISSED_CALL_NOTIFICATIONS_STORAGE_KEY,
|
|
683
709
|
String(config.enableMissedCallNotifications ?? false)
|
|
684
710
|
);
|
|
711
|
+
await AsyncStorage.setItem(
|
|
712
|
+
QUALITY_METRICS_STORAGE_KEY,
|
|
713
|
+
String(config.enableQualityMetrics ?? false)
|
|
714
|
+
);
|
|
715
|
+
await AsyncStorage.setItem(
|
|
716
|
+
signaling_environment_1.SIGNALING_ENVIRONMENT_STORAGE_KEY,
|
|
717
|
+
config.environment ?? this._options.environment ?? 'production'
|
|
718
|
+
);
|
|
719
|
+
if (config.iceServers) {
|
|
720
|
+
await AsyncStorage.setItem(
|
|
721
|
+
ice_servers_storage_1.ICE_SERVERS_STORAGE_KEY,
|
|
722
|
+
JSON.stringify(config.iceServers)
|
|
723
|
+
);
|
|
724
|
+
} else {
|
|
725
|
+
await AsyncStorage.removeItem(ice_servers_storage_1.ICE_SERVERS_STORAGE_KEY);
|
|
726
|
+
}
|
|
685
727
|
if (config.pushNotificationDeviceToken) {
|
|
686
728
|
await AsyncStorage.setItem('@push_token', config.pushNotificationDeviceToken);
|
|
687
729
|
}
|
|
@@ -716,6 +758,22 @@ class TelnyxVoipClient {
|
|
|
716
758
|
MISSED_CALL_NOTIFICATIONS_STORAGE_KEY,
|
|
717
759
|
String(config.enableMissedCallNotifications ?? false)
|
|
718
760
|
);
|
|
761
|
+
await AsyncStorage.setItem(
|
|
762
|
+
QUALITY_METRICS_STORAGE_KEY,
|
|
763
|
+
String(config.enableQualityMetrics ?? false)
|
|
764
|
+
);
|
|
765
|
+
await AsyncStorage.setItem(
|
|
766
|
+
signaling_environment_1.SIGNALING_ENVIRONMENT_STORAGE_KEY,
|
|
767
|
+
config.environment ?? this._options.environment ?? 'production'
|
|
768
|
+
);
|
|
769
|
+
if (config.iceServers) {
|
|
770
|
+
await AsyncStorage.setItem(
|
|
771
|
+
ice_servers_storage_1.ICE_SERVERS_STORAGE_KEY,
|
|
772
|
+
JSON.stringify(config.iceServers)
|
|
773
|
+
);
|
|
774
|
+
} else {
|
|
775
|
+
await AsyncStorage.removeItem(ice_servers_storage_1.ICE_SERVERS_STORAGE_KEY);
|
|
776
|
+
}
|
|
719
777
|
if (config.pushNotificationDeviceToken) {
|
|
720
778
|
await AsyncStorage.setItem('@push_token', config.pushNotificationDeviceToken);
|
|
721
779
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@telnyx/react-voice-commons-sdk",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
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",
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"@react-native-async-storage/async-storage": "^2.1.0",
|
|
69
69
|
"react": ">=19.0.0 <20.0.0",
|
|
70
70
|
"react-native": ">=0.79.0 <1.0.0",
|
|
71
|
-
"react-native-webrtc": "^
|
|
71
|
+
"react-native-webrtc": "^155.0.1"
|
|
72
72
|
},
|
|
73
73
|
"peerDependenciesMeta": {
|
|
74
74
|
"@react-native-async-storage/async-storage": {
|
|
@@ -86,9 +86,8 @@
|
|
|
86
86
|
},
|
|
87
87
|
"dependencies": {
|
|
88
88
|
"@react-native-community/eslint-config": "^3.2.0",
|
|
89
|
-
"@telnyx/react-native-voice-sdk": "
|
|
89
|
+
"@telnyx/react-native-voice-sdk": "^1.2.0",
|
|
90
90
|
"eventemitter3": "^5.0.1",
|
|
91
|
-
"expo": "~53.0.22",
|
|
92
91
|
"react-native-url-polyfill": "^3.0.0",
|
|
93
92
|
"react-native-voip-push-notification": "^3.3.3",
|
|
94
93
|
"rxjs": "^7.8.2"
|
|
@@ -4,6 +4,14 @@ import { Call } from '@telnyx/react-native-voice-sdk';
|
|
|
4
4
|
import { VoicePnBridge } from '../internal/voice-pn-bridge';
|
|
5
5
|
import type { TelnyxVoipClient } from '../telnyx-voip-client';
|
|
6
6
|
|
|
7
|
+
export type RemoteCallEndedReason = {
|
|
8
|
+
cause?: string;
|
|
9
|
+
causeCode?: number;
|
|
10
|
+
sipCode?: number;
|
|
11
|
+
sipReason?: string;
|
|
12
|
+
sipCallId?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
7
15
|
type PendingSwap = {
|
|
8
16
|
mode: 'swap' | 'restoring' | 'rollback';
|
|
9
17
|
originalActiveCallKitUUID: string;
|
|
@@ -37,6 +45,15 @@ class CallKitCoordinator {
|
|
|
37
45
|
// Tracks action+UUID pairs so an end action is not dropped merely because
|
|
38
46
|
// an answer or held action for the same call is still completing.
|
|
39
47
|
private processingCalls = new Set<string>();
|
|
48
|
+
private activeOrAnsweringCallUUID: string | null = null;
|
|
49
|
+
|
|
50
|
+
private claimActiveCall(callKitUUID: string): boolean {
|
|
51
|
+
if (this.activeOrAnsweringCallUUID !== null && this.activeOrAnsweringCallUUID !== callKitUUID) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
this.activeOrAnsweringCallUUID = callKitUUID;
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
40
57
|
|
|
41
58
|
// Tracks calls that have already been ended in CallKit to prevent duplicate reports
|
|
42
59
|
private endedCalls = new Set<string>();
|
|
@@ -184,6 +201,16 @@ class CallKitCoordinator {
|
|
|
184
201
|
);
|
|
185
202
|
|
|
186
203
|
if (success) {
|
|
204
|
+
if (!this.claimActiveCall(callKitUUID)) {
|
|
205
|
+
console.warn('CallKitCoordinator: Another call is already active or answering');
|
|
206
|
+
await CallKit.reportCallEnded(callKitUUID, CallEndReason.Failed);
|
|
207
|
+
try {
|
|
208
|
+
await call.hangup();
|
|
209
|
+
} finally {
|
|
210
|
+
this.cleanupCall(callKitUUID);
|
|
211
|
+
}
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
187
214
|
this.callMap.set(callKitUUID, call);
|
|
188
215
|
this.setupWebRTCCallListeners(call, callKitUUID);
|
|
189
216
|
(call as any)._callKitUUID = callKitUUID;
|
|
@@ -208,6 +235,11 @@ class CallKitCoordinator {
|
|
|
208
235
|
return false;
|
|
209
236
|
}
|
|
210
237
|
|
|
238
|
+
if (!this.claimActiveCall(callKitUUID)) {
|
|
239
|
+
console.warn('CallKitCoordinator: Another call is already active or answering');
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
|
|
211
243
|
console.log(
|
|
212
244
|
'CallKitCoordinator: Answering call from UI using CallKit answer simulation',
|
|
213
245
|
callKitUUID
|
|
@@ -240,6 +272,8 @@ class CallKitCoordinator {
|
|
|
240
272
|
this.isCallFromPush = this.pendingPushCallUUIDs.size > 0;
|
|
241
273
|
await this.clearMatchingPendingVoipPush(callKitUUID);
|
|
242
274
|
console.log('CallKitCoordinator: CallKit answer success');
|
|
275
|
+
} else {
|
|
276
|
+
this.releaseActiveCall(callKitUUID);
|
|
243
277
|
}
|
|
244
278
|
|
|
245
279
|
return success;
|
|
@@ -418,6 +452,22 @@ class CallKitCoordinator {
|
|
|
418
452
|
return;
|
|
419
453
|
}
|
|
420
454
|
|
|
455
|
+
if (!this.claimActiveCall(callKitUUID)) {
|
|
456
|
+
console.warn(
|
|
457
|
+
'CallKitCoordinator: Rejecting answer because another call owns the active slot'
|
|
458
|
+
);
|
|
459
|
+
await CallKit.reportCallEnded(callKitUUID, CallEndReason.Failed);
|
|
460
|
+
const rejectedCall = this.callMap.get(callKitUUID);
|
|
461
|
+
if (rejectedCall) {
|
|
462
|
+
try {
|
|
463
|
+
await rejectedCall.hangup();
|
|
464
|
+
} finally {
|
|
465
|
+
this.cleanupCall(callKitUUID);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
421
471
|
const call = this.callMap.get(callKitUUID);
|
|
422
472
|
|
|
423
473
|
if (!call) {
|
|
@@ -428,7 +478,10 @@ class CallKitCoordinator {
|
|
|
428
478
|
});
|
|
429
479
|
|
|
430
480
|
console.log('CallKitCoordinator: No WebRTC call found, handling as push notification');
|
|
431
|
-
await this.handlePushNotificationAnswer(callKitUUID, event);
|
|
481
|
+
const queued = await this.handlePushNotificationAnswer(callKitUUID, event);
|
|
482
|
+
if (!queued) {
|
|
483
|
+
this.releaseActiveCall(callKitUUID);
|
|
484
|
+
}
|
|
432
485
|
return;
|
|
433
486
|
}
|
|
434
487
|
|
|
@@ -744,11 +797,44 @@ class CallKitCoordinator {
|
|
|
744
797
|
}
|
|
745
798
|
}
|
|
746
799
|
|
|
800
|
+
/** End a still-pending PushKit CallKit call after a remote hangup. */
|
|
801
|
+
async handlePendingPushCallRemoteEnded(
|
|
802
|
+
callKitUUID: string,
|
|
803
|
+
reason?: RemoteCallEndedReason
|
|
804
|
+
): Promise<boolean> {
|
|
805
|
+
callKitUUID = this.normalizeUUID(callKitUUID);
|
|
806
|
+
|
|
807
|
+
// Do not let an unrelated/stale socket BYE terminate another CallKit call.
|
|
808
|
+
// The UUID is added here only after native CallKit registration succeeds.
|
|
809
|
+
if (!this.pendingPushCallUUIDs.has(callKitUUID)) {
|
|
810
|
+
console.log('CallKitCoordinator: Ignoring remote-ended event for unknown push UUID', {
|
|
811
|
+
callKitUUID,
|
|
812
|
+
reason,
|
|
813
|
+
});
|
|
814
|
+
return false;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
console.log('CallKitCoordinator: Ending pending push call after remote hangup', {
|
|
818
|
+
callKitUUID,
|
|
819
|
+
reason,
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
if (!this.endedCalls.has(callKitUUID)) {
|
|
823
|
+
await CallKit.reportCallEnded(callKitUUID, CallEndReason.RemoteEnded);
|
|
824
|
+
this.endedCalls.add(callKitUUID);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
this.cleanupCall(callKitUUID);
|
|
828
|
+
await this.clearMatchingPendingVoipPush(callKitUUID);
|
|
829
|
+
await this.checkBackgroundDisconnection();
|
|
830
|
+
return true;
|
|
831
|
+
}
|
|
832
|
+
|
|
747
833
|
/**
|
|
748
834
|
* Handle push notification answer - when user answers from CallKit but we don't have a WebRTC call yet
|
|
749
835
|
* This is the iOS equivalent of the Android FCM handler
|
|
750
836
|
*/
|
|
751
|
-
private async handlePushNotificationAnswer(callKitUUID: string, event?: any) {
|
|
837
|
+
private async handlePushNotificationAnswer(callKitUUID: string, event?: any): Promise<boolean> {
|
|
752
838
|
try {
|
|
753
839
|
console.log(
|
|
754
840
|
'CallKitCoordinator: Handling push notification answer for CallKit UUID:',
|
|
@@ -770,7 +856,7 @@ class CallKitCoordinator {
|
|
|
770
856
|
// checkForInitialPushNotification() will run after setVoipClient()
|
|
771
857
|
// and will find the push data still intact, call handleCallKitPushReceived()
|
|
772
858
|
// which queues the matching auto-answer.
|
|
773
|
-
return;
|
|
859
|
+
return false;
|
|
774
860
|
}
|
|
775
861
|
|
|
776
862
|
// Queue one UUID-targeted answer. If the push is already being
|
|
@@ -779,22 +865,22 @@ class CallKitCoordinator {
|
|
|
779
865
|
voipClient.queueAnswerFromCallKit(callKitUUID);
|
|
780
866
|
if (this.pendingPushCallUUIDs.has(callKitUUID)) {
|
|
781
867
|
this.autoAnswerCallUUIDs.delete(callKitUUID);
|
|
782
|
-
return;
|
|
868
|
+
return true;
|
|
783
869
|
}
|
|
784
870
|
|
|
785
|
-
await this.handleCallKitPushReceived(callKitUUID, event);
|
|
786
|
-
|
|
787
|
-
return;
|
|
871
|
+
return await this.handleCallKitPushReceived(callKitUUID, event);
|
|
788
872
|
}
|
|
789
873
|
|
|
790
874
|
// For other platforms (shouldn't happen on iOS)
|
|
791
875
|
console.error('CallKitCoordinator: ❌ Unsupported platform for push notification handling');
|
|
792
876
|
await CallKit.reportCallEnded(callKitUUID, CallEndReason.Failed);
|
|
877
|
+
return false;
|
|
793
878
|
} catch (error) {
|
|
794
879
|
console.error('CallKitCoordinator: ❌ Error handling push notification answer:', error);
|
|
795
880
|
// Report the call as failed to CallKit
|
|
796
881
|
await CallKit.reportCallEnded(callKitUUID, CallEndReason.Failed);
|
|
797
882
|
this.cleanupCall(callKitUUID);
|
|
883
|
+
return false;
|
|
798
884
|
}
|
|
799
885
|
}
|
|
800
886
|
|
|
@@ -849,6 +935,18 @@ class CallKitCoordinator {
|
|
|
849
935
|
|
|
850
936
|
switch (state) {
|
|
851
937
|
case 'active':
|
|
938
|
+
if (!this.claimActiveCall(callKitUUID)) {
|
|
939
|
+
console.warn(
|
|
940
|
+
'CallKitCoordinator: Ending active call because another call owns the slot'
|
|
941
|
+
);
|
|
942
|
+
await CallKit.reportCallEnded(callKitUUID, CallEndReason.Failed);
|
|
943
|
+
try {
|
|
944
|
+
await call.hangup();
|
|
945
|
+
} finally {
|
|
946
|
+
this.cleanupCall(callKitUUID);
|
|
947
|
+
}
|
|
948
|
+
break;
|
|
949
|
+
}
|
|
852
950
|
this.selectCall(call);
|
|
853
951
|
this.pendingPushCallUUIDs.delete(callKitUUID);
|
|
854
952
|
this.autoAnswerCallUUIDs.delete(callKitUUID);
|
|
@@ -927,6 +1025,9 @@ class CallKitCoordinator {
|
|
|
927
1025
|
this.connectedCalls.delete(callKitUUID);
|
|
928
1026
|
this.pendingPushCallUUIDs.delete(callKitUUID);
|
|
929
1027
|
this.autoAnswerCallUUIDs.delete(callKitUUID);
|
|
1028
|
+
if (this.activeOrAnsweringCallUUID === callKitUUID) {
|
|
1029
|
+
this.activeOrAnsweringCallUUID = null;
|
|
1030
|
+
}
|
|
930
1031
|
|
|
931
1032
|
// Get the call before removing it
|
|
932
1033
|
const call = this.callMap.get(callKitUUID);
|
|
@@ -1084,6 +1185,13 @@ class CallKitCoordinator {
|
|
|
1084
1185
|
return `${action}:${this.normalizeUUID(callKitUUID)}`;
|
|
1085
1186
|
}
|
|
1086
1187
|
|
|
1188
|
+
private releaseActiveCall(callKitUUID: string): void {
|
|
1189
|
+
callKitUUID = this.normalizeUUID(callKitUUID);
|
|
1190
|
+
if (this.activeOrAnsweringCallUUID === callKitUUID) {
|
|
1191
|
+
this.activeOrAnsweringCallUUID = null;
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1087
1195
|
private selectCall(call: Call): void {
|
|
1088
1196
|
const callKitUUID = this.getCallKitUUID(call);
|
|
1089
1197
|
const wrapperCall = this.voipClient?.findCallByTelnyxCall(call);
|
|
@@ -1322,6 +1430,10 @@ class CallKitCoordinator {
|
|
|
1322
1430
|
|
|
1323
1431
|
this.isCallFromPush = this.pendingPushCallUUIDs.size > 0;
|
|
1324
1432
|
|
|
1433
|
+
if (this.callMap.size === 0) {
|
|
1434
|
+
this.activeOrAnsweringCallUUID = null;
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1325
1437
|
console.log('CallKitCoordinator: ✅ Coordinator flags reset');
|
|
1326
1438
|
}
|
|
1327
1439
|
|
|
@@ -1337,6 +1449,16 @@ class CallKitCoordinator {
|
|
|
1337
1449
|
return this.processingCalls.size > 0 || this.isCallFromPush;
|
|
1338
1450
|
}
|
|
1339
1451
|
|
|
1452
|
+
/** @internal Exposed for deterministic single-active-call regression tests. */
|
|
1453
|
+
claimActiveCallForTesting(callKitUUID: string): boolean {
|
|
1454
|
+
return this.claimActiveCall(callKitUUID);
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
/** @internal Exposed for deterministic single-active-call regression tests. */
|
|
1458
|
+
releaseActiveCallForTesting(callKitUUID: string): void {
|
|
1459
|
+
this.releaseActiveCall(callKitUUID);
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1340
1462
|
/**
|
|
1341
1463
|
* Check if there's currently a call from push notification being processed
|
|
1342
1464
|
* This helps prevent disconnection during push call handling
|
package/src/index.ts
CHANGED
|
@@ -47,11 +47,16 @@ export {
|
|
|
47
47
|
createCredentialConfig,
|
|
48
48
|
createTokenConfig,
|
|
49
49
|
} from './models/config';
|
|
50
|
-
export type { Config, CredentialConfig, TokenConfig } from './models/config';
|
|
50
|
+
export type { Config, CredentialConfig, TokenConfig, TelnyxEnvironment } from './models/config';
|
|
51
|
+
export type { TelnyxIceServer } from '@telnyx/react-native-voice-sdk';
|
|
51
52
|
|
|
52
53
|
// Re-export useful types from the underlying SDK
|
|
53
54
|
export type { Call as TelnyxCall } from '@telnyx/react-native-voice-sdk';
|
|
54
55
|
|
|
56
|
+
// Re-export call quality metrics types from the underlying SDK
|
|
57
|
+
export { CallQualityLevel } from '@telnyx/react-native-voice-sdk';
|
|
58
|
+
export type { CallQualityMetrics, QualityStatsRecord } from '@telnyx/react-native-voice-sdk';
|
|
59
|
+
|
|
55
60
|
// Export CallKit functionality
|
|
56
61
|
export * from './callkit';
|
|
57
62
|
|
|
@@ -4,7 +4,7 @@ import { TelnyxRTC, Call as TelnyxCall, type CallOptions } from '@telnyx/react-n
|
|
|
4
4
|
import { Call } from '../../models/call';
|
|
5
5
|
import { TelnyxCallState } from '../../models/call-state';
|
|
6
6
|
import { SessionManager } from '../session/session-manager';
|
|
7
|
-
import { callKitCoordinator } from '../../callkit/callkit-coordinator';
|
|
7
|
+
import { callKitCoordinator, type RemoteCallEndedReason } from '../../callkit/callkit-coordinator';
|
|
8
8
|
|
|
9
9
|
export type CustomHeader = { name: string; value: string };
|
|
10
10
|
export type CustomHeaders = Record<string, string> | CustomHeader[];
|
|
@@ -178,8 +178,28 @@ export class CallStateController {
|
|
|
178
178
|
* This should be called by the session manager after client creation
|
|
179
179
|
*/
|
|
180
180
|
initializeClientListeners(): void {
|
|
181
|
+
const telnyxClient = this._sessionManager.telnyxClient;
|
|
182
|
+
|
|
181
183
|
console.log('CallStateController: initializeClientListeners called');
|
|
182
|
-
console.log('CallStateController: Current client exists:', !!
|
|
184
|
+
console.log('CallStateController: Current client exists:', !!telnyxClient);
|
|
185
|
+
|
|
186
|
+
if (!telnyxClient) {
|
|
187
|
+
console.log('CallStateController: No telnyxClient available, skipping listener setup');
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Skip redundant listener setup — SessionManager calls _onClientReady
|
|
192
|
+
// twice during connect/reconnect (once before connection, once on
|
|
193
|
+
// telnyx.client.ready event). _setupClientListeners() already
|
|
194
|
+
// removes old listeners before re-adding, so this guard skips the
|
|
195
|
+
// redundant off→on churn rather than preventing actual duplicates.
|
|
196
|
+
if (this._listenerClient === telnyxClient) {
|
|
197
|
+
console.log(
|
|
198
|
+
'CallStateController: Listeners already registered for this client instance, skipping'
|
|
199
|
+
);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
183
203
|
this._setupClientListeners();
|
|
184
204
|
|
|
185
205
|
// CallKit integration now handled by CallKitCoordinator
|
|
@@ -352,6 +372,11 @@ export class CallStateController {
|
|
|
352
372
|
// Listen for call removal events from TelnyxRTC (multi-call support)
|
|
353
373
|
telnyxClient.on('telnyx.call.removed', this._handleTelnyxCallRemoved);
|
|
354
374
|
|
|
375
|
+
// A caller can hang up while PushKit is reconnecting the socket, before
|
|
376
|
+
// an INVITE creates a Call object. Forward that terminal event so the
|
|
377
|
+
// already-presented CallKit call is dismissed.
|
|
378
|
+
telnyxClient.on('telnyx.call.remoteEnded', this._handleRemoteCallEnded);
|
|
379
|
+
|
|
355
380
|
console.log('CallStateController: Client listeners set up successfully');
|
|
356
381
|
}
|
|
357
382
|
|
|
@@ -364,6 +389,7 @@ export class CallStateController {
|
|
|
364
389
|
this._listenerClient.off('telnyx.call.reattached', this._handleTelnyxReattachedCall);
|
|
365
390
|
this._listenerClient.off('telnyx.call.stateChanged', this._handleTelnyxCallStateChanged);
|
|
366
391
|
this._listenerClient.off('telnyx.call.removed', this._handleTelnyxCallRemoved);
|
|
392
|
+
this._listenerClient.off('telnyx.call.remoteEnded', this._handleRemoteCallEnded);
|
|
367
393
|
this._listenerClient = undefined;
|
|
368
394
|
}
|
|
369
395
|
|
|
@@ -377,6 +403,19 @@ export class CallStateController {
|
|
|
377
403
|
this._handleIncomingCall(telnyxCall, msg, true);
|
|
378
404
|
};
|
|
379
405
|
|
|
406
|
+
private _handleRemoteCallEnded = (callId: string, reason: RemoteCallEndedReason): void => {
|
|
407
|
+
console.log('CallStateController: Pending push call ended remotely', {
|
|
408
|
+
callId,
|
|
409
|
+
reason,
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
// A tracked call can also reach the existing state-listener teardown path.
|
|
413
|
+
// Both paths are intentionally idempotent so pre-INVITE calls are covered too.
|
|
414
|
+
void callKitCoordinator.handlePendingPushCallRemoteEnded(callId, reason).catch((error) => {
|
|
415
|
+
console.error('CallStateController: Failed to end pending push CallKit call:', error);
|
|
416
|
+
});
|
|
417
|
+
};
|
|
418
|
+
|
|
380
419
|
private _handleTelnyxCallStateChanged = (telnyxCall: TelnyxCall, state: string): void => {
|
|
381
420
|
console.log(
|
|
382
421
|
'CallStateController: Call state changed from TelnyxRTC:',
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { TelnyxIceServer } from '@telnyx/react-native-voice-sdk';
|
|
2
|
+
|
|
3
|
+
export const ICE_SERVERS_STORAGE_KEY = '@ice_servers';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Parse the ICE servers persisted by a previous login. A missing, malformed,
|
|
7
|
+
* or structurally invalid value yields `undefined` (the core SDK then uses the
|
|
8
|
+
* Telnyx defaults) rather than throwing, so a bad stored list never discards
|
|
9
|
+
* otherwise valid stored authentication.
|
|
10
|
+
*/
|
|
11
|
+
export function parseStoredIceServers(stored: string | null): TelnyxIceServer[] | undefined {
|
|
12
|
+
if (!stored) {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
const parsed: unknown = JSON.parse(stored);
|
|
17
|
+
if (Array.isArray(parsed) && parsed.length > 0 && parsed.every(isIceServer)) {
|
|
18
|
+
return parsed;
|
|
19
|
+
}
|
|
20
|
+
} catch {
|
|
21
|
+
// Fall through to the warning below.
|
|
22
|
+
}
|
|
23
|
+
console.warn('Ignoring invalid stored ICE servers; using the SDK defaults');
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isIceServer(value: unknown): value is TelnyxIceServer {
|
|
28
|
+
if (!value || typeof value !== 'object') {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
const server = value as Record<string, unknown>;
|
|
32
|
+
return (
|
|
33
|
+
typeof server.urls === 'string' &&
|
|
34
|
+
(server.username === undefined || typeof server.username === 'string') &&
|
|
35
|
+
(server.credential === undefined || typeof server.credential === 'string')
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import { BehaviorSubject, Observable } from 'rxjs';
|
|
2
2
|
import { distinctUntilChanged } from 'rxjs/operators';
|
|
3
3
|
import * as TelnyxSDK from '@telnyx/react-native-voice-sdk';
|
|
4
|
-
import
|
|
4
|
+
import { REACT_NATIVE_COMMONS_SDK_IDENTITY } from '../sdk-identity';
|
|
5
|
+
import { ICE_SERVERS_STORAGE_KEY, parseStoredIceServers } from '../ice-servers-storage';
|
|
6
|
+
import {
|
|
7
|
+
SIGNALING_ENVIRONMENT_STORAGE_KEY,
|
|
8
|
+
resolveStoredEnvironment,
|
|
9
|
+
} from '../signaling-environment';
|
|
5
10
|
import { TelnyxConnectionState } from '../../models/connection-state';
|
|
6
11
|
import {
|
|
7
12
|
Config,
|
|
8
13
|
CredentialConfig,
|
|
14
|
+
TelnyxEnvironment,
|
|
9
15
|
TokenConfig,
|
|
10
16
|
isCredentialConfig,
|
|
11
17
|
isTokenConfig,
|
|
@@ -14,6 +20,12 @@ import {
|
|
|
14
20
|
const USE_TRICKLE_ICE_STORAGE_KEY = '@use_trickle_ice';
|
|
15
21
|
const PUSH_WHEN_ACTIVE_STORAGE_KEY = '@push_when_active';
|
|
16
22
|
const MISSED_CALL_NOTIFICATIONS_STORAGE_KEY = '@enable_missed_call_notifications';
|
|
23
|
+
const QUALITY_METRICS_STORAGE_KEY = '@enable_quality_metrics';
|
|
24
|
+
|
|
25
|
+
export interface SessionManagerOptions {
|
|
26
|
+
/** Environment used when a config or stored restore does not specify one */
|
|
27
|
+
defaultEnvironment?: TelnyxEnvironment;
|
|
28
|
+
}
|
|
17
29
|
|
|
18
30
|
/**
|
|
19
31
|
* Manages the connection lifecycle to the Telnyx platform.
|
|
@@ -35,9 +47,11 @@ export class SessionManager {
|
|
|
35
47
|
private _connectPromise?: Promise<void>;
|
|
36
48
|
private _onClientReady?: () => void;
|
|
37
49
|
private _onDisconnect?: () => void;
|
|
50
|
+
private readonly _defaultEnvironment: TelnyxEnvironment;
|
|
38
51
|
|
|
39
|
-
constructor() {
|
|
52
|
+
constructor(options: SessionManagerOptions = {}) {
|
|
40
53
|
this._sessionId = this._generateSessionId();
|
|
54
|
+
this._defaultEnvironment = options.defaultEnvironment ?? 'production';
|
|
41
55
|
}
|
|
42
56
|
|
|
43
57
|
/**
|
|
@@ -243,9 +257,15 @@ export class SessionManager {
|
|
|
243
257
|
const storedMissedCallNotifications = await AsyncStorage.getItem(
|
|
244
258
|
MISSED_CALL_NOTIFICATIONS_STORAGE_KEY
|
|
245
259
|
);
|
|
260
|
+
const storedEnvironment = await AsyncStorage.getItem(SIGNALING_ENVIRONMENT_STORAGE_KEY);
|
|
261
|
+
const storedQualityMetrics = await AsyncStorage.getItem(QUALITY_METRICS_STORAGE_KEY);
|
|
262
|
+
const storedIceServers = await AsyncStorage.getItem(ICE_SERVERS_STORAGE_KEY);
|
|
263
|
+
const iceServers = parseStoredIceServers(storedIceServers);
|
|
246
264
|
const useTrickleIce = storedUseTrickleIce === 'true';
|
|
247
265
|
const pushWhenActive = storedPushWhenActive === 'true';
|
|
248
266
|
const enableMissedCallNotifications = storedMissedCallNotifications === 'true';
|
|
267
|
+
const enableQualityMetrics = storedQualityMetrics === 'true';
|
|
268
|
+
const environment = resolveStoredEnvironment(storedEnvironment, this._defaultEnvironment);
|
|
249
269
|
|
|
250
270
|
if (this._isTeardownActive()) {
|
|
251
271
|
return;
|
|
@@ -260,6 +280,9 @@ export class SessionManager {
|
|
|
260
280
|
useTrickleIce,
|
|
261
281
|
pushWhenActive,
|
|
262
282
|
enableMissedCallNotifications,
|
|
283
|
+
enableQualityMetrics,
|
|
284
|
+
environment,
|
|
285
|
+
iceServers,
|
|
263
286
|
});
|
|
264
287
|
}
|
|
265
288
|
// Check if we have token-based authentication data
|
|
@@ -271,6 +294,9 @@ export class SessionManager {
|
|
|
271
294
|
useTrickleIce,
|
|
272
295
|
pushWhenActive,
|
|
273
296
|
enableMissedCallNotifications,
|
|
297
|
+
enableQualityMetrics,
|
|
298
|
+
environment,
|
|
299
|
+
iceServers,
|
|
274
300
|
});
|
|
275
301
|
}
|
|
276
302
|
|
|
@@ -485,11 +511,15 @@ export class SessionManager {
|
|
|
485
511
|
pushWhenActive: config.pushWhenActive,
|
|
486
512
|
enableMissedCallNotifications: config.enableMissedCallNotifications ?? false,
|
|
487
513
|
useTrickleIce: config.useTrickleIce,
|
|
514
|
+
environment: config.environment ?? this._defaultEnvironment,
|
|
488
515
|
enableCallReports: config.enableCallReports,
|
|
516
|
+
enableQualityMetrics: config.enableQualityMetrics,
|
|
489
517
|
callReportInterval: config.callReportInterval,
|
|
490
518
|
callReportLogLevel: config.callReportLogLevel,
|
|
491
519
|
callReportMaxLogEntries: config.callReportMaxLogEntries,
|
|
492
|
-
|
|
520
|
+
iceServers: config.iceServers,
|
|
521
|
+
sdkName: REACT_NATIVE_COMMONS_SDK_IDENTITY.name,
|
|
522
|
+
sdkVersion: REACT_NATIVE_COMMONS_SDK_IDENTITY.version,
|
|
493
523
|
};
|
|
494
524
|
console.log(
|
|
495
525
|
'🔧 SessionManager: Creating TelnyxRTC with credential config, logLevel:',
|
|
@@ -506,11 +536,15 @@ export class SessionManager {
|
|
|
506
536
|
pushWhenActive: config.pushWhenActive,
|
|
507
537
|
enableMissedCallNotifications: config.enableMissedCallNotifications ?? false,
|
|
508
538
|
useTrickleIce: config.useTrickleIce,
|
|
539
|
+
environment: config.environment ?? this._defaultEnvironment,
|
|
509
540
|
enableCallReports: config.enableCallReports,
|
|
541
|
+
enableQualityMetrics: config.enableQualityMetrics,
|
|
510
542
|
callReportInterval: config.callReportInterval,
|
|
511
543
|
callReportLogLevel: config.callReportLogLevel,
|
|
512
544
|
callReportMaxLogEntries: config.callReportMaxLogEntries,
|
|
513
|
-
|
|
545
|
+
iceServers: config.iceServers,
|
|
546
|
+
sdkName: REACT_NATIVE_COMMONS_SDK_IDENTITY.name,
|
|
547
|
+
sdkVersion: REACT_NATIVE_COMMONS_SDK_IDENTITY.version,
|
|
514
548
|
};
|
|
515
549
|
console.log(
|
|
516
550
|
'🔧 SessionManager: Creating TelnyxRTC with token config, logLevel:',
|