@telnyx/react-voice-commons-sdk 1.0.0 → 1.1.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +28 -7
  3. package/android/src/main/java/com/telnyx/react_voice_commons/CallForegroundService.kt +16 -2
  4. package/android/src/main/java/com/telnyx/react_voice_commons/TelnyxNotificationHelper.kt +345 -24
  5. package/android/src/main/java/com/telnyx/react_voice_commons/VoicePnBridgeModule.kt +19 -0
  6. package/android/src/main/java/com/telnyx/react_voice_commons/VoicePnManager.kt +19 -0
  7. package/ios/CallKitBridge.m +20 -1
  8. package/ios/CallKitBridge.swift +527 -78
  9. package/ios/README.md +2 -2
  10. package/ios/VoicePnBridge.m +6 -0
  11. package/ios/VoicePnBridge.swift +15 -0
  12. package/lib/callkit/callkit-coordinator.d.ts +39 -3
  13. package/lib/callkit/callkit-coordinator.js +561 -148
  14. package/lib/callkit/callkit.d.ts +7 -0
  15. package/lib/callkit/callkit.js +83 -11
  16. package/lib/hooks/useAppStateHandler.js +6 -2
  17. package/lib/internal/CallKitHandler.d.ts +3 -0
  18. package/lib/internal/CallKitHandler.js +52 -46
  19. package/lib/internal/calls/call-state-controller.d.ts +33 -1
  20. package/lib/internal/calls/call-state-controller.js +100 -28
  21. package/lib/internal/session/session-manager.d.ts +15 -1
  22. package/lib/internal/session/session-manager.js +214 -62
  23. package/lib/internal/voice-pn-bridge.d.ts +18 -0
  24. package/lib/internal/voice-pn-bridge.js +39 -0
  25. package/lib/models/call.js +35 -0
  26. package/lib/models/config.d.ts +10 -0
  27. package/lib/models/config.js +2 -0
  28. package/lib/telnyx-voice-app.js +85 -43
  29. package/lib/telnyx-voip-client.d.ts +46 -5
  30. package/lib/telnyx-voip-client.js +238 -22
  31. package/package.json +6 -4
  32. package/src/callkit/callkit-coordinator.ts +642 -160
  33. package/src/callkit/callkit.ts +110 -12
  34. package/src/hooks/useAppStateHandler.ts +7 -2
  35. package/src/internal/CallKitHandler.tsx +60 -55
  36. package/src/internal/calls/call-state-controller.ts +112 -29
  37. package/src/internal/session/session-manager.ts +260 -65
  38. package/src/internal/voice-pn-bridge.ts +47 -0
  39. package/src/models/call.ts +34 -0
  40. package/src/models/config.ts +10 -0
  41. package/src/telnyx-voice-app.tsx +96 -45
  42. package/src/telnyx-voip-client.ts +216 -24
  43. package/src/types/telnyx-sdk.d.ts +2 -1
@@ -7,6 +7,7 @@ export declare enum CallEndReason {
7
7
  }
8
8
  export interface CallKitEvent {
9
9
  callUUID: string;
10
+ isOnHold?: boolean;
10
11
  [key: string]: any;
11
12
  }
12
13
  declare class CallKitManager {
@@ -33,7 +34,11 @@ declare class CallKitManager {
33
34
  startOutgoingCall(callUUID: string, handle: string, displayName: string): Promise<boolean>;
34
35
  reportIncomingCall(callUUID: string, handle: string, displayName: string): Promise<boolean>;
35
36
  answerCall(callUUID: string): Promise<boolean>;
37
+ isCallRegistered(callUUID: string): Promise<boolean>;
36
38
  endCall(callUUID: string): Promise<boolean>;
39
+ completeHeldCallAction(callUUID: string, success: boolean): Promise<boolean>;
40
+ setCallHeld(callUUID: string, isOnHold: boolean): Promise<boolean>;
41
+ swapCalls(activeCallUUID: string, heldCallUUID: string): Promise<boolean>;
37
42
  reportCallConnected(callUUID: string): Promise<boolean>;
38
43
  reportCallEnded(callUUID: string, reason?: CallEndReason): Promise<boolean>;
39
44
  updateCall(callUUID: string, displayName: string, handle: string): Promise<boolean>;
@@ -41,7 +46,9 @@ declare class CallKitManager {
41
46
  onStartCall(listener: (event: CallKitEvent) => void): () => void;
42
47
  onAnswerCall(listener: (event: CallKitEvent) => void): () => void;
43
48
  onEndCall(listener: (event: CallKitEvent) => void): () => void;
49
+ onHeldCall(listener: (event: CallKitEvent) => void): () => void;
44
50
  onReceivePush(listener: (event: CallKitEvent) => void): () => void;
51
+ private addListener;
45
52
  generateCallUUID(): string;
46
53
  isAvailable(): boolean;
47
54
  }
@@ -68,6 +68,11 @@ class CallKitManager {
68
68
  console.log('CallKit: Received end call action', normalizedEvent);
69
69
  this.notifyListeners('endCall', normalizedEvent);
70
70
  });
71
+ this.eventEmitter.addListener('CallKitDidPerformHeldCallAction', (event) => {
72
+ const normalizedEvent = this.normalizeEvent(event);
73
+ console.log('CallKit: Received held call action', normalizedEvent);
74
+ this.notifyListeners('heldCall', normalizedEvent);
75
+ });
71
76
  this.eventEmitter.addListener('CallKitDidReceivePush', (event) => {
72
77
  const normalizedEvent = this.normalizeEvent(event);
73
78
  console.log('CallKit: Received push notification event', normalizedEvent);
@@ -75,9 +80,11 @@ class CallKitManager {
75
80
  });
76
81
  }
77
82
  notifyListeners(eventType, event) {
78
- const listener = this.listeners.get(eventType);
79
- if (listener) {
80
- listener(event);
83
+ const listeners = this.listeners.get(eventType);
84
+ if (listeners) {
85
+ for (const listener of listeners) {
86
+ listener(event);
87
+ }
81
88
  }
82
89
  }
83
90
  // Public API methods
@@ -140,6 +147,21 @@ class CallKitManager {
140
147
  return false;
141
148
  }
142
149
  }
150
+ async isCallRegistered(callUUID) {
151
+ if (!this.bridge || react_native_1.Platform.OS !== 'ios') {
152
+ return false;
153
+ }
154
+ try {
155
+ const result = await this.bridge.isCallRegistered(this.denormalizeUUID(callUUID));
156
+ return result.registered;
157
+ } catch (error) {
158
+ console.error('CallKit: Failed to check incoming call registration', {
159
+ callUUID,
160
+ error,
161
+ });
162
+ return false;
163
+ }
164
+ }
143
165
  async endCall(callUUID) {
144
166
  if (!this.bridge || react_native_1.Platform.OS !== 'ios') {
145
167
  console.warn('CallKit: Not available on this platform');
@@ -157,6 +179,46 @@ class CallKitManager {
157
179
  return false;
158
180
  }
159
181
  }
182
+ async completeHeldCallAction(callUUID, success) {
183
+ if (!this.bridge || react_native_1.Platform.OS !== 'ios') {
184
+ return false;
185
+ }
186
+ try {
187
+ const uppercaseUUID = this.denormalizeUUID(callUUID);
188
+ const result = await this.bridge.completeHeldCallAction(uppercaseUUID, success);
189
+ return result.success;
190
+ } catch (error) {
191
+ console.error('CallKit: Failed to complete held call action', error);
192
+ return false;
193
+ }
194
+ }
195
+ async setCallHeld(callUUID, isOnHold) {
196
+ if (!this.bridge || react_native_1.Platform.OS !== 'ios') {
197
+ return false;
198
+ }
199
+ try {
200
+ const result = await this.bridge.setCallHeld(this.denormalizeUUID(callUUID), isOnHold);
201
+ return result.success;
202
+ } catch (error) {
203
+ console.error('CallKit: Failed to change held state', { callUUID, isOnHold, error });
204
+ return false;
205
+ }
206
+ }
207
+ async swapCalls(activeCallUUID, heldCallUUID) {
208
+ if (!this.bridge || react_native_1.Platform.OS !== 'ios') {
209
+ return false;
210
+ }
211
+ try {
212
+ const result = await this.bridge.swapCalls(
213
+ this.denormalizeUUID(activeCallUUID),
214
+ this.denormalizeUUID(heldCallUUID)
215
+ );
216
+ return result.success;
217
+ } catch (error) {
218
+ console.error('CallKit: Failed to swap calls', error);
219
+ return false;
220
+ }
221
+ }
160
222
  async reportCallConnected(callUUID) {
161
223
  if (!this.bridge || react_native_1.Platform.OS !== 'ios') {
162
224
  return false;
@@ -220,20 +282,30 @@ class CallKitManager {
220
282
  }
221
283
  // Event listener management
222
284
  onStartCall(listener) {
223
- this.listeners.set('startCall', listener);
224
- return () => this.listeners.delete('startCall');
285
+ return this.addListener('startCall', listener);
225
286
  }
226
287
  onAnswerCall(listener) {
227
- this.listeners.set('answerCall', listener);
228
- return () => this.listeners.delete('answerCall');
288
+ return this.addListener('answerCall', listener);
229
289
  }
230
290
  onEndCall(listener) {
231
- this.listeners.set('endCall', listener);
232
- return () => this.listeners.delete('endCall');
291
+ return this.addListener('endCall', listener);
292
+ }
293
+ onHeldCall(listener) {
294
+ return this.addListener('heldCall', listener);
233
295
  }
234
296
  onReceivePush(listener) {
235
- this.listeners.set('receivePush', listener);
236
- return () => this.listeners.delete('receivePush');
297
+ return this.addListener('receivePush', listener);
298
+ }
299
+ addListener(eventType, listener) {
300
+ const listeners = this.listeners.get(eventType) ?? new Set();
301
+ listeners.add(listener);
302
+ this.listeners.set(eventType, listeners);
303
+ return () => {
304
+ listeners.delete(listener);
305
+ if (listeners.size === 0) {
306
+ this.listeners.delete(eventType);
307
+ }
308
+ };
237
309
  }
238
310
  // Utility methods
239
311
  generateCallUUID() {
@@ -30,8 +30,12 @@ const useAppStateHandler = ({
30
30
  if (appState.current.match(/active/) && nextAppState === 'background') {
31
31
  log('AppStateHandler: App went to background, checking for active calls...');
32
32
  const connectionState = voipClient.currentConnectionState;
33
- if (connectionState !== connection_state_1.TelnyxConnectionState.CONNECTED) {
34
- log('AppStateHandler: Not connected, skipping background handling');
33
+ const shouldHandleBackgroundConnection =
34
+ connectionState === connection_state_1.TelnyxConnectionState.CONNECTED ||
35
+ connectionState === connection_state_1.TelnyxConnectionState.CONNECTING ||
36
+ connectionState === connection_state_1.TelnyxConnectionState.RECONNECTING;
37
+ if (!shouldHandleBackgroundConnection) {
38
+ log('AppStateHandler: Not connected or connecting, skipping background handling');
35
39
  appState.current = nextAppState;
36
40
  return;
37
41
  }
@@ -11,6 +11,9 @@ interface CallKitHandlerProps {
11
11
  * Internal CallKit handler for iOS push notifications
12
12
  * This component is automatically included in TelnyxVoiceApp
13
13
  *
14
+ * The active instance removes only its own DeviceEventEmitter subscriptions on
15
+ * unmount via each listener's `.remove()` method.
16
+ *
14
17
  * @internal - Users should not use this component directly
15
18
  */
16
19
  export declare const CallKitHandler: React.FC<CallKitHandlerProps>;
@@ -9,19 +9,68 @@ exports.CallKitHandler = void 0;
9
9
  const react_1 = require('react');
10
10
  const react_native_1 = require('react-native');
11
11
  const async_storage_1 = __importDefault(require('@react-native-async-storage/async-storage'));
12
- const TelnyxVoiceContext_1 = require('../context/TelnyxVoiceContext');
13
- // Global flag to ensure only one CallKitHandler is active
12
+ // Global flag to ensure only one CallKitHandler is active.
14
13
  let isCallKitHandlerActive = false;
15
14
  /**
16
15
  * Internal CallKit handler for iOS push notifications
17
16
  * This component is automatically included in TelnyxVoiceApp
18
17
  *
18
+ * The active instance removes only its own DeviceEventEmitter subscriptions on
19
+ * unmount via each listener's `.remove()` method.
20
+ *
19
21
  * @internal - Users should not use this component directly
20
22
  */
21
23
  const CallKitHandler = ({ onLoginRequired, onNavigateToDialer, onNavigateBack }) => {
22
- const { voipClient } = (0, TelnyxVoiceContext_1.useTelnyxVoice)();
23
24
  // Store active calls by CallKit UUID for coordination
24
25
  const activeCallsRef = (0, react_1.useRef)(new Map());
26
+ // DeviceEventEmitter listeners are registered once below, so read callbacks
27
+ // from refs that are updated during every render rather than stale closures.
28
+ const onLoginRequiredRef = (0, react_1.useRef)(onLoginRequired);
29
+ const onNavigateToDialerRef = (0, react_1.useRef)(onNavigateToDialer);
30
+ const onNavigateBackRef = (0, react_1.useRef)(onNavigateBack);
31
+ onLoginRequiredRef.current = onLoginRequired;
32
+ onNavigateToDialerRef.current = onNavigateToDialer;
33
+ onNavigateBackRef.current = onNavigateBack;
34
+ const handleIncomingCall = async (eventData) => {
35
+ console.log('CallKitHandler: Handling incoming call', {
36
+ callUUID: eventData.callUUID,
37
+ });
38
+ // Store the push notification payload
39
+ await async_storage_1.default.setItem(
40
+ '@push_notification_payload',
41
+ JSON.stringify(eventData.payload)
42
+ );
43
+ // Mark this call as being processed
44
+ activeCallsRef.current.set(eventData.callUUID, {
45
+ processing: true,
46
+ timestamp: Date.now(),
47
+ });
48
+ // Trigger login required callback if provided
49
+ if (onLoginRequiredRef.current) {
50
+ onLoginRequiredRef.current(eventData.payload);
51
+ }
52
+ };
53
+ const handleAnswerCall = async (eventData) => {
54
+ console.log('CallKitHandler: User answered call via CallKit', {
55
+ callUUID: eventData.callUUID,
56
+ isTrackedCall: activeCallsRef.current.has(eventData.callUUID),
57
+ });
58
+ if (onNavigateToDialerRef.current) {
59
+ onNavigateToDialerRef.current();
60
+ }
61
+ };
62
+ const handleEndCall = async (eventData) => {
63
+ console.log('CallKitHandler: User ended call via CallKit', {
64
+ callUUID: eventData.callUUID,
65
+ isTrackedCall: activeCallsRef.current.has(eventData.callUUID),
66
+ });
67
+ // Clean up our local tracking info
68
+ activeCallsRef.current.delete(eventData.callUUID);
69
+ await async_storage_1.default.removeItem('@push_notification_payload');
70
+ if (onNavigateBackRef.current) {
71
+ onNavigateBackRef.current();
72
+ }
73
+ };
25
74
  (0, react_1.useEffect)(() => {
26
75
  if (react_native_1.Platform.OS !== 'ios') return;
27
76
  if (isCallKitHandlerActive) {
@@ -30,8 +79,6 @@ const CallKitHandler = ({ onLoginRequired, onNavigateToDialer, onNavigateBack })
30
79
  }
31
80
  isCallKitHandlerActive = true;
32
81
  console.log('CallKitHandler: Setting up DeviceEventEmitter listeners (singleton instance)...');
33
- react_native_1.DeviceEventEmitter.removeAllListeners('incomingVoIPCall');
34
- react_native_1.DeviceEventEmitter.removeAllListeners('callKitAction');
35
82
  const incomingCallListener = react_native_1.DeviceEventEmitter.addListener(
36
83
  'incomingVoIPCall',
37
84
  async (eventData) => {
@@ -63,47 +110,6 @@ const CallKitHandler = ({ onLoginRequired, onNavigateToDialer, onNavigateBack })
63
110
  isCallKitHandlerActive = false;
64
111
  };
65
112
  }, []);
66
- const handleIncomingCall = async (eventData) => {
67
- console.log('CallKitHandler: Handling incoming call', {
68
- callUUID: eventData.callUUID,
69
- hasClient: !!voipClient,
70
- });
71
- // Store the push notification payload
72
- await async_storage_1.default.setItem(
73
- '@push_notification_payload',
74
- JSON.stringify(eventData.payload)
75
- );
76
- // Mark this call as being processed
77
- activeCallsRef.current.set(eventData.callUUID, {
78
- processing: true,
79
- timestamp: Date.now(),
80
- });
81
- // Trigger login required callback if provided
82
- if (onLoginRequired) {
83
- onLoginRequired(eventData.payload);
84
- }
85
- };
86
- const handleAnswerCall = async (eventData) => {
87
- console.log('CallKitHandler: User answered call via CallKit', {
88
- callUUID: eventData.callUUID,
89
- isTrackedCall: activeCallsRef.current.has(eventData.callUUID),
90
- });
91
- if (onNavigateToDialer) {
92
- onNavigateToDialer();
93
- }
94
- };
95
- const handleEndCall = async (eventData) => {
96
- console.log('CallKitHandler: User ended call via CallKit', {
97
- callUUID: eventData.callUUID,
98
- isTrackedCall: activeCallsRef.current.has(eventData.callUUID),
99
- });
100
- // Clean up our local tracking info
101
- activeCallsRef.current.delete(eventData.callUUID);
102
- await async_storage_1.default.removeItem('@push_notification_payload');
103
- if (onNavigateBack) {
104
- onNavigateBack();
105
- }
106
- };
107
113
  // This component doesn't render anything, it just handles events
108
114
  return null;
109
115
  };
@@ -1,6 +1,11 @@
1
1
  import { Observable } from 'rxjs';
2
2
  import { Call } from '../../models/call';
3
3
  import { SessionManager } from '../session/session-manager';
4
+ export type CustomHeader = {
5
+ name: string;
6
+ value: string;
7
+ };
8
+ export type CustomHeaders = Record<string, string> | CustomHeader[];
4
9
  /**
5
10
  * Central state machine for call management.
6
11
  *
@@ -15,6 +20,7 @@ export declare class CallStateController {
15
20
  private _listenerClient?;
16
21
  private _isWaitingForInvite?;
17
22
  private _onInviteAutoAccepted?;
23
+ private _activeCallId;
18
24
  constructor(_sessionManager: SessionManager);
19
25
  /**
20
26
  * Observable stream of all current calls
@@ -46,11 +52,33 @@ export declare class CallStateController {
46
52
  * @param callId The ID of the call to set to connecting state
47
53
  */
48
54
  setCallConnecting(callId: string): void;
55
+ /**
56
+ * Explicitly set the active call for multi-call scenarios.
57
+ * When set, activeCall$ and currentActiveCall prefer this call over
58
+ * the first-match heuristic. The ID is cleared automatically when the
59
+ * call reaches a terminal state.
60
+ * @param callId The ID of the call to mark as active
61
+ */
62
+ setActiveCall(callId: string): void;
63
+ /**
64
+ * Clear the explicitly-tracked active call ID, reverting to the
65
+ * first-match heuristic for active call selection.
66
+ */
67
+ clearActiveCall(): void;
49
68
  /**
50
69
  * Find a call by its underlying Telnyx call ID
51
70
  * @param telnyxCall The Telnyx call object to find
52
71
  */
53
72
  findCallByTelnyxCall(telnyxCall: any): Call | null;
73
+ /**
74
+ * Select the active call, preferring the explicitly-tracked call ID
75
+ * over the first-match heuristic.
76
+ */
77
+ private _selectActiveCall;
78
+ /**
79
+ * Check if a call is in a non-terminal (active or connecting) state.
80
+ */
81
+ private _isNonTerminal;
54
82
  /**
55
83
  * Initialize client listeners when the Telnyx client becomes available
56
84
  * This should be called by the session manager after client creation
@@ -63,8 +91,12 @@ export declare class CallStateController {
63
91
  destination: string,
64
92
  callerName?: string,
65
93
  callerNumber?: string,
66
- customHeaders?: Record<string, string>
94
+ customHeaders?: CustomHeaders
67
95
  ): Promise<Call>;
96
+ /**
97
+ * Normalize public custom headers into the format expected by the underlying SDK.
98
+ */
99
+ private _normalizeCustomHeaders;
68
100
  /**
69
101
  * Set callbacks for waiting for invite logic (used for push notifications)
70
102
  */
@@ -6,7 +6,6 @@ const operators_1 = require('rxjs/operators');
6
6
  const call_1 = require('../../models/call');
7
7
  const call_state_1 = require('../../models/call-state');
8
8
  const callkit_coordinator_1 = require('../../callkit/callkit-coordinator');
9
- const voice_pn_bridge_1 = require('../voice-pn-bridge');
10
9
  /**
11
10
  * Central state machine for call management.
12
11
  *
@@ -19,6 +18,11 @@ class CallStateController {
19
18
  this._calls = new rxjs_1.BehaviorSubject([]);
20
19
  this._callMap = new Map();
21
20
  this._disposed = false;
21
+ // Explicitly-tracked active call ID for multi-call scenarios.
22
+ // When set, activeCall$/currentActiveCall prefer this call over the
23
+ // first-match heuristic. Cleared automatically when the call reaches
24
+ // a terminal state, or manually via clearActiveCall().
25
+ this._activeCallId = null;
22
26
  this._handleTelnyxIncomingCall = (telnyxCall, msg) => {
23
27
  console.log('CallStateController: Incoming call received:', telnyxCall.callId);
24
28
  this._handleIncomingCall(telnyxCall, msg, false);
@@ -62,18 +66,18 @@ class CallStateController {
62
66
  get activeCall$() {
63
67
  return this.calls$.pipe(
64
68
  (0, operators_1.map)((calls) => {
65
- // Find the first call that is not terminated (includes RINGING, CONNECTING, ACTIVE, HELD)
66
- return (
67
- calls.find(
68
- (call) =>
69
- call.currentState === call_state_1.TelnyxCallState.RINGING ||
70
- call.currentState === call_state_1.TelnyxCallState.CONNECTING ||
71
- call.currentState === call_state_1.TelnyxCallState.ACTIVE ||
72
- call.currentState === call_state_1.TelnyxCallState.HELD
73
- ) || null
74
- );
69
+ const call = this._selectActiveCall(calls);
70
+ return {
71
+ call,
72
+ callId: call?.callId ?? null,
73
+ state: call?.currentState ?? null,
74
+ };
75
75
  }),
76
- (0, operators_1.distinctUntilChanged)()
76
+ (0, operators_1.distinctUntilChanged)(
77
+ (previous, current) =>
78
+ previous.callId === current.callId && previous.state === current.state
79
+ ),
80
+ (0, operators_1.map)(({ call }) => call)
77
81
  );
78
82
  }
79
83
  /**
@@ -86,16 +90,7 @@ class CallStateController {
86
90
  * Current active call (synchronous access)
87
91
  */
88
92
  get currentActiveCall() {
89
- const calls = this.currentCalls;
90
- return (
91
- calls.find(
92
- (call) =>
93
- call.currentState === call_state_1.TelnyxCallState.RINGING ||
94
- call.currentState === call_state_1.TelnyxCallState.CONNECTING ||
95
- call.currentState === call_state_1.TelnyxCallState.ACTIVE ||
96
- call.currentState === call_state_1.TelnyxCallState.HELD
97
- ) || null
98
- );
93
+ return this._selectActiveCall(this.currentCalls);
99
94
  }
100
95
  /**
101
96
  * Access any active call tracked by the client.
@@ -121,6 +116,32 @@ class CallStateController {
121
116
  console.warn('CallStateController: Could not find call to set connecting:', callId);
122
117
  }
123
118
  }
119
+ /**
120
+ * Explicitly set the active call for multi-call scenarios.
121
+ * When set, activeCall$ and currentActiveCall prefer this call over
122
+ * the first-match heuristic. The ID is cleared automatically when the
123
+ * call reaches a terminal state.
124
+ * @param callId The ID of the call to mark as active
125
+ */
126
+ setActiveCall(callId) {
127
+ const call = this._callMap.get(callId);
128
+ if (call) {
129
+ this._activeCallId = callId;
130
+ this._calls.next([...this.currentCalls]);
131
+ } else {
132
+ console.warn('CallStateController: Cannot set active call - call not found:', callId);
133
+ }
134
+ }
135
+ /**
136
+ * Clear the explicitly-tracked active call ID, reverting to the
137
+ * first-match heuristic for active call selection.
138
+ */
139
+ clearActiveCall() {
140
+ if (this._activeCallId !== null) {
141
+ this._activeCallId = null;
142
+ this._calls.next([...this.currentCalls]);
143
+ }
144
+ }
124
145
  /**
125
146
  * Find a call by its underlying Telnyx call ID
126
147
  * @param telnyxCall The Telnyx call object to find
@@ -133,6 +154,31 @@ class CallStateController {
133
154
  }
134
155
  return null;
135
156
  }
157
+ /**
158
+ * Select the active call, preferring the explicitly-tracked call ID
159
+ * over the first-match heuristic.
160
+ */
161
+ _selectActiveCall(calls) {
162
+ if (this._activeCallId) {
163
+ const tracked = calls.find((c) => c.callId === this._activeCallId);
164
+ if (tracked && this._isNonTerminal(tracked)) {
165
+ return tracked;
166
+ }
167
+ }
168
+ // Fall back to first non-terminal call (backward compatible)
169
+ return calls.find((c) => this._isNonTerminal(c)) || null;
170
+ }
171
+ /**
172
+ * Check if a call is in a non-terminal (active or connecting) state.
173
+ */
174
+ _isNonTerminal(call) {
175
+ return (
176
+ call.currentState === call_state_1.TelnyxCallState.RINGING ||
177
+ call.currentState === call_state_1.TelnyxCallState.CONNECTING ||
178
+ call.currentState === call_state_1.TelnyxCallState.ACTIVE ||
179
+ call.currentState === call_state_1.TelnyxCallState.HELD
180
+ );
181
+ }
136
182
  /**
137
183
  * Initialize client listeners when the Telnyx client becomes available
138
184
  * This should be called by the session manager after client creation
@@ -160,7 +206,7 @@ class CallStateController {
160
206
  destinationNumber: destination,
161
207
  callerIdName: callerName,
162
208
  callerIdNumber: callerNumber,
163
- customHeaders,
209
+ customHeaders: this._normalizeCustomHeaders(customHeaders),
164
210
  peerConnectionOptions: {
165
211
  useTrickleIce: this._sessionManager.useTrickleIce,
166
212
  },
@@ -184,6 +230,18 @@ class CallStateController {
184
230
  throw error;
185
231
  }
186
232
  }
233
+ /**
234
+ * Normalize public custom headers into the format expected by the underlying SDK.
235
+ */
236
+ _normalizeCustomHeaders(customHeaders) {
237
+ if (!customHeaders) {
238
+ return undefined;
239
+ }
240
+ if (Array.isArray(customHeaders)) {
241
+ return customHeaders;
242
+ }
243
+ return Object.entries(customHeaders).map(([name, value]) => ({ name, value }));
244
+ }
187
245
  /**
188
246
  * Set callbacks for waiting for invite logic (used for push notifications)
189
247
  */
@@ -212,6 +270,7 @@ class CallStateController {
212
270
  }
213
271
  }
214
272
  this._callMap.clear();
273
+ this._activeCallId = null;
215
274
  this._calls.next([]);
216
275
  }
217
276
  /**
@@ -226,6 +285,7 @@ class CallStateController {
226
285
  // Dispose of all calls
227
286
  this.currentCalls.forEach((call) => call.dispose());
228
287
  this._callMap.clear();
288
+ this._activeCallId = null;
229
289
  // CallKit cleanup is now handled by CallKitCoordinator automatically
230
290
  this._calls.complete();
231
291
  }
@@ -346,6 +406,10 @@ class CallStateController {
346
406
  */
347
407
  _addCall(call) {
348
408
  this._callMap.set(call.callId, call);
409
+ // Auto-track as active if no active call is currently set
410
+ if (this._activeCallId === null) {
411
+ this._activeCallId = call.callId;
412
+ }
349
413
  const currentCalls = this.currentCalls;
350
414
  currentCalls.push(call);
351
415
  this._calls.next([...currentCalls]);
@@ -358,7 +422,11 @@ class CallStateController {
358
422
  callkit_coordinator_1.callKitCoordinator.getCallKitUUID(telnyxCall);
359
423
  if (existingCallKitUUID) {
360
424
  console.log(
361
- 'CallStateController: Call already has CallKit integration, skipping duplicate report:',
425
+ 'CallStateController: Linking push-created CallKit call to its signaling call:',
426
+ existingCallKitUUID
427
+ );
428
+ callkit_coordinator_1.callKitCoordinator.linkExistingCallKitCall(
429
+ telnyxCall,
362
430
  existingCallKitUUID
363
431
  );
364
432
  } else if (call.isIncoming) {
@@ -383,16 +451,20 @@ class CallStateController {
383
451
  call.callState$.subscribe((state) => {
384
452
  // CallKitCoordinator automatically updates CallKit via setupWebRTCCallListeners
385
453
  console.log('CallStateController: Call state changed to:', state);
454
+ // Re-emit the call list so calls$/activeCall$ subscribers see state changes.
455
+ // distinctUntilChanged uses reference equality; without a new array
456
+ // reference, non-terminal transitions leave activeCall$ stale.
457
+ this._calls.next([...this.currentCalls]);
386
458
  // Clean up when call ends - delay to next tick so external subscribers
387
459
  // receive the ENDED/FAILED state before the call is disposed
388
460
  if (
389
461
  state === call_state_1.TelnyxCallState.ENDED ||
390
462
  state === call_state_1.TelnyxCallState.FAILED
391
463
  ) {
392
- // Clear pending push data so the next app launch isn't mistaken for a push launch
393
- voice_pn_bridge_1.VoicePnBridge.clearPendingVoipPush().catch((e) =>
394
- console.warn('CallStateController: Failed to clear pending voip push:', e)
395
- );
464
+ // Clear active call ID if the ending call was the tracked active call
465
+ if (this._activeCallId === call.callId) {
466
+ this._activeCallId = null;
467
+ }
396
468
  setTimeout(() => this._removeCall(call.callId), 0);
397
469
  }
398
470
  });
@@ -14,6 +14,10 @@ export declare class SessionManager {
14
14
  private _currentConfig?;
15
15
  private _sessionId;
16
16
  private _disposed;
17
+ private _disposing;
18
+ private _connectionGeneration;
19
+ private _disposePromise?;
20
+ private _connectPromise?;
17
21
  private _onClientReady?;
18
22
  private _onDisconnect?;
19
23
  constructor();
@@ -76,14 +80,17 @@ export declare class SessionManager {
76
80
  * Handle push notification (async version)
77
81
  */
78
82
  handlePushNotification(payload: Record<string, any>): Promise<void>;
83
+ private _hasActiveOrHeldCall;
79
84
  /**
80
85
  * Dispose of the session manager and clean up resources
81
86
  */
82
- dispose(): void;
87
+ dispose(): Promise<void>;
83
88
  /**
84
89
  * Internal method to establish connection with or without push notification handling
85
90
  */
86
91
  private _connect;
92
+ private _dispose;
93
+ private _runConnect;
87
94
  /**
88
95
  * Set up event listeners for the Telnyx client
89
96
  */
@@ -96,4 +103,11 @@ export declare class SessionManager {
96
103
  * Generate a unique session ID
97
104
  */
98
105
  private _generateSessionId;
106
+ private _assertCanStartConnection;
107
+ private _isTeardownActive;
108
+ private _isConnectCanceled;
109
+ private _throwIfConnectCanceled;
110
+ private _createConnectCanceledError;
111
+ private _disconnectClient;
112
+ private _disconnectAndForgetClient;
99
113
  }