@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
@@ -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
  }
package/src/index.ts CHANGED
@@ -55,5 +55,8 @@ export type { Call as TelnyxCall } from '@telnyx/react-native-voice-sdk';
55
55
  // Export CallKit functionality
56
56
  export * from './callkit';
57
57
 
58
+ // Native push notification helpers
59
+ export { VoicePnBridge } from './internal/voice-pn-bridge';
60
+
58
61
  // Export hooks
59
62
  export { useAppReadyNotifier } from './hooks/useAppReadyNotifier';
@@ -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
  };