@telnyx/react-voice-commons-sdk 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +27 -1
  8. package/ios/CallKitBridge.swift +640 -125
  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 +7 -5
  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
@@ -11,8 +11,21 @@ interface CallKitBridgeInterface {
11
11
  handle: string,
12
12
  displayName: string
13
13
  ): Promise<{ success: boolean; callUUID: string }>;
14
+ isCallRegistered(callUUID: string): Promise<{ registered: boolean; callUUID: string }>;
14
15
  answerCall(callUUID: string): Promise<{ success: boolean; callUUID: string }>;
15
16
  endCall(callUUID: string): Promise<{ success: boolean; callUUID: string }>;
17
+ completeHeldCallAction(
18
+ callUUID: string,
19
+ success: boolean
20
+ ): Promise<{ success: boolean; callUUID: string }>;
21
+ setCallHeld(
22
+ callUUID: string,
23
+ isOnHold: boolean
24
+ ): Promise<{ success: boolean; callUUID: string; isOnHold: boolean }>;
25
+ swapCalls(
26
+ activeCallUUID: string,
27
+ heldCallUUID: string
28
+ ): Promise<{ success: boolean; activeCallUUID: string; heldCallUUID: string }>;
16
29
  reportCallConnected(callUUID: string): Promise<{ success: boolean }>;
17
30
  reportCallEnded(callUUID: string, reason: number): Promise<{ success: boolean }>;
18
31
  updateCall(callUUID: string, displayName: string, handle: string): Promise<{ success: boolean }>;
@@ -31,13 +44,14 @@ export enum CallEndReason {
31
44
  // CallKit event types
32
45
  export interface CallKitEvent {
33
46
  callUUID: string;
47
+ isOnHold?: boolean;
34
48
  [key: string]: any;
35
49
  }
36
50
 
37
51
  class CallKitManager {
38
52
  private bridge: CallKitBridgeInterface | null = null;
39
53
  private eventEmitter: NativeEventEmitter | null = null;
40
- private listeners: Map<string, (event: CallKitEvent) => void> = new Map();
54
+ private listeners: Map<string, Set<(event: CallKitEvent) => void>> = new Map();
41
55
 
42
56
  /**
43
57
  * Normalize UUID to lowercase for consistent handling in React Native
@@ -100,6 +114,12 @@ class CallKitManager {
100
114
  this.notifyListeners('endCall', normalizedEvent);
101
115
  });
102
116
 
117
+ this.eventEmitter.addListener('CallKitDidPerformHeldCallAction', (event) => {
118
+ const normalizedEvent = this.normalizeEvent(event);
119
+ console.log('CallKit: Received held call action', normalizedEvent);
120
+ this.notifyListeners('heldCall', normalizedEvent);
121
+ });
122
+
103
123
  this.eventEmitter.addListener('CallKitDidReceivePush', (event) => {
104
124
  const normalizedEvent = this.normalizeEvent(event);
105
125
  console.log('CallKit: Received push notification event', normalizedEvent);
@@ -108,9 +128,11 @@ class CallKitManager {
108
128
  }
109
129
 
110
130
  private notifyListeners(eventType: string, event: CallKitEvent) {
111
- const listener = this.listeners.get(eventType);
112
- if (listener) {
113
- listener(event);
131
+ const listeners = this.listeners.get(eventType);
132
+ if (listeners) {
133
+ for (const listener of listeners) {
134
+ listener(event);
135
+ }
114
136
  }
115
137
  }
116
138
 
@@ -189,6 +211,23 @@ class CallKitManager {
189
211
  }
190
212
  }
191
213
 
214
+ public async isCallRegistered(callUUID: string): Promise<boolean> {
215
+ if (!this.bridge || Platform.OS !== 'ios') {
216
+ return false;
217
+ }
218
+
219
+ try {
220
+ const result = await this.bridge.isCallRegistered(this.denormalizeUUID(callUUID));
221
+ return result.registered;
222
+ } catch (error) {
223
+ console.error('CallKit: Failed to check incoming call registration', {
224
+ callUUID,
225
+ error,
226
+ });
227
+ return false;
228
+ }
229
+ }
230
+
192
231
  public async endCall(callUUID: string): Promise<boolean> {
193
232
  if (!this.bridge || Platform.OS !== 'ios') {
194
233
  console.warn('CallKit: Not available on this platform');
@@ -208,6 +247,52 @@ class CallKitManager {
208
247
  }
209
248
  }
210
249
 
250
+ public async completeHeldCallAction(callUUID: string, success: boolean): Promise<boolean> {
251
+ if (!this.bridge || Platform.OS !== 'ios') {
252
+ return false;
253
+ }
254
+
255
+ try {
256
+ const uppercaseUUID = this.denormalizeUUID(callUUID);
257
+ const result = await this.bridge.completeHeldCallAction(uppercaseUUID, success);
258
+ return result.success;
259
+ } catch (error) {
260
+ console.error('CallKit: Failed to complete held call action', error);
261
+ return false;
262
+ }
263
+ }
264
+
265
+ public async setCallHeld(callUUID: string, isOnHold: boolean): Promise<boolean> {
266
+ if (!this.bridge || Platform.OS !== 'ios') {
267
+ return false;
268
+ }
269
+
270
+ try {
271
+ const result = await this.bridge.setCallHeld(this.denormalizeUUID(callUUID), isOnHold);
272
+ return result.success;
273
+ } catch (error) {
274
+ console.error('CallKit: Failed to change held state', { callUUID, isOnHold, error });
275
+ return false;
276
+ }
277
+ }
278
+
279
+ public async swapCalls(activeCallUUID: string, heldCallUUID: string): Promise<boolean> {
280
+ if (!this.bridge || Platform.OS !== 'ios') {
281
+ return false;
282
+ }
283
+
284
+ try {
285
+ const result = await this.bridge.swapCalls(
286
+ this.denormalizeUUID(activeCallUUID),
287
+ this.denormalizeUUID(heldCallUUID)
288
+ );
289
+ return result.success;
290
+ } catch (error) {
291
+ console.error('CallKit: Failed to swap calls', error);
292
+ return false;
293
+ }
294
+ }
295
+
211
296
  public async reportCallConnected(callUUID: string): Promise<boolean> {
212
297
  if (!this.bridge || Platform.OS !== 'ios') {
213
298
  return false;
@@ -283,23 +368,36 @@ class CallKitManager {
283
368
  // Event listener management
284
369
 
285
370
  public onStartCall(listener: (event: CallKitEvent) => void): () => void {
286
- this.listeners.set('startCall', listener);
287
- return () => this.listeners.delete('startCall');
371
+ return this.addListener('startCall', listener);
288
372
  }
289
373
 
290
374
  public onAnswerCall(listener: (event: CallKitEvent) => void): () => void {
291
- this.listeners.set('answerCall', listener);
292
- return () => this.listeners.delete('answerCall');
375
+ return this.addListener('answerCall', listener);
293
376
  }
294
377
 
295
378
  public onEndCall(listener: (event: CallKitEvent) => void): () => void {
296
- this.listeners.set('endCall', listener);
297
- return () => this.listeners.delete('endCall');
379
+ return this.addListener('endCall', listener);
380
+ }
381
+
382
+ public onHeldCall(listener: (event: CallKitEvent) => void): () => void {
383
+ return this.addListener('heldCall', listener);
298
384
  }
299
385
 
300
386
  public onReceivePush(listener: (event: CallKitEvent) => void): () => void {
301
- this.listeners.set('receivePush', listener);
302
- return () => this.listeners.delete('receivePush');
387
+ return this.addListener('receivePush', listener);
388
+ }
389
+
390
+ private addListener(eventType: string, listener: (event: CallKitEvent) => void): () => void {
391
+ const listeners = this.listeners.get(eventType) ?? new Set();
392
+ listeners.add(listener);
393
+ this.listeners.set(eventType, listeners);
394
+
395
+ return () => {
396
+ listeners.delete(listener);
397
+ if (listeners.size === 0) {
398
+ this.listeners.delete(eventType);
399
+ }
400
+ };
303
401
  }
304
402
 
305
403
  // Utility methods
@@ -34,8 +34,13 @@ export const useAppStateHandler = ({
34
34
  log('AppStateHandler: App went to background, checking for active calls...');
35
35
 
36
36
  const connectionState = voipClient.currentConnectionState;
37
- if (connectionState !== TelnyxConnectionState.CONNECTED) {
38
- log('AppStateHandler: Not connected, skipping background handling');
37
+ const shouldHandleBackgroundConnection =
38
+ connectionState === TelnyxConnectionState.CONNECTED ||
39
+ connectionState === TelnyxConnectionState.CONNECTING ||
40
+ connectionState === TelnyxConnectionState.RECONNECTING;
41
+
42
+ if (!shouldHandleBackgroundConnection) {
43
+ log('AppStateHandler: Not connected or connecting, skipping background handling');
39
44
  appState.current = nextAppState;
40
45
  return;
41
46
  }
@@ -1,10 +1,8 @@
1
1
  import React, { useEffect, useRef } from 'react';
2
2
  import { Platform, DeviceEventEmitter } from 'react-native';
3
3
  import AsyncStorage from '@react-native-async-storage/async-storage';
4
- import { useTelnyxVoice } from '../context/TelnyxVoiceContext';
5
- import { callKitCoordinator } from '../callkit';
6
4
 
7
- // Global flag to ensure only one CallKitHandler is active
5
+ // Global flag to ensure only one CallKitHandler is active.
8
6
  let isCallKitHandlerActive = false;
9
7
 
10
8
  interface CallData {
@@ -28,6 +26,9 @@ interface CallKitHandlerProps {
28
26
  * Internal CallKit handler for iOS push notifications
29
27
  * This component is automatically included in TelnyxVoiceApp
30
28
  *
29
+ * The active instance removes only its own DeviceEventEmitter subscriptions on
30
+ * unmount via each listener's `.remove()` method.
31
+ *
31
32
  * @internal - Users should not use this component directly
32
33
  */
33
34
  export const CallKitHandler: React.FC<CallKitHandlerProps> = ({
@@ -35,11 +36,65 @@ export const CallKitHandler: React.FC<CallKitHandlerProps> = ({
35
36
  onNavigateToDialer,
36
37
  onNavigateBack,
37
38
  }) => {
38
- const { voipClient } = useTelnyxVoice();
39
-
40
39
  // Store active calls by CallKit UUID for coordination
41
40
  const activeCallsRef = useRef<Map<string, any>>(new Map());
42
41
 
42
+ // DeviceEventEmitter listeners are registered once below, so read callbacks
43
+ // from refs that are updated during every render rather than stale closures.
44
+ const onLoginRequiredRef = useRef(onLoginRequired);
45
+ const onNavigateToDialerRef = useRef(onNavigateToDialer);
46
+ const onNavigateBackRef = useRef(onNavigateBack);
47
+
48
+ onLoginRequiredRef.current = onLoginRequired;
49
+ onNavigateToDialerRef.current = onNavigateToDialer;
50
+ onNavigateBackRef.current = onNavigateBack;
51
+
52
+ const handleIncomingCall = async (eventData: CallData) => {
53
+ console.log('CallKitHandler: Handling incoming call', {
54
+ callUUID: eventData.callUUID,
55
+ });
56
+
57
+ // Store the push notification payload
58
+ await AsyncStorage.setItem('@push_notification_payload', JSON.stringify(eventData.payload));
59
+
60
+ // Mark this call as being processed
61
+ activeCallsRef.current.set(eventData.callUUID, {
62
+ processing: true,
63
+ timestamp: Date.now(),
64
+ });
65
+
66
+ // Trigger login required callback if provided
67
+ if (onLoginRequiredRef.current) {
68
+ onLoginRequiredRef.current(eventData.payload);
69
+ }
70
+ };
71
+
72
+ const handleAnswerCall = async (eventData: CallData) => {
73
+ console.log('CallKitHandler: User answered call via CallKit', {
74
+ callUUID: eventData.callUUID,
75
+ isTrackedCall: activeCallsRef.current.has(eventData.callUUID),
76
+ });
77
+
78
+ if (onNavigateToDialerRef.current) {
79
+ onNavigateToDialerRef.current();
80
+ }
81
+ };
82
+
83
+ const handleEndCall = async (eventData: CallData) => {
84
+ console.log('CallKitHandler: User ended call via CallKit', {
85
+ callUUID: eventData.callUUID,
86
+ isTrackedCall: activeCallsRef.current.has(eventData.callUUID),
87
+ });
88
+
89
+ // Clean up our local tracking info
90
+ activeCallsRef.current.delete(eventData.callUUID);
91
+ await AsyncStorage.removeItem('@push_notification_payload');
92
+
93
+ if (onNavigateBackRef.current) {
94
+ onNavigateBackRef.current();
95
+ }
96
+ };
97
+
43
98
  useEffect(() => {
44
99
  if (Platform.OS !== 'ios') return;
45
100
 
@@ -51,9 +106,6 @@ export const CallKitHandler: React.FC<CallKitHandlerProps> = ({
51
106
  isCallKitHandlerActive = true;
52
107
  console.log('CallKitHandler: Setting up DeviceEventEmitter listeners (singleton instance)...');
53
108
 
54
- DeviceEventEmitter.removeAllListeners('incomingVoIPCall');
55
- DeviceEventEmitter.removeAllListeners('callKitAction');
56
-
57
109
  const incomingCallListener = DeviceEventEmitter.addListener(
58
110
  'incomingVoIPCall',
59
111
  async (eventData: CallData) => {
@@ -89,53 +141,6 @@ export const CallKitHandler: React.FC<CallKitHandlerProps> = ({
89
141
  };
90
142
  }, []);
91
143
 
92
- const handleIncomingCall = async (eventData: CallData) => {
93
- console.log('CallKitHandler: Handling incoming call', {
94
- callUUID: eventData.callUUID,
95
- hasClient: !!voipClient,
96
- });
97
-
98
- // Store the push notification payload
99
- await AsyncStorage.setItem('@push_notification_payload', JSON.stringify(eventData.payload));
100
-
101
- // Mark this call as being processed
102
- activeCallsRef.current.set(eventData.callUUID, {
103
- processing: true,
104
- timestamp: Date.now(),
105
- });
106
-
107
- // Trigger login required callback if provided
108
- if (onLoginRequired) {
109
- onLoginRequired(eventData.payload);
110
- }
111
- };
112
-
113
- const handleAnswerCall = async (eventData: CallData) => {
114
- console.log('CallKitHandler: User answered call via CallKit', {
115
- callUUID: eventData.callUUID,
116
- isTrackedCall: activeCallsRef.current.has(eventData.callUUID),
117
- });
118
-
119
- if (onNavigateToDialer) {
120
- onNavigateToDialer();
121
- }
122
- };
123
-
124
- const handleEndCall = async (eventData: CallData) => {
125
- console.log('CallKitHandler: User ended call via CallKit', {
126
- callUUID: eventData.callUUID,
127
- isTrackedCall: activeCallsRef.current.has(eventData.callUUID),
128
- });
129
-
130
- // Clean up our local tracking info
131
- activeCallsRef.current.delete(eventData.callUUID);
132
- await AsyncStorage.removeItem('@push_notification_payload');
133
-
134
- if (onNavigateBack) {
135
- onNavigateBack();
136
- }
137
- };
138
-
139
144
  // This component doesn't render anything, it just handles events
140
145
  return null;
141
146
  };
@@ -5,7 +5,9 @@ import { Call } from '../../models/call';
5
5
  import { TelnyxCallState } from '../../models/call-state';
6
6
  import { SessionManager } from '../session/session-manager';
7
7
  import { callKitCoordinator } from '../../callkit/callkit-coordinator';
8
- import { VoicePnBridge } from '../voice-pn-bridge';
8
+
9
+ export type CustomHeader = { name: string; value: string };
10
+ export type CustomHeaders = Record<string, string> | CustomHeader[];
9
11
 
10
12
  /**
11
13
  * Central state machine for call management.
@@ -23,6 +25,12 @@ export class CallStateController {
23
25
  private _isWaitingForInvite?: () => boolean;
24
26
  private _onInviteAutoAccepted?: () => void;
25
27
 
28
+ // Explicitly-tracked active call ID for multi-call scenarios.
29
+ // When set, activeCall$/currentActiveCall prefer this call over the
30
+ // first-match heuristic. Cleared automatically when the call reaches
31
+ // a terminal state, or manually via clearActiveCall().
32
+ private _activeCallId: string | null = null;
33
+
26
34
  constructor(private readonly _sessionManager: SessionManager) {
27
35
  console.log('CallStateController: Constructor called - instance created');
28
36
  // Don't set up client listeners here - client doesn't exist yet
@@ -42,18 +50,18 @@ export class CallStateController {
42
50
  get activeCall$(): Observable<Call | null> {
43
51
  return this.calls$.pipe(
44
52
  map((calls) => {
45
- // Find the first call that is not terminated (includes RINGING, CONNECTING, ACTIVE, HELD)
46
- return (
47
- calls.find(
48
- (call) =>
49
- call.currentState === TelnyxCallState.RINGING ||
50
- call.currentState === TelnyxCallState.CONNECTING ||
51
- call.currentState === TelnyxCallState.ACTIVE ||
52
- call.currentState === TelnyxCallState.HELD
53
- ) || null
54
- );
53
+ const call = this._selectActiveCall(calls);
54
+ return {
55
+ call,
56
+ callId: call?.callId ?? null,
57
+ state: call?.currentState ?? null,
58
+ };
55
59
  }),
56
- distinctUntilChanged()
60
+ distinctUntilChanged(
61
+ (previous, current) =>
62
+ previous.callId === current.callId && previous.state === current.state
63
+ ),
64
+ map(({ call }) => call)
57
65
  );
58
66
  }
59
67
 
@@ -68,16 +76,7 @@ export class CallStateController {
68
76
  * Current active call (synchronous access)
69
77
  */
70
78
  get currentActiveCall(): Call | null {
71
- const calls = this.currentCalls;
72
- return (
73
- calls.find(
74
- (call) =>
75
- call.currentState === TelnyxCallState.RINGING ||
76
- call.currentState === TelnyxCallState.CONNECTING ||
77
- call.currentState === TelnyxCallState.ACTIVE ||
78
- call.currentState === TelnyxCallState.HELD
79
- ) || null
80
- );
79
+ return this._selectActiveCall(this.currentCalls);
81
80
  }
82
81
 
83
82
  /**
@@ -106,6 +105,34 @@ export class CallStateController {
106
105
  }
107
106
  }
108
107
 
108
+ /**
109
+ * Explicitly set the active call for multi-call scenarios.
110
+ * When set, activeCall$ and currentActiveCall prefer this call over
111
+ * the first-match heuristic. The ID is cleared automatically when the
112
+ * call reaches a terminal state.
113
+ * @param callId The ID of the call to mark as active
114
+ */
115
+ setActiveCall(callId: string): void {
116
+ const call = this._callMap.get(callId);
117
+ if (call) {
118
+ this._activeCallId = callId;
119
+ this._calls.next([...this.currentCalls]);
120
+ } else {
121
+ console.warn('CallStateController: Cannot set active call - call not found:', callId);
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Clear the explicitly-tracked active call ID, reverting to the
127
+ * first-match heuristic for active call selection.
128
+ */
129
+ clearActiveCall(): void {
130
+ if (this._activeCallId !== null) {
131
+ this._activeCallId = null;
132
+ this._calls.next([...this.currentCalls]);
133
+ }
134
+ }
135
+
109
136
  /**
110
137
  * Find a call by its underlying Telnyx call ID
111
138
  * @param telnyxCall The Telnyx call object to find
@@ -119,6 +146,33 @@ export class CallStateController {
119
146
  return null;
120
147
  }
121
148
 
149
+ /**
150
+ * Select the active call, preferring the explicitly-tracked call ID
151
+ * over the first-match heuristic.
152
+ */
153
+ private _selectActiveCall(calls: Call[]): Call | null {
154
+ if (this._activeCallId) {
155
+ const tracked = calls.find((c) => c.callId === this._activeCallId);
156
+ if (tracked && this._isNonTerminal(tracked)) {
157
+ return tracked;
158
+ }
159
+ }
160
+ // Fall back to first non-terminal call (backward compatible)
161
+ return calls.find((c) => this._isNonTerminal(c)) || null;
162
+ }
163
+
164
+ /**
165
+ * Check if a call is in a non-terminal (active or connecting) state.
166
+ */
167
+ private _isNonTerminal(call: Call): boolean {
168
+ return (
169
+ call.currentState === TelnyxCallState.RINGING ||
170
+ call.currentState === TelnyxCallState.CONNECTING ||
171
+ call.currentState === TelnyxCallState.ACTIVE ||
172
+ call.currentState === TelnyxCallState.HELD
173
+ );
174
+ }
175
+
122
176
  /**
123
177
  * Initialize client listeners when the Telnyx client becomes available
124
178
  * This should be called by the session manager after client creation
@@ -139,7 +193,7 @@ export class CallStateController {
139
193
  destination: string,
140
194
  callerName?: string,
141
195
  callerNumber?: string,
142
- customHeaders?: Record<string, string>
196
+ customHeaders?: CustomHeaders
143
197
  ): Promise<Call> {
144
198
  if (this._disposed) {
145
199
  throw new Error('CallStateController has been disposed');
@@ -155,7 +209,7 @@ export class CallStateController {
155
209
  destinationNumber: destination,
156
210
  callerIdName: callerName,
157
211
  callerIdNumber: callerNumber,
158
- customHeaders,
212
+ customHeaders: this._normalizeCustomHeaders(customHeaders),
159
213
  peerConnectionOptions: {
160
214
  useTrickleIce: this._sessionManager.useTrickleIce,
161
215
  },
@@ -183,6 +237,21 @@ export class CallStateController {
183
237
  }
184
238
  }
185
239
 
240
+ /**
241
+ * Normalize public custom headers into the format expected by the underlying SDK.
242
+ */
243
+ private _normalizeCustomHeaders(customHeaders?: CustomHeaders): CustomHeader[] | undefined {
244
+ if (!customHeaders) {
245
+ return undefined;
246
+ }
247
+
248
+ if (Array.isArray(customHeaders)) {
249
+ return customHeaders;
250
+ }
251
+
252
+ return Object.entries(customHeaders).map(([name, value]) => ({ name, value }));
253
+ }
254
+
186
255
  /**
187
256
  * Set callbacks for waiting for invite logic (used for push notifications)
188
257
  */
@@ -217,6 +286,7 @@ export class CallStateController {
217
286
  }
218
287
  }
219
288
  this._callMap.clear();
289
+ this._activeCallId = null;
220
290
  this._calls.next([]);
221
291
  }
222
292
 
@@ -234,6 +304,7 @@ export class CallStateController {
234
304
  // Dispose of all calls
235
305
  this.currentCalls.forEach((call) => call.dispose());
236
306
  this._callMap.clear();
307
+ this._activeCallId = null;
237
308
 
238
309
  // CallKit cleanup is now handled by CallKitCoordinator automatically
239
310
 
@@ -415,6 +486,11 @@ export class CallStateController {
415
486
  private _addCall(call: Call): void {
416
487
  this._callMap.set(call.callId, call);
417
488
 
489
+ // Auto-track as active if no active call is currently set
490
+ if (this._activeCallId === null) {
491
+ this._activeCallId = call.callId;
492
+ }
493
+
418
494
  const currentCalls = this.currentCalls;
419
495
  currentCalls.push(call);
420
496
  this._calls.next([...currentCalls]);
@@ -429,9 +505,10 @@ export class CallStateController {
429
505
 
430
506
  if (existingCallKitUUID) {
431
507
  console.log(
432
- 'CallStateController: Call already has CallKit integration, skipping duplicate report:',
508
+ 'CallStateController: Linking push-created CallKit call to its signaling call:',
433
509
  existingCallKitUUID
434
510
  );
511
+ callKitCoordinator.linkExistingCallKitCall(telnyxCall, existingCallKitUUID);
435
512
  } else if (call.isIncoming) {
436
513
  // Handle incoming call with CallKit (only if not already integrated)
437
514
  console.log('CallStateController: Reporting incoming call to CallKitCoordinator');
@@ -448,13 +525,19 @@ export class CallStateController {
448
525
  // CallKitCoordinator automatically updates CallKit via setupWebRTCCallListeners
449
526
  console.log('CallStateController: Call state changed to:', state);
450
527
 
528
+ // Re-emit the call list so calls$/activeCall$ subscribers see state changes.
529
+ // distinctUntilChanged uses reference equality; without a new array
530
+ // reference, non-terminal transitions leave activeCall$ stale.
531
+ this._calls.next([...this.currentCalls]);
532
+
451
533
  // Clean up when call ends - delay to next tick so external subscribers
452
534
  // receive the ENDED/FAILED state before the call is disposed
453
535
  if (state === TelnyxCallState.ENDED || state === TelnyxCallState.FAILED) {
454
- // Clear pending push data so the next app launch isn't mistaken for a push launch
455
- VoicePnBridge.clearPendingVoipPush().catch((e) =>
456
- console.warn('CallStateController: Failed to clear pending voip push:', e)
457
- );
536
+ // Clear active call ID if the ending call was the tracked active call
537
+ if (this._activeCallId === call.callId) {
538
+ this._activeCallId = null;
539
+ }
540
+
458
541
  setTimeout(() => this._removeCall(call.callId), 0);
459
542
  }
460
543
  });