@telnyx/react-voice-commons-sdk 0.4.3 → 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 (47) 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/TelnyxFirebaseMessagingService.kt +16 -2
  5. package/android/src/main/java/com/telnyx/react_voice_commons/TelnyxNotificationHelper.kt +345 -24
  6. package/android/src/main/java/com/telnyx/react_voice_commons/VoicePnBridgeModule.kt +74 -1
  7. package/android/src/main/java/com/telnyx/react_voice_commons/VoicePnManager.kt +36 -1
  8. package/ios/CallKitBridge.m +20 -1
  9. package/ios/CallKitBridge.swift +935 -60
  10. package/ios/README.md +2 -2
  11. package/ios/VoicePnBridge.m +21 -1
  12. package/ios/VoicePnBridge.swift +62 -1
  13. package/lib/callkit/callkit-coordinator.d.ts +39 -3
  14. package/lib/callkit/callkit-coordinator.js +561 -148
  15. package/lib/callkit/callkit.d.ts +7 -0
  16. package/lib/callkit/callkit.js +83 -11
  17. package/lib/hooks/useAppStateHandler.js +6 -2
  18. package/lib/index.d.ts +1 -0
  19. package/lib/index.js +9 -0
  20. package/lib/internal/CallKitHandler.d.ts +3 -0
  21. package/lib/internal/CallKitHandler.js +52 -46
  22. package/lib/internal/calls/call-state-controller.d.ts +39 -1
  23. package/lib/internal/calls/call-state-controller.js +152 -64
  24. package/lib/internal/session/session-manager.d.ts +16 -1
  25. package/lib/internal/session/session-manager.js +229 -58
  26. package/lib/internal/voice-pn-bridge.d.ts +44 -0
  27. package/lib/internal/voice-pn-bridge.js +94 -0
  28. package/lib/models/call.js +35 -0
  29. package/lib/models/config.d.ts +18 -0
  30. package/lib/models/config.js +2 -0
  31. package/lib/telnyx-voice-app.js +85 -43
  32. package/lib/telnyx-voip-client.d.ts +48 -5
  33. package/lib/telnyx-voip-client.js +276 -22
  34. package/package.json +7 -5
  35. package/src/callkit/callkit-coordinator.ts +642 -160
  36. package/src/callkit/callkit.ts +110 -12
  37. package/src/hooks/useAppStateHandler.ts +7 -2
  38. package/src/index.ts +3 -0
  39. package/src/internal/CallKitHandler.tsx +60 -55
  40. package/src/internal/calls/call-state-controller.ts +173 -74
  41. package/src/internal/session/session-manager.ts +277 -61
  42. package/src/internal/voice-pn-bridge.ts +111 -0
  43. package/src/models/call.ts +34 -0
  44. package/src/models/config.ts +18 -0
  45. package/src/telnyx-voice-app.tsx +96 -45
  46. package/src/telnyx-voip-client.ts +260 -24
  47. package/src/types/telnyx-sdk.d.ts +12 -1
@@ -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.
@@ -17,11 +19,18 @@ export class CallStateController {
17
19
  private readonly _calls = new BehaviorSubject<Call[]>([]);
18
20
  private readonly _callMap = new Map<string, Call>();
19
21
  private _disposed = false;
22
+ private _listenerClient?: TelnyxRTC;
20
23
 
21
24
  // Callbacks for waiting for invite logic (used for push notifications)
22
25
  private _isWaitingForInvite?: () => boolean;
23
26
  private _onInviteAutoAccepted?: () => void;
24
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
+
25
34
  constructor(private readonly _sessionManager: SessionManager) {
26
35
  console.log('CallStateController: Constructor called - instance created');
27
36
  // Don't set up client listeners here - client doesn't exist yet
@@ -41,18 +50,18 @@ export class CallStateController {
41
50
  get activeCall$(): Observable<Call | null> {
42
51
  return this.calls$.pipe(
43
52
  map((calls) => {
44
- // Find the first call that is not terminated (includes RINGING, CONNECTING, ACTIVE, HELD)
45
- return (
46
- calls.find(
47
- (call) =>
48
- call.currentState === TelnyxCallState.RINGING ||
49
- call.currentState === TelnyxCallState.CONNECTING ||
50
- call.currentState === TelnyxCallState.ACTIVE ||
51
- call.currentState === TelnyxCallState.HELD
52
- ) || null
53
- );
53
+ const call = this._selectActiveCall(calls);
54
+ return {
55
+ call,
56
+ callId: call?.callId ?? null,
57
+ state: call?.currentState ?? null,
58
+ };
54
59
  }),
55
- distinctUntilChanged()
60
+ distinctUntilChanged(
61
+ (previous, current) =>
62
+ previous.callId === current.callId && previous.state === current.state
63
+ ),
64
+ map(({ call }) => call)
56
65
  );
57
66
  }
58
67
 
@@ -67,16 +76,7 @@ export class CallStateController {
67
76
  * Current active call (synchronous access)
68
77
  */
69
78
  get currentActiveCall(): Call | null {
70
- const calls = this.currentCalls;
71
- return (
72
- calls.find(
73
- (call) =>
74
- call.currentState === TelnyxCallState.RINGING ||
75
- call.currentState === TelnyxCallState.CONNECTING ||
76
- call.currentState === TelnyxCallState.ACTIVE ||
77
- call.currentState === TelnyxCallState.HELD
78
- ) || null
79
- );
79
+ return this._selectActiveCall(this.currentCalls);
80
80
  }
81
81
 
82
82
  /**
@@ -105,6 +105,34 @@ export class CallStateController {
105
105
  }
106
106
  }
107
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
+
108
136
  /**
109
137
  * Find a call by its underlying Telnyx call ID
110
138
  * @param telnyxCall The Telnyx call object to find
@@ -118,6 +146,33 @@ export class CallStateController {
118
146
  return null;
119
147
  }
120
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
+
121
176
  /**
122
177
  * Initialize client listeners when the Telnyx client becomes available
123
178
  * This should be called by the session manager after client creation
@@ -138,7 +193,7 @@ export class CallStateController {
138
193
  destination: string,
139
194
  callerName?: string,
140
195
  callerNumber?: string,
141
- customHeaders?: Record<string, string>
196
+ customHeaders?: CustomHeaders
142
197
  ): Promise<Call> {
143
198
  if (this._disposed) {
144
199
  throw new Error('CallStateController has been disposed');
@@ -154,7 +209,10 @@ export class CallStateController {
154
209
  destinationNumber: destination,
155
210
  callerIdName: callerName,
156
211
  callerIdNumber: callerNumber,
157
- customHeaders,
212
+ customHeaders: this._normalizeCustomHeaders(customHeaders),
213
+ peerConnectionOptions: {
214
+ useTrickleIce: this._sessionManager.useTrickleIce,
215
+ },
158
216
  };
159
217
  const telnyxCall = await this._sessionManager.telnyxClient.newCall(callOptions);
160
218
 
@@ -179,6 +237,21 @@ export class CallStateController {
179
237
  }
180
238
  }
181
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
+
182
255
  /**
183
256
  * Set callbacks for waiting for invite logic (used for push notifications)
184
257
  */
@@ -213,6 +286,7 @@ export class CallStateController {
213
286
  }
214
287
  }
215
288
  this._callMap.clear();
289
+ this._activeCallId = null;
216
290
  this._calls.next([]);
217
291
  }
218
292
 
@@ -225,10 +299,12 @@ export class CallStateController {
225
299
  }
226
300
 
227
301
  this._disposed = true;
302
+ this._removeClientListeners();
228
303
 
229
304
  // Dispose of all calls
230
305
  this.currentCalls.forEach((call) => call.dispose());
231
306
  this._callMap.clear();
307
+ this._activeCallId = null;
232
308
 
233
309
  // CallKit cleanup is now handled by CallKitCoordinator automatically
234
310
 
@@ -241,40 +317,28 @@ export class CallStateController {
241
317
  private _setupClientListeners(): void {
242
318
  console.log('CallStateController: Setting up client listeners...');
243
319
 
244
- if (!this._sessionManager.telnyxClient) {
320
+ const telnyxClient = this._sessionManager.telnyxClient;
321
+
322
+ if (!telnyxClient) {
245
323
  console.log('CallStateController: No telnyxClient available yet, skipping listener setup');
246
324
  return;
247
325
  }
248
326
 
327
+ this._removeClientListeners();
328
+ this._listenerClient = telnyxClient;
329
+
249
330
  console.log('CallStateController: TelnyxClient found, setting up incoming call listener');
250
- console.log(
251
- 'CallStateController: Client instance:',
252
- this._sessionManager.telnyxClient.constructor.name
253
- );
331
+ console.log('CallStateController: Client instance:', telnyxClient.constructor.name);
254
332
 
255
333
  // Listen for incoming calls
256
- this._sessionManager.telnyxClient.on(
257
- 'telnyx.call.incoming',
258
- (telnyxCall: TelnyxCall, msg: any) => {
259
- console.log('CallStateController: Incoming call received:', telnyxCall.callId);
260
- this._handleIncomingCall(telnyxCall, msg, false);
261
- }
262
- );
334
+ telnyxClient.on('telnyx.call.incoming', this._handleTelnyxIncomingCall);
263
335
 
264
336
  // Listen for reattached calls (after network reconnection)
265
- this._sessionManager.telnyxClient.on(
266
- 'telnyx.call.reattached',
267
- (telnyxCall: TelnyxCall, msg: any) => {
268
- console.log('CallStateController: Reattached call received:', telnyxCall.callId);
269
- this._handleIncomingCall(telnyxCall, msg, true);
270
- }
271
- );
337
+ telnyxClient.on('telnyx.call.reattached', this._handleTelnyxReattachedCall);
272
338
 
273
339
  // Verify listeners are set up
274
- const incomingListeners =
275
- this._sessionManager.telnyxClient.listenerCount('telnyx.call.incoming');
276
- const reattachedListeners =
277
- this._sessionManager.telnyxClient.listenerCount('telnyx.call.reattached');
340
+ const incomingListeners = telnyxClient.listenerCount('telnyx.call.incoming');
341
+ const reattachedListeners = telnyxClient.listenerCount('telnyx.call.reattached');
278
342
  console.log(
279
343
  'CallStateController: Listeners registered - incoming:',
280
344
  incomingListeners,
@@ -283,34 +347,57 @@ export class CallStateController {
283
347
  );
284
348
 
285
349
  // Listen for call state changes from the TelnyxRTC client (multi-call support)
286
- this._sessionManager.telnyxClient.on(
287
- 'telnyx.call.stateChanged',
288
- (telnyxCall: TelnyxCall, state: string) => {
289
- console.log(
290
- 'CallStateController: Call state changed from TelnyxRTC:',
291
- telnyxCall.callId,
292
- state
293
- );
294
- // Find our wrapper call and update if needed
295
- const call = this.findCallByTelnyxCall(telnyxCall);
296
- if (call) {
297
- console.log(
298
- 'CallStateController: Found wrapper call, state sync handled by Call subscription'
299
- );
300
- }
301
- }
302
- );
350
+ telnyxClient.on('telnyx.call.stateChanged', this._handleTelnyxCallStateChanged);
303
351
 
304
352
  // Listen for call removal events from TelnyxRTC (multi-call support)
305
- this._sessionManager.telnyxClient.on('telnyx.call.removed', (callId: string) => {
306
- console.log('CallStateController: Call removed from TelnyxRTC:', callId);
307
- // The call cleanup is already handled by our call state subscription
308
- // This event is informational for logging/debugging
309
- });
353
+ telnyxClient.on('telnyx.call.removed', this._handleTelnyxCallRemoved);
310
354
 
311
355
  console.log('CallStateController: Client listeners set up successfully');
312
356
  }
313
357
 
358
+ private _removeClientListeners(): void {
359
+ if (!this._listenerClient) {
360
+ return;
361
+ }
362
+
363
+ this._listenerClient.off('telnyx.call.incoming', this._handleTelnyxIncomingCall);
364
+ this._listenerClient.off('telnyx.call.reattached', this._handleTelnyxReattachedCall);
365
+ this._listenerClient.off('telnyx.call.stateChanged', this._handleTelnyxCallStateChanged);
366
+ this._listenerClient.off('telnyx.call.removed', this._handleTelnyxCallRemoved);
367
+ this._listenerClient = undefined;
368
+ }
369
+
370
+ private _handleTelnyxIncomingCall = (telnyxCall: TelnyxCall, msg: any): void => {
371
+ console.log('CallStateController: Incoming call received:', telnyxCall.callId);
372
+ this._handleIncomingCall(telnyxCall, msg, false);
373
+ };
374
+
375
+ private _handleTelnyxReattachedCall = (telnyxCall: TelnyxCall, msg: any): void => {
376
+ console.log('CallStateController: Reattached call received:', telnyxCall.callId);
377
+ this._handleIncomingCall(telnyxCall, msg, true);
378
+ };
379
+
380
+ private _handleTelnyxCallStateChanged = (telnyxCall: TelnyxCall, state: string): void => {
381
+ console.log(
382
+ 'CallStateController: Call state changed from TelnyxRTC:',
383
+ telnyxCall.callId,
384
+ state
385
+ );
386
+ // Find our wrapper call and update if needed
387
+ const call = this.findCallByTelnyxCall(telnyxCall);
388
+ if (call) {
389
+ console.log(
390
+ 'CallStateController: Found wrapper call, state sync handled by Call subscription'
391
+ );
392
+ }
393
+ };
394
+
395
+ private _handleTelnyxCallRemoved = (callId: string): void => {
396
+ console.log('CallStateController: Call removed from TelnyxRTC:', callId);
397
+ // The call cleanup is already handled by our call state subscription.
398
+ // This event is informational for logging/debugging.
399
+ };
400
+
314
401
  /**
315
402
  * Handle incoming call or reattached call
316
403
  */
@@ -399,6 +486,11 @@ export class CallStateController {
399
486
  private _addCall(call: Call): void {
400
487
  this._callMap.set(call.callId, call);
401
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
+
402
494
  const currentCalls = this.currentCalls;
403
495
  currentCalls.push(call);
404
496
  this._calls.next([...currentCalls]);
@@ -413,9 +505,10 @@ export class CallStateController {
413
505
 
414
506
  if (existingCallKitUUID) {
415
507
  console.log(
416
- 'CallStateController: Call already has CallKit integration, skipping duplicate report:',
508
+ 'CallStateController: Linking push-created CallKit call to its signaling call:',
417
509
  existingCallKitUUID
418
510
  );
511
+ callKitCoordinator.linkExistingCallKitCall(telnyxCall, existingCallKitUUID);
419
512
  } else if (call.isIncoming) {
420
513
  // Handle incoming call with CallKit (only if not already integrated)
421
514
  console.log('CallStateController: Reporting incoming call to CallKitCoordinator');
@@ -432,13 +525,19 @@ export class CallStateController {
432
525
  // CallKitCoordinator automatically updates CallKit via setupWebRTCCallListeners
433
526
  console.log('CallStateController: Call state changed to:', state);
434
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
+
435
533
  // Clean up when call ends - delay to next tick so external subscribers
436
534
  // receive the ENDED/FAILED state before the call is disposed
437
535
  if (state === TelnyxCallState.ENDED || state === TelnyxCallState.FAILED) {
438
- // Clear pending push data so the next app launch isn't mistaken for a push launch
439
- VoicePnBridge.clearPendingVoipPush().catch((e) =>
440
- console.warn('CallStateController: Failed to clear pending voip push:', e)
441
- );
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
+
442
541
  setTimeout(() => this._removeCall(call.callId), 0);
443
542
  }
444
543
  });