@telnyx/react-voice-commons-sdk 0.2.1 → 0.3.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.
package/CHANGELOG.md CHANGED
@@ -1,10 +1,35 @@
1
1
  # CHANGELOG.md
2
2
 
3
- ## [0.2.1](https://github.com/team-telnyx/react-native-voice-commons/releases/tag/commons-sdk-v0.2.1) (2026-04-02)
3
+ ## [0.3.1] (2026-04-16)
4
4
 
5
5
  ### Bug Fixing
6
6
 
7
- - Fixed CallKit auto-answer not working on iOS when user answers from CallKit UI during app cold launch from VoIP push. The native `CXAnswerCallAction` event was silently dropped because JS listeners weren't registered yet. Now persists the answer action in UserDefaults so the JS side can detect and honor it.
7
+ - Fixed stale `CONNECTED` state during background disconnect: `SessionManager.disconnect()` now emits `DISCONNECTED` before awaiting the underlying client teardown. Previously, observers (including the auto-reconnect logic in `TelnyxVoiceApp`) could read a stale `CONNECTED` value while the socket was being torn down, causing auto-reconnection to be skipped and subsequent `newCall()` attempts to fail with `Cannot make call when connection state is: DISCONNECTED` or `No connection exists. Please connect first.`
8
+ - Tracked calls are now cleared on disconnect. Previously, calls left in non-terminal states when the socket was torn down would accumulate across background/foreground cycles, since a dead socket never emits the `ENDED`/`FAILED` events that normally trigger per-call cleanup.
9
+
10
+ ## [0.3.0] (2026-04-15)
11
+
12
+ ### ⚠️ Breaking changes
13
+
14
+ - **`expo-router` is no longer a dependency of the SDK.** The SDK previously navigated the host app in a few places (`useAppStateHandler` on background disconnect, `CallKitHandler` after CallKit answer/end). Those calls have been removed — navigation is now exclusively the host app's responsibility.
15
+ - **Migration for Expo consumers:** subscribe to `voipClient.connectionState$` and `voipClient.activeCall$` in your app and navigate there. Example:
16
+ ```tsx
17
+ useEffect(() => {
18
+ const sub = voipClient.connectionState$.subscribe((state) => {
19
+ if (state === TelnyxConnectionState.DISCONNECTED) {
20
+ router.replace('/');
21
+ }
22
+ });
23
+ return () => sub.unsubscribe();
24
+ }, []);
25
+ ```
26
+ - `CallKitHandler` already exposed `onNavigateToDialer` / `onNavigateBack` callback props; those are now the only way to wire navigation.
27
+ - The `navigateToLoginOnDisconnect` option on `useAppStateHandler` is retained in the type signature for source compatibility but no longer has any effect.
28
+ - **Bare React Native (non-Expo) projects are now supported.** Metro bundling no longer fails on missing `expo-router`; SDK-level behavior is identical for Expo and bare RN consumers.
29
+
30
+ ### Enhancement
31
+
32
+ - **`react-native-url-polyfill` is now a direct dependency and auto-loaded** from the SDK entry point. Previously, apps running Hermes crashed on the second login attempt with `URLSearchParams.set is not implemented` because the SDK constructs the WebSocket URL via `URLSearchParams`, which is incomplete in Hermes. No action required from consumers.
8
33
 
9
34
  ## [0.2.0](https://github.com/team-telnyx/react-native-voice-commons/releases/tag/commons-sdk-v0.2.0) (2026-04-01)
10
35
 
package/README.md CHANGED
@@ -11,6 +11,7 @@ A high-level, state-agnostic, drop-in module for the Telnyx React Native SDK tha
11
11
  - **Reactive State Management**: RxJS-based state streams for real-time UI updates
12
12
  - **TypeScript Support**: Full TypeScript definitions for better developer experience
13
13
  - **Cross-Platform**: Built for both iOS and Android with React Native
14
+ - **Framework-agnostic**: Works in both Expo and bare React Native projects. See the [bare RN reference demo](https://github.com/team-telnyx/telnyx-react-native-bare-demo) for non-Expo integration.
14
15
 
15
16
  ## About @telnyx/react-voice-commons-sdk
16
17
 
@@ -115,6 +116,29 @@ call.callState$.subscribe((state) => {
115
116
  });
116
117
  ```
117
118
 
119
+ ### Navigation
120
+
121
+ As of **v0.3.0**, the SDK no longer navigates the host app. Routing on state transitions (e.g. redirecting to a login screen on disconnect, surfacing a dialer screen after answering a call via CallKit) is entirely the host app's responsibility. Subscribe to `connectionState$` and `activeCall$` and invoke your own navigator.
122
+
123
+ Example using `expo-router`:
124
+
125
+ ```tsx
126
+ import { router } from 'expo-router';
127
+ import { useEffect } from 'react';
128
+ import { TelnyxConnectionState } from '@telnyx/react-voice-commons-sdk';
129
+
130
+ useEffect(() => {
131
+ const sub = voipClient.connectionState$.subscribe((state) => {
132
+ if (state === TelnyxConnectionState.DISCONNECTED) {
133
+ router.replace('/');
134
+ }
135
+ });
136
+ return () => sub.unsubscribe();
137
+ }, []);
138
+ ```
139
+
140
+ The same pattern works with `react-navigation`, React Router, or any other navigator — the SDK is agnostic.
141
+
118
142
  ### 4. Call Management
119
143
 
120
144
  ```tsx
@@ -533,12 +533,6 @@ import React
533
533
  NSLog("📞 TelnyxVoice: CALLKIT ANSWER ACTION - Provider: \(provider), Action: \(action)")
534
534
  NSLog("TelnyxVoice: User answered call with UUID: \(action.callUUID)")
535
535
 
536
- // Always persist the answer action in UserDefaults so the JS side can detect it
537
- // even when the RCTEventEmitter bridge is not yet ready (app cold-launched from push).
538
- UserDefaults.standard.set(action.callUUID.uuidString, forKey: "pending_callkit_answer")
539
- UserDefaults.standard.synchronize()
540
- NSLog("TelnyxVoice: Stored pending CallKit answer for UUID: \(action.callUUID)")
541
-
542
536
  // Check if this is a programmatic answer (call already answered in WebRTC)
543
537
  // vs a user answer from CallKit UI
544
538
  if let callData = activeCalls[action.callUUID],
@@ -561,10 +555,6 @@ import React
561
555
  public func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
562
556
  NSLog("TelnyxVoice: User ended call with UUID: \(action.callUUID)")
563
557
 
564
- // Clear any pending CallKit answer (prevents stale auto-answer on next call)
565
- UserDefaults.standard.removeObject(forKey: "pending_callkit_answer")
566
- UserDefaults.standard.synchronize()
567
-
568
558
  // Notify React Native via CallKit bridge
569
559
  CallKitBridge.shared?.emitCallEvent(
570
560
  "CallKitDidPerformEndCallAction", callUUID: action.callUUID,
@@ -22,12 +22,6 @@ RCT_EXTERN_METHOD(getPendingVoipPush:(RCTPromiseResolveBlock)resolve
22
22
  RCT_EXTERN_METHOD(clearPendingVoipPush:(RCTPromiseResolveBlock)resolve
23
23
  rejecter:(RCTPromiseRejectBlock)reject)
24
24
 
25
- RCT_EXTERN_METHOD(getPendingCallKitAnswer:(RCTPromiseResolveBlock)resolve
26
- rejecter:(RCTPromiseRejectBlock)reject)
27
-
28
- RCT_EXTERN_METHOD(clearPendingCallKitAnswer:(RCTPromiseResolveBlock)resolve
29
- rejecter:(RCTPromiseRejectBlock)reject)
30
-
31
25
  RCT_EXTERN_METHOD(getPendingVoipAction:(RCTPromiseResolveBlock)resolve
32
26
  rejecter:(RCTPromiseRejectBlock)reject)
33
27
 
@@ -70,21 +70,6 @@ class VoicePnBridge: NSObject {
70
70
  resolve(true)
71
71
  }
72
72
 
73
- @objc
74
- func getPendingCallKitAnswer(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) {
75
- let answer = UserDefaults.standard.string(forKey: "pending_callkit_answer")
76
- NSLog("[VoicePnBridge] getPendingCallKitAnswer - answer: \(answer ?? "nil")")
77
- resolve(answer)
78
- }
79
-
80
- @objc
81
- func clearPendingCallKitAnswer(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) {
82
- UserDefaults.standard.removeObject(forKey: "pending_callkit_answer")
83
- UserDefaults.standard.synchronize()
84
- NSLog("[VoicePnBridge] clearPendingCallKitAnswer - cleared")
85
- resolve(true)
86
- }
87
-
88
73
  @objc
89
74
  func getPendingVoipAction(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) {
90
75
  let pending = UserDefaults.standard.string(forKey: "pending_voip_action")
@@ -258,10 +258,6 @@ class CallKitCoordinator {
258
258
  console.log('CallKitCoordinator: Answer action already being processed, skipping duplicate');
259
259
  return;
260
260
  }
261
- // Clear native-side pending answer since JS received the event normally
262
- try {
263
- await voice_pn_bridge_1.VoicePnBridge.clearPendingCallKitAnswer();
264
- } catch (_) {}
265
261
  const call = this.callMap.get(callKitUUID);
266
262
  if (!call) {
267
263
  console.warn('CallKitCoordinator: No WebRTC call found for CallKit answer action', {
@@ -444,21 +440,6 @@ class CallKitCoordinator {
444
440
  ...realPushData.metadata,
445
441
  from_callkit: true,
446
442
  };
447
- // Check if the user answered from CallKit before JS listeners were ready.
448
- // The native CXAnswerCallAction handler persists the answer UUID in UserDefaults
449
- // so we can detect it here even when the JS event was dropped.
450
- try {
451
- const pendingAnswer = await voice_pn_bridge_1.VoicePnBridge.getPendingCallKitAnswer();
452
- if (pendingAnswer) {
453
- console.log(
454
- 'CallKitCoordinator: Found pending CallKit answer from native (JS event was missed), setting auto-answer flag'
455
- );
456
- this.shouldAutoAnswerNextCall = true;
457
- await voice_pn_bridge_1.VoicePnBridge.clearPendingCallKitAnswer();
458
- }
459
- } catch (e) {
460
- // Ignore - method may not exist on older native versions
461
- }
462
443
  // Check if auto-answer is set and add from_notification flag
463
444
  const shouldAddFromNotification = this.shouldAutoAnswerNextCall;
464
445
  let pushData;
@@ -736,10 +717,6 @@ class CallKitCoordinator {
736
717
  async cleanupPushNotificationState() {
737
718
  console.log('CallKitCoordinator: ✅ Cleared auto-answer flag');
738
719
  this.shouldAutoAnswerNextCall = false;
739
- // Also clear native-side pending answer to prevent stale auto-answer
740
- try {
741
- await voice_pn_bridge_1.VoicePnBridge.clearPendingCallKitAnswer();
742
- } catch (_) {}
743
720
  }
744
721
  /**
745
722
  * Get reference to the SDK client (for queuing actions when call doesn't exist yet)
@@ -8,7 +8,6 @@ Object.defineProperty(exports, '__esModule', { value: true });
8
8
  exports.useAppStateHandler = void 0;
9
9
  const react_1 = require('react');
10
10
  const react_native_1 = require('react-native');
11
- const expo_router_1 = require('expo-router');
12
11
  const async_storage_1 = __importDefault(require('@react-native-async-storage/async-storage'));
13
12
  const connection_state_1 = require('../models/connection-state');
14
13
  const call_state_1 = require('../models/call-state');
@@ -70,9 +69,6 @@ const useAppStateHandler = ({
70
69
  if (!stillInProgress) {
71
70
  log('AppStateHandler: Push notification call completed, now disconnecting socket');
72
71
  await voipClient.logout();
73
- if (navigateToLoginOnDisconnect) {
74
- expo_router_1.router.replace('/');
75
- }
76
72
  }
77
73
  }, 5000); // Wait 5 seconds
78
74
  appState.current = nextAppState;
@@ -83,14 +79,6 @@ const useAppStateHandler = ({
83
79
  // Disconnect the socket with background reason
84
80
  await voipClient.logout();
85
81
  log('AppStateHandler: Socket disconnected successfully');
86
- // Navigate to login screen
87
- if (navigateToLoginOnDisconnect) {
88
- // Use a small delay to ensure the disconnect completes
89
- setTimeout(() => {
90
- log('AppStateHandler: Navigating to login screen');
91
- expo_router_1.router.replace('/');
92
- }, 100);
93
- }
94
82
  } catch (error) {
95
83
  console.error('AppStateHandler: Error during background disconnect:', error);
96
84
  }
package/lib/index.d.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  * call state transitions, push notification processing, and native call UI
9
9
  * integration.
10
10
  */
11
+ import 'react-native-url-polyfill/auto';
11
12
  export {
12
13
  TelnyxVoipClient,
13
14
  createTelnyxVoipClient,
package/lib/index.js CHANGED
@@ -62,6 +62,7 @@ exports.useAppReadyNotifier =
62
62
  exports.createTelnyxVoipClient =
63
63
  exports.TelnyxVoipClient =
64
64
  void 0;
65
+ require('react-native-url-polyfill/auto');
65
66
  // Main client
66
67
  var telnyx_voip_client_1 = require('./telnyx-voip-client');
67
68
  Object.defineProperty(exports, 'TelnyxVoipClient', {
@@ -8,7 +8,6 @@ Object.defineProperty(exports, '__esModule', { value: true });
8
8
  exports.CallKitHandler = void 0;
9
9
  const react_1 = require('react');
10
10
  const react_native_1 = require('react-native');
11
- const expo_router_1 = require('expo-router');
12
11
  const async_storage_1 = __importDefault(require('@react-native-async-storage/async-storage'));
13
12
  const TelnyxVoiceContext_1 = require('../context/TelnyxVoiceContext');
14
13
  // Global flag to ensure only one CallKitHandler is active
@@ -20,7 +19,6 @@ let isCallKitHandlerActive = false;
20
19
  * @internal - Users should not use this component directly
21
20
  */
22
21
  const CallKitHandler = ({ onLoginRequired, onNavigateToDialer, onNavigateBack }) => {
23
- const router = (0, expo_router_1.useRouter)();
24
22
  const { voipClient } = (0, TelnyxVoiceContext_1.useTelnyxVoice)();
25
23
  // Store active calls by CallKit UUID for coordination
26
24
  const activeCallsRef = (0, react_1.useRef)(new Map());
@@ -90,11 +88,8 @@ const CallKitHandler = ({ onLoginRequired, onNavigateToDialer, onNavigateBack })
90
88
  callUUID: eventData.callUUID,
91
89
  isTrackedCall: activeCallsRef.current.has(eventData.callUUID),
92
90
  });
93
- // Navigate to dialer after answering
94
91
  if (onNavigateToDialer) {
95
92
  onNavigateToDialer();
96
- } else {
97
- router.replace('/dialer');
98
93
  }
99
94
  };
100
95
  const handleEndCall = async (eventData) => {
@@ -105,11 +100,8 @@ const CallKitHandler = ({ onLoginRequired, onNavigateToDialer, onNavigateBack })
105
100
  // Clean up our local tracking info
106
101
  activeCallsRef.current.delete(eventData.callUUID);
107
102
  await async_storage_1.default.removeItem('@push_notification_payload');
108
- // Navigate back after call ends
109
103
  if (onNavigateBack) {
110
104
  onNavigateBack();
111
- } else {
112
- router.replace('/dialer');
113
105
  }
114
106
  };
115
107
  // This component doesn't render anything, it just handles events
@@ -71,6 +71,13 @@ export declare class CallStateController {
71
71
  isWaitingForInvite: () => boolean;
72
72
  onInviteAutoAccepted: () => void;
73
73
  }): void;
74
+ /**
75
+ * Clear all tracked calls. Called when the session disconnects so that
76
+ * calls left in non-terminal states (because the socket died before
77
+ * their ENDED/FAILED events could arrive) don't accumulate as ghosts
78
+ * across reconnect cycles.
79
+ */
80
+ clearAllCalls(): void;
74
81
  /**
75
82
  * Dispose of the controller and clean up resources
76
83
  */
@@ -161,6 +161,29 @@ class CallStateController {
161
161
  this._isWaitingForInvite = callbacks.isWaitingForInvite;
162
162
  this._onInviteAutoAccepted = callbacks.onInviteAutoAccepted;
163
163
  }
164
+ /**
165
+ * Clear all tracked calls. Called when the session disconnects so that
166
+ * calls left in non-terminal states (because the socket died before
167
+ * their ENDED/FAILED events could arrive) don't accumulate as ghosts
168
+ * across reconnect cycles.
169
+ */
170
+ clearAllCalls() {
171
+ if (this._callMap.size === 0) {
172
+ return;
173
+ }
174
+ console.log(
175
+ `CallStateController: Clearing ${this._callMap.size} tracked call(s) on disconnect`
176
+ );
177
+ for (const call of this._callMap.values()) {
178
+ try {
179
+ call.dispose();
180
+ } catch (error) {
181
+ console.warn('CallStateController: Error disposing call during clear:', error);
182
+ }
183
+ }
184
+ this._callMap.clear();
185
+ this._calls.next([]);
186
+ }
164
187
  /**
165
188
  * Dispose of the controller and clean up resources
166
189
  */
@@ -15,6 +15,7 @@ export declare class SessionManager {
15
15
  private _sessionId;
16
16
  private _disposed;
17
17
  private _onClientReady?;
18
+ private _onDisconnect?;
18
19
  constructor();
19
20
  /**
20
21
  * Observable stream of connection state changes
@@ -24,6 +25,11 @@ export declare class SessionManager {
24
25
  * Set callback to be called when the Telnyx client is ready
25
26
  */
26
27
  setOnClientReady(callback: () => void): void;
28
+ /**
29
+ * Set callback to be called when the session disconnects, so dependent
30
+ * subsystems (e.g. the call state controller) can clear their state.
31
+ */
32
+ setOnDisconnect(callback: () => void): void;
27
33
  /**
28
34
  * Current connection state (synchronous access)
29
35
  */
@@ -45,7 +51,14 @@ export declare class SessionManager {
45
51
  */
46
52
  connectWithToken(config: TokenConfig): Promise<void>;
47
53
  /**
48
- * Disconnect from the Telnyx platform
54
+ * Disconnect from the Telnyx platform.
55
+ *
56
+ * The DISCONNECTED state is emitted BEFORE awaiting the underlying
57
+ * client teardown so that observers (including the auto-reconnect logic
58
+ * in TelnyxVoiceApp) cannot read a stale CONNECTED value during the
59
+ * short window while the socket is being torn down. Tracked calls are
60
+ * cleared here too, since a torn-down socket will never emit the
61
+ * ENDED/FAILED events that normally trigger per-call cleanup.
49
62
  */
50
63
  disconnect(): Promise<void>;
51
64
  /**
@@ -85,6 +85,13 @@ class SessionManager {
85
85
  setOnClientReady(callback) {
86
86
  this._onClientReady = callback;
87
87
  }
88
+ /**
89
+ * Set callback to be called when the session disconnects, so dependent
90
+ * subsystems (e.g. the call state controller) can clear their state.
91
+ */
92
+ setOnDisconnect(callback) {
93
+ this._onDisconnect = callback;
94
+ }
88
95
  /**
89
96
  * Current connection state (synchronous access)
90
97
  */
@@ -124,13 +131,28 @@ class SessionManager {
124
131
  await this._connect();
125
132
  }
126
133
  /**
127
- * Disconnect from the Telnyx platform
134
+ * Disconnect from the Telnyx platform.
135
+ *
136
+ * The DISCONNECTED state is emitted BEFORE awaiting the underlying
137
+ * client teardown so that observers (including the auto-reconnect logic
138
+ * in TelnyxVoiceApp) cannot read a stale CONNECTED value during the
139
+ * short window while the socket is being torn down. Tracked calls are
140
+ * cleared here too, since a torn-down socket will never emit the
141
+ * ENDED/FAILED events that normally trigger per-call cleanup.
128
142
  */
129
143
  async disconnect() {
130
144
  if (this._disposed) {
131
145
  return;
132
146
  }
133
147
  this._currentConfig = undefined;
148
+ this._connectionState.next(connection_state_1.TelnyxConnectionState.DISCONNECTED);
149
+ if (this._onDisconnect) {
150
+ try {
151
+ this._onDisconnect();
152
+ } catch (error) {
153
+ console.error('Error in onDisconnect callback:', error);
154
+ }
155
+ }
134
156
  if (this._telnyxClient) {
135
157
  try {
136
158
  await this._telnyxClient.disconnect();
@@ -138,7 +160,6 @@ class SessionManager {
138
160
  console.error('Error during disconnect:', error);
139
161
  }
140
162
  }
141
- this._connectionState.next(connection_state_1.TelnyxConnectionState.DISCONNECTED);
142
163
  }
143
164
  /**
144
165
  * Disable push notifications for the current session.
@@ -25,8 +25,6 @@ export interface VoicePnBridgeInterface {
25
25
  ): Promise<boolean>;
26
26
  hideOngoingCallNotification(): Promise<boolean>;
27
27
  hideIncomingCallNotification(): Promise<boolean>;
28
- getPendingCallKitAnswer(): Promise<string | null>;
29
- clearPendingCallKitAnswer(): Promise<boolean>;
30
28
  getVoipToken(): Promise<string | null>;
31
29
  getPendingVoipPush(): Promise<string | null>;
32
30
  clearPendingVoipPush(): Promise<boolean>;
@@ -89,16 +87,6 @@ export declare class VoicePnBridge {
89
87
  * Useful for dismissing notifications when call is answered/rejected in app
90
88
  */
91
89
  static hideIncomingCallNotification(): Promise<boolean>;
92
- /**
93
- * Get pending CallKit answer UUID from native storage (iOS only).
94
- * When the user answers a CallKit call before JS listeners are ready,
95
- * the native side persists the answer UUID in UserDefaults so JS can detect it.
96
- */
97
- static getPendingCallKitAnswer(): Promise<string | null>;
98
- /**
99
- * Clear pending CallKit answer from native storage (iOS only)
100
- */
101
- static clearPendingCallKitAnswer(): Promise<boolean>;
102
90
  /**
103
91
  * Get VoIP token from native storage
104
92
  */
@@ -124,32 +124,6 @@ class VoicePnBridge {
124
124
  return false;
125
125
  }
126
126
  }
127
- /**
128
- * Get pending CallKit answer UUID from native storage (iOS only).
129
- * When the user answers a CallKit call before JS listeners are ready,
130
- * the native side persists the answer UUID in UserDefaults so JS can detect it.
131
- */
132
- static async getPendingCallKitAnswer() {
133
- if (react_native_1.Platform.OS !== 'ios') return null;
134
- try {
135
- return await NativeBridge.getPendingCallKitAnswer();
136
- } catch (error) {
137
- console.error('VoicePnBridge: Error getting pending CallKit answer:', error);
138
- return null;
139
- }
140
- }
141
- /**
142
- * Clear pending CallKit answer from native storage (iOS only)
143
- */
144
- static async clearPendingCallKitAnswer() {
145
- if (react_native_1.Platform.OS !== 'ios') return true;
146
- try {
147
- return await NativeBridge.clearPendingCallKitAnswer();
148
- } catch (error) {
149
- console.error('VoicePnBridge: Error clearing pending CallKit answer:', error);
150
- return false;
151
- }
152
- }
153
127
  /**
154
128
  * Get VoIP token from native storage
155
129
  */
@@ -66,6 +66,11 @@ class TelnyxVoipClient {
66
66
  );
67
67
  this._callStateController.initializeClientListeners();
68
68
  });
69
+ // Clear any tracked calls when the session disconnects, so ghosts
70
+ // don't accumulate across background → foreground reconnect cycles.
71
+ this._sessionManager.setOnDisconnect(() => {
72
+ this._callStateController.clearAllCalls();
73
+ });
69
74
  if (this._options.debug) {
70
75
  console.log('TelnyxVoipClient initialized with options:', this._options);
71
76
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telnyx/react-voice-commons-sdk",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
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",
@@ -59,14 +59,13 @@
59
59
  "homepage": "https://github.com/team-telnyx/react-native-voice-commons",
60
60
  "repository": {
61
61
  "type": "git",
62
- "url": "git+https://github.com/team-telnyx/react-native-voice-commons.git"
62
+ "url": "https://github.com/team-telnyx/react-native-voice-commons.git"
63
63
  },
64
64
  "bugs": {
65
65
  "url": "https://github.com/team-telnyx/react-native-voice-commons/issues"
66
66
  },
67
67
  "peerDependencies": {
68
68
  "@react-native-async-storage/async-storage": "^2.1.0",
69
- "expo-router": "^5.1.0",
70
69
  "react": ">=19.0.0 <20.0.0",
71
70
  "react-native": ">=0.79.0 <1.0.0",
72
71
  "react-native-webrtc": "^124.0.5"
@@ -75,9 +74,6 @@
75
74
  "@react-native-async-storage/async-storage": {
76
75
  "optional": false
77
76
  },
78
- "expo-router": {
79
- "optional": false
80
- },
81
77
  "react": {
82
78
  "optional": false
83
79
  },
@@ -93,6 +89,7 @@
93
89
  "@telnyx/react-native-voice-sdk": ">=0.4.1",
94
90
  "eventemitter3": "^5.0.1",
95
91
  "expo": "~53.0.22",
92
+ "react-native-url-polyfill": "^3.0.0",
96
93
  "react-native-voip-push-notification": "^3.3.3",
97
94
  "rxjs": "^7.8.2"
98
95
  },
@@ -104,7 +101,6 @@
104
101
  "@typescript-eslint/eslint-plugin": "^6.0.0",
105
102
  "@typescript-eslint/parser": "^6.0.0",
106
103
  "eslint": "^8.0.0",
107
- "expo-router": "^5.1.0",
108
104
  "jest": "^29.5.0",
109
105
  "prettier": "^3.0.0",
110
106
  "ts-jest": "^29.1.0",
@@ -116,6 +112,7 @@
116
112
  "node": ">=16"
117
113
  },
118
114
  "publishConfig": {
119
- "access": "public"
115
+ "access": "public",
116
+ "provenance": true
120
117
  }
121
118
  }
@@ -2,9 +2,9 @@ import { Platform, AppState } from 'react-native';
2
2
  import CallKit, { CallEndReason } from './callkit';
3
3
  import { Call } from '@telnyx/react-native-voice-sdk';
4
4
  import { VoicePnBridge } from '../internal/voice-pn-bridge';
5
- import { router } from 'expo-router';
6
5
  import { TelnyxVoipClient } from '../telnyx-voip-client';
7
6
  import { TelnyxConnectionState } from '../models/connection-state';
7
+ import { act } from 'react';
8
8
 
9
9
  /**
10
10
  * CallKit Coordinator - Manages the proper CallKit-first flow for iOS
@@ -253,11 +253,6 @@ class CallKitCoordinator {
253
253
  return;
254
254
  }
255
255
 
256
- // Clear native-side pending answer since JS received the event normally
257
- try {
258
- await VoicePnBridge.clearPendingCallKitAnswer();
259
- } catch (_) {}
260
-
261
256
  const call = this.callMap.get(callKitUUID);
262
257
 
263
258
  if (!call) {
@@ -473,22 +468,6 @@ class CallKitCoordinator {
473
468
  from_callkit: true,
474
469
  };
475
470
 
476
- // Check if the user answered from CallKit before JS listeners were ready.
477
- // The native CXAnswerCallAction handler persists the answer UUID in UserDefaults
478
- // so we can detect it here even when the JS event was dropped.
479
- try {
480
- const pendingAnswer = await VoicePnBridge.getPendingCallKitAnswer();
481
- if (pendingAnswer) {
482
- console.log(
483
- 'CallKitCoordinator: Found pending CallKit answer from native (JS event was missed), setting auto-answer flag'
484
- );
485
- this.shouldAutoAnswerNextCall = true;
486
- await VoicePnBridge.clearPendingCallKitAnswer();
487
- }
488
- } catch (e) {
489
- // Ignore - method may not exist on older native versions
490
- }
491
-
492
471
  // Check if auto-answer is set and add from_notification flag
493
472
  const shouldAddFromNotification = this.shouldAutoAnswerNextCall;
494
473
 
@@ -809,10 +788,6 @@ class CallKitCoordinator {
809
788
  private async cleanupPushNotificationState(): Promise<void> {
810
789
  console.log('CallKitCoordinator: ✅ Cleared auto-answer flag');
811
790
  this.shouldAutoAnswerNextCall = false;
812
- // Also clear native-side pending answer to prevent stale auto-answer
813
- try {
814
- await VoicePnBridge.clearPendingCallKitAnswer();
815
- } catch (_) {}
816
791
  }
817
792
 
818
793
  /**
@@ -1,6 +1,5 @@
1
1
  import { useEffect, useRef } from 'react';
2
2
  import { AppState, AppStateStatus } from 'react-native';
3
- import { router } from 'expo-router';
4
3
  import AsyncStorage from '@react-native-async-storage/async-storage';
5
4
  import { TelnyxVoipClient } from '../telnyx-voip-client';
6
5
  import { TelnyxConnectionState } from '../models/connection-state';
@@ -76,9 +75,6 @@ export const useAppStateHandler = ({
76
75
  if (!stillInProgress) {
77
76
  log('AppStateHandler: Push notification call completed, now disconnecting socket');
78
77
  await voipClient.logout();
79
- if (navigateToLoginOnDisconnect) {
80
- router.replace('/');
81
- }
82
78
  }
83
79
  }, 5000); // Wait 5 seconds
84
80
  appState.current = nextAppState;
@@ -92,15 +88,6 @@ export const useAppStateHandler = ({
92
88
  await voipClient.logout();
93
89
 
94
90
  log('AppStateHandler: Socket disconnected successfully');
95
-
96
- // Navigate to login screen
97
- if (navigateToLoginOnDisconnect) {
98
- // Use a small delay to ensure the disconnect completes
99
- setTimeout(() => {
100
- log('AppStateHandler: Navigating to login screen');
101
- router.replace('/');
102
- }, 100);
103
- }
104
91
  } catch (error) {
105
92
  console.error('AppStateHandler: Error during background disconnect:', error);
106
93
  }
package/src/index.ts CHANGED
@@ -9,6 +9,8 @@
9
9
  * integration.
10
10
  */
11
11
 
12
+ import 'react-native-url-polyfill/auto';
13
+
12
14
  // Main client
13
15
  export {
14
16
  TelnyxVoipClient,
@@ -1,6 +1,5 @@
1
1
  import React, { useEffect, useRef } from 'react';
2
2
  import { Platform, DeviceEventEmitter } from 'react-native';
3
- import { useRouter } from 'expo-router';
4
3
  import AsyncStorage from '@react-native-async-storage/async-storage';
5
4
  import { useTelnyxVoice } from '../context/TelnyxVoiceContext';
6
5
  import { callKitCoordinator } from '../callkit';
@@ -36,7 +35,6 @@ export const CallKitHandler: React.FC<CallKitHandlerProps> = ({
36
35
  onNavigateToDialer,
37
36
  onNavigateBack,
38
37
  }) => {
39
- const router = useRouter();
40
38
  const { voipClient } = useTelnyxVoice();
41
39
 
42
40
  // Store active calls by CallKit UUID for coordination
@@ -118,11 +116,8 @@ export const CallKitHandler: React.FC<CallKitHandlerProps> = ({
118
116
  isTrackedCall: activeCallsRef.current.has(eventData.callUUID),
119
117
  });
120
118
 
121
- // Navigate to dialer after answering
122
119
  if (onNavigateToDialer) {
123
120
  onNavigateToDialer();
124
- } else {
125
- router.replace('/dialer');
126
121
  }
127
122
  };
128
123
 
@@ -136,11 +131,8 @@ export const CallKitHandler: React.FC<CallKitHandlerProps> = ({
136
131
  activeCallsRef.current.delete(eventData.callUUID);
137
132
  await AsyncStorage.removeItem('@push_notification_payload');
138
133
 
139
- // Navigate back after call ends
140
134
  if (onNavigateBack) {
141
135
  onNavigateBack();
142
- } else {
143
- router.replace('/dialer');
144
136
  }
145
137
  };
146
138
 
@@ -190,6 +190,32 @@ export class CallStateController {
190
190
  this._onInviteAutoAccepted = callbacks.onInviteAutoAccepted;
191
191
  }
192
192
 
193
+ /**
194
+ * Clear all tracked calls. Called when the session disconnects so that
195
+ * calls left in non-terminal states (because the socket died before
196
+ * their ENDED/FAILED events could arrive) don't accumulate as ghosts
197
+ * across reconnect cycles.
198
+ */
199
+ clearAllCalls(): void {
200
+ if (this._callMap.size === 0) {
201
+ return;
202
+ }
203
+
204
+ console.log(
205
+ `CallStateController: Clearing ${this._callMap.size} tracked call(s) on disconnect`
206
+ );
207
+
208
+ for (const call of this._callMap.values()) {
209
+ try {
210
+ call.dispose();
211
+ } catch (error) {
212
+ console.warn('CallStateController: Error disposing call during clear:', error);
213
+ }
214
+ }
215
+ this._callMap.clear();
216
+ this._calls.next([]);
217
+ }
218
+
193
219
  /**
194
220
  * Dispose of the controller and clean up resources
195
221
  */
@@ -26,6 +26,7 @@ export class SessionManager {
26
26
  private _sessionId: string;
27
27
  private _disposed = false;
28
28
  private _onClientReady?: () => void;
29
+ private _onDisconnect?: () => void;
29
30
 
30
31
  constructor() {
31
32
  this._sessionId = this._generateSessionId();
@@ -45,6 +46,14 @@ export class SessionManager {
45
46
  this._onClientReady = callback;
46
47
  }
47
48
 
49
+ /**
50
+ * Set callback to be called when the session disconnects, so dependent
51
+ * subsystems (e.g. the call state controller) can clear their state.
52
+ */
53
+ setOnDisconnect(callback: () => void): void {
54
+ this._onDisconnect = callback;
55
+ }
56
+
48
57
  /**
49
58
  * Current connection state (synchronous access)
50
59
  */
@@ -91,7 +100,14 @@ export class SessionManager {
91
100
  }
92
101
 
93
102
  /**
94
- * Disconnect from the Telnyx platform
103
+ * Disconnect from the Telnyx platform.
104
+ *
105
+ * The DISCONNECTED state is emitted BEFORE awaiting the underlying
106
+ * client teardown so that observers (including the auto-reconnect logic
107
+ * in TelnyxVoiceApp) cannot read a stale CONNECTED value during the
108
+ * short window while the socket is being torn down. Tracked calls are
109
+ * cleared here too, since a torn-down socket will never emit the
110
+ * ENDED/FAILED events that normally trigger per-call cleanup.
95
111
  */
96
112
  async disconnect(): Promise<void> {
97
113
  if (this._disposed) {
@@ -99,6 +115,15 @@ export class SessionManager {
99
115
  }
100
116
 
101
117
  this._currentConfig = undefined;
118
+ this._connectionState.next(TelnyxConnectionState.DISCONNECTED);
119
+
120
+ if (this._onDisconnect) {
121
+ try {
122
+ this._onDisconnect();
123
+ } catch (error) {
124
+ console.error('Error in onDisconnect callback:', error);
125
+ }
126
+ }
102
127
 
103
128
  if (this._telnyxClient) {
104
129
  try {
@@ -107,8 +132,6 @@ export class SessionManager {
107
132
  console.error('Error during disconnect:', error);
108
133
  }
109
134
  }
110
-
111
- this._connectionState.next(TelnyxConnectionState.DISCONNECTED);
112
135
  }
113
136
 
114
137
  /**
@@ -32,10 +32,6 @@ export interface VoicePnBridgeInterface {
32
32
  hideOngoingCallNotification(): Promise<boolean>;
33
33
  hideIncomingCallNotification(): Promise<boolean>;
34
34
 
35
- // CallKit answer persistence (iOS only)
36
- getPendingCallKitAnswer(): Promise<string | null>;
37
- clearPendingCallKitAnswer(): Promise<boolean>;
38
-
39
35
  // Additional UserDefaults methods
40
36
  getVoipToken(): Promise<string | null>;
41
37
  getPendingVoipPush(): Promise<string | null>;
@@ -183,34 +179,6 @@ export class VoicePnBridge {
183
179
  }
184
180
  }
185
181
 
186
- /**
187
- * Get pending CallKit answer UUID from native storage (iOS only).
188
- * When the user answers a CallKit call before JS listeners are ready,
189
- * the native side persists the answer UUID in UserDefaults so JS can detect it.
190
- */
191
- static async getPendingCallKitAnswer(): Promise<string | null> {
192
- if (Platform.OS !== 'ios') return null;
193
- try {
194
- return await NativeBridge.getPendingCallKitAnswer();
195
- } catch (error) {
196
- console.error('VoicePnBridge: Error getting pending CallKit answer:', error);
197
- return null;
198
- }
199
- }
200
-
201
- /**
202
- * Clear pending CallKit answer from native storage (iOS only)
203
- */
204
- static async clearPendingCallKitAnswer(): Promise<boolean> {
205
- if (Platform.OS !== 'ios') return true;
206
- try {
207
- return await NativeBridge.clearPendingCallKitAnswer();
208
- } catch (error) {
209
- console.error('VoicePnBridge: Error clearing pending CallKit answer:', error);
210
- return false;
211
- }
212
- }
213
-
214
182
  /**
215
183
  * Get VoIP token from native storage
216
184
  */
@@ -82,6 +82,12 @@ export class TelnyxVoipClient {
82
82
  this._callStateController.initializeClientListeners();
83
83
  });
84
84
 
85
+ // Clear any tracked calls when the session disconnects, so ghosts
86
+ // don't accumulate across background → foreground reconnect cycles.
87
+ this._sessionManager.setOnDisconnect(() => {
88
+ this._callStateController.clearAllCalls();
89
+ });
90
+
85
91
  if (this._options.debug) {
86
92
  console.log('TelnyxVoipClient initialized with options:', this._options);
87
93
  }