@telnyx/react-voice-commons-sdk 0.3.0 → 0.4.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # CHANGELOG.md
2
2
 
3
+ ## [0.4.0] (2026-04-19)
4
+
5
+ ### Enhancement
6
+
7
+ - **DTMF support.** Added `Call.dtmf(digits: string): Promise<void>` — sends DTMF tones on the current call as Verto `INFO` messages. Accepts a single digit (e.g. `"5"`) or a whole string (e.g. `"1234#"`). Valid characters are `0-9`, `A-D`, `*`, and `#`; anything else is silently dropped by the underlying SDK. Call state must be `ACTIVE` — will throw otherwise.
8
+
9
+ ### Bug Fixing
10
+
11
+ - **Fixed `IllegalArgumentException: Could not find @ReactModule annotation in VoicePnBridgeModule` crash** on Android when the app is cold-started by a push notification action (Answer/Decline). React Native 0.79+ requires `@ReactModule(name = …)` on native modules that are looked up by class via `ReactContext.getNativeModule(Class)`. The annotation has been added to `VoicePnBridgeModule`, and the module name is now sourced from a single `NAME` constant.
12
+
13
+ ### Dependencies
14
+
15
+ - Now requires `@telnyx/react-native-voice-sdk >= 0.4.2`, which fixes a bug where every `Call.dtmf()` invocation threw `Invalid DTMF response received` even though the tone was delivered. See the [voice-sdk 0.4.2 changelog](../package/CHANGELOG.md#042-2026-04-19) for details.
16
+
17
+ ## [0.3.1] (2026-04-16)
18
+
19
+ ### Bug Fixing
20
+
21
+ - 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.`
22
+ - 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.
23
+
3
24
  ## [0.3.0] (2026-04-15)
4
25
 
5
26
  ### ⚠️ Breaking changes
package/README.md CHANGED
@@ -118,9 +118,18 @@ call.callState$.subscribe((state) => {
118
118
 
119
119
  ### Navigation
120
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.
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 an in-call screen when a call arrives via push) is entirely the host app's responsibility. Subscribe to the observables below and invoke your own navigator.
122
122
 
123
- Example using `expo-router`:
123
+ **What to observe:**
124
+
125
+ | Observable | Emits | Use it for |
126
+ | ----------------------------- | -------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
127
+ | `voipClient.connectionState$` | `TelnyxConnectionState` (`CONNECTING`, `CONNECTED`, `RECONNECTING`, `DISCONNECTED`, `ERROR`) | Redirect to login on `DISCONNECTED`; gate outbound-call UI on `CONNECTED`. There is no separate `loginState$` — `CONNECTED` means the socket is up **and** authenticated. |
128
+ | `voipClient.activeCall$` | `Call \| null` | Navigate to your in-call screen when a call appears. Primary signal for push-launched cold starts — when the SDK finishes the push-driven login and the call arrives, this emits. |
129
+ | `voipClient.calls$` | `Call[]` | Multi-call UIs (call waiting, conference). |
130
+ | `call.callState$` | `TelnyxCallState` | Per-call transitions (ringing / active / held / ended). |
131
+
132
+ #### Redirect to login on disconnect
124
133
 
125
134
  ```tsx
126
135
  import { router } from 'expo-router';
@@ -137,6 +146,43 @@ useEffect(() => {
137
146
  }, []);
138
147
  ```
139
148
 
149
+ #### Navigate to in-call screen when a call arrives (push-launched or otherwise)
150
+
151
+ This is the piece that pairs with the push flow: after `isLaunchedFromPushNotification()` tells you to skip manual login, the SDK drives login internally and the call shows up on `activeCall$`. The same subscription handles foreground pushes and outbound calls, so you only need one:
152
+
153
+ ```tsx
154
+ import { router } from 'expo-router';
155
+ import { useEffect } from 'react';
156
+
157
+ useEffect(() => {
158
+ // Handle the cold-start race: if the call was already delivered before this
159
+ // component mounted (common on push-launched cold starts), read it synchronously.
160
+ if (voipClient.currentActiveCall) {
161
+ router.replace('/call');
162
+ }
163
+
164
+ const sub = voipClient.activeCall$.subscribe((call) => {
165
+ if (call) router.replace('/call');
166
+ else router.replace('/dialer');
167
+ });
168
+ return () => sub.unsubscribe();
169
+ }, []);
170
+ ```
171
+
172
+ **Note on CallKit / ConnectionService:** the native call UI (ringtone, answer/decline) is shown by the OS regardless — your RN screen is only visible once the user taps into the app. If you only need native UI, you can skip the navigation step entirely.
173
+
174
+ #### Optional: gate outbound-call UI on CONNECTED
175
+
176
+ ```tsx
177
+ import { canMakeCalls } from '@telnyx/react-voice-commons-sdk';
178
+
179
+ const [canCall, setCanCall] = useState(false);
180
+ useEffect(() => {
181
+ const sub = voipClient.connectionState$.subscribe((s) => setCanCall(canMakeCalls(s)));
182
+ return () => sub.unsubscribe();
183
+ }, []);
184
+ ```
185
+
140
186
  The same pattern works with `react-navigation`, React Router, or any other navigator — the SDK is agnostic.
141
187
 
142
188
  ### 4. Call Management
@@ -152,8 +198,64 @@ await call.answer();
152
198
  await call.mute();
153
199
  await call.hold();
154
200
  await call.hangup();
201
+
202
+ // Send DTMF tones (for IVRs, conference pins, etc.)
203
+ await call.dtmf('1'); // single digit
204
+ await call.dtmf('1234#'); // whole string
205
+ ```
206
+
207
+ **DTMF:** `call.dtmf(digits)` sends each character as a Verto `INFO` message to the Telnyx platform. Valid characters are `0-9`, `A-D`, `*`, and `#`; any other characters are silently dropped by the underlying SDK. The call must be in the `ACTIVE` state — calling `dtmf()` in any other state throws. Safe to call with a single digit for dialpad presses or with a whole string for pre-recorded sequences.
208
+
209
+ ### Push Notification Flow
210
+
211
+ You do not wire push handlers in JS. The native layer does the work:
212
+
213
+ - **Android**: `TelnyxFirebaseMessagingService` receives the FCM message, posts the call notification, and launches `TelnyxMainActivity` with the intent on Answer/Decline.
214
+ - **iOS**: `TelnyxVoipPushHandler` receives the PushKit payload and reports the call to CallKit.
215
+
216
+ The SDK then connects the socket and restores the call internally. You observe `voipClient.calls$` to render UI.
217
+
218
+ #### Detecting a Push-Launched Cold Start
219
+
220
+ When the OS wakes the app from a terminated state to deliver a call, the SDK is already handling login via the push flow. If your app _also_ triggers a login on mount, you get two competing sessions (double-login), which causes the call to fail or the socket to churn.
221
+
222
+ Use the static `TelnyxVoipClient.isLaunchedFromPushNotification()` to guard your login:
223
+
224
+ ```tsx
225
+ import { TelnyxVoipClient } from '@telnyx/react-voice-commons-sdk';
226
+
227
+ React.useEffect(() => {
228
+ TelnyxVoipClient.isLaunchedFromPushNotification().then((isFromPush) => {
229
+ if (isFromPush) return; // SDK is handling login via the push flow
230
+ voipClient.loginFromStoredConfig(); // Normal cold start
231
+ });
232
+ }, []);
233
+ ```
234
+
235
+ Returns `true` when a pending FCM intent (Android) or PushKit payload (iOS) has not yet been consumed.
236
+
237
+ #### Common Mistake: Manual or Automatic Login on Push
238
+
239
+ The single most common integration bug is re-logging in while the SDK is already handling a push:
240
+
241
+ ```tsx
242
+ // WRONG — runs on every mount, including push-launched cold starts
243
+ React.useEffect(() => {
244
+ voipClient.login(config); // or loginFromStoredConfig(), or loginWithToken()
245
+ }, []);
246
+ ```
247
+
248
+ ```tsx
249
+ // Right — guard the login on push-launched cold starts
250
+ React.useEffect(() => {
251
+ TelnyxVoipClient.isLaunchedFromPushNotification().then((isFromPush) => {
252
+ if (!isFromPush) voipClient.loginFromStoredConfig();
253
+ });
254
+ }, []);
155
255
  ```
156
256
 
257
+ Symptoms of getting this wrong: the incoming call rings briefly then disappears, the socket disconnects mid-call, or CallKit shows a call that immediately ends.
258
+
157
259
  ### Authentication & Persistent Storage
158
260
 
159
261
  The library supports both credential-based and token-based authentication with automatic persistence for seamless reconnection.
@@ -273,17 +375,12 @@ The `TelnyxMainActivity` provides:
273
375
 
274
376
  ### 2. Push Notification Setup
275
377
 
276
- 1. Place `google-services.json` in the project root
277
- 2. Register background message handler:
378
+ 1. Place `google-services.json` in the project root.
379
+ 2. Create an FCM service that extends `TelnyxFirebaseMessagingService` and a notification action receiver that extends `TelnyxNotificationActionReceiver`, then register both in `AndroidManifest.xml`. See the [push notification app setup guide](../docs-markdown/push-notification/app-setup.md) for the full boilerplate.
278
380
 
279
- ```tsx
280
- import messaging from '@react-native-firebase/messaging';
281
- import { TelnyxVoiceApp } from '@telnyx/react-voice-commons-sdk';
381
+ > **Do not** register `messaging().setBackgroundMessageHandler(...)` from JS. Android push is handled entirely by the native `TelnyxFirebaseMessagingService` — adding a JS handler will fight the native layer and can double-process calls. There is no `TelnyxVoiceApp.handleBackgroundPush` step required on Android.
282
382
 
283
- messaging().setBackgroundMessageHandler(async (remoteMessage) => {
284
- await TelnyxVoiceApp.handleBackgroundPush(remoteMessage.data);
285
- });
286
- ```
383
+ See [Push Notification Flow](#push-notification-flow) below for how to detect push-launched cold starts and avoid double-login.
287
384
 
288
385
  ### iOS Integration
289
386
 
@@ -9,17 +9,18 @@ import com.facebook.react.bridge.ReactMethod
9
9
  import com.facebook.react.bridge.Promise
10
10
  import com.facebook.react.bridge.WritableMap
11
11
  import com.facebook.react.bridge.Arguments
12
+ import com.facebook.react.module.annotations.ReactModule
12
13
  import com.facebook.react.modules.core.DeviceEventManagerModule
13
14
 
15
+ @ReactModule(name = VoicePnBridgeModule.NAME)
14
16
  class VoicePnBridgeModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
15
17
 
16
18
  companion object {
19
+ const val NAME = "VoicePnBridge"
17
20
  private const val TAG = "VoicePnBridgeModule"
18
21
  }
19
-
20
- override fun getName(): String {
21
- return "VoicePnBridge"
22
- }
22
+
23
+ override fun getName(): String = NAME
23
24
 
24
25
  @ReactMethod
25
26
  fun getPendingPushAction(promise: Promise) {
@@ -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.
@@ -171,6 +171,18 @@ export declare class Call {
171
171
  * Toggle mute state
172
172
  */
173
173
  toggleMute(): Promise<void>;
174
+ /**
175
+ * Send DTMF tones on this call.
176
+ *
177
+ * Each character in `digits` is sent as a Verto INFO message to the Telnyx
178
+ * platform. Valid characters are `0-9`, `A-D`, `*`, and `#`; any other
179
+ * characters are silently dropped by the underlying SDK.
180
+ *
181
+ * Only valid while the call is `ACTIVE` — will throw otherwise. Safe to call
182
+ * with a single digit (e.g. for IVR dialpad presses) or a whole string
183
+ * (e.g. `"123#"`).
184
+ */
185
+ dtmf(digits: string): Promise<void>;
174
186
  /**
175
187
  * Set the call to connecting state (used for push notification calls when answered via CallKit)
176
188
  * @internal
@@ -374,6 +374,28 @@ class Call {
374
374
  await this.mute();
375
375
  }
376
376
  }
377
+ /**
378
+ * Send DTMF tones on this call.
379
+ *
380
+ * Each character in `digits` is sent as a Verto INFO message to the Telnyx
381
+ * platform. Valid characters are `0-9`, `A-D`, `*`, and `#`; any other
382
+ * characters are silently dropped by the underlying SDK.
383
+ *
384
+ * Only valid while the call is `ACTIVE` — will throw otherwise. Safe to call
385
+ * with a single digit (e.g. for IVR dialpad presses) or a whole string
386
+ * (e.g. `"123#"`).
387
+ */
388
+ async dtmf(digits) {
389
+ if (this.currentState !== call_state_1.TelnyxCallState.ACTIVE) {
390
+ throw new Error(`Cannot send DTMF in state: ${this.currentState}`);
391
+ }
392
+ try {
393
+ await this._telnyxCall.dtmf(digits);
394
+ } catch (error) {
395
+ console.error('Failed to send DTMF:', error);
396
+ throw error;
397
+ }
398
+ }
377
399
  /**
378
400
  * Set the call to connecting state (used for push notification calls when answered via CallKit)
379
401
  * @internal
@@ -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.3.0",
3
+ "version": "0.4.0",
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",
@@ -39,7 +39,7 @@
39
39
  "android": "expo run:android",
40
40
  "ios": "expo run:ios",
41
41
  "dev:local": "npm pkg set dependencies.@telnyx/react-native-voice-sdk=file:../package",
42
- "dev:published": "npm pkg set \"dependencies.@telnyx/react-native-voice-sdk\"=\">=0.4.1\"",
42
+ "dev:published": "npm pkg set \"dependencies.@telnyx/react-native-voice-sdk\"=\">=0.4.2\"",
43
43
  "prepublishOnly": "npm run dev:published && npm install --legacy-peer-deps",
44
44
  "postpublish": "npm run dev:local && npm install --legacy-peer-deps"
45
45
  },
@@ -59,7 +59,7 @@
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"
@@ -86,7 +86,7 @@
86
86
  },
87
87
  "dependencies": {
88
88
  "@react-native-community/eslint-config": "^3.2.0",
89
- "@telnyx/react-native-voice-sdk": ">=0.4.1",
89
+ "@telnyx/react-native-voice-sdk": ">=0.4.2",
90
90
  "eventemitter3": "^5.0.1",
91
91
  "expo": "~53.0.22",
92
92
  "react-native-url-polyfill": "^3.0.0",
@@ -112,6 +112,7 @@
112
112
  "node": ">=16"
113
113
  },
114
114
  "publishConfig": {
115
- "access": "public"
115
+ "access": "public",
116
+ "provenance": true
116
117
  }
117
118
  }
@@ -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
  /**
@@ -353,6 +353,30 @@ export class Call {
353
353
  }
354
354
  }
355
355
 
356
+ /**
357
+ * Send DTMF tones on this call.
358
+ *
359
+ * Each character in `digits` is sent as a Verto INFO message to the Telnyx
360
+ * platform. Valid characters are `0-9`, `A-D`, `*`, and `#`; any other
361
+ * characters are silently dropped by the underlying SDK.
362
+ *
363
+ * Only valid while the call is `ACTIVE` — will throw otherwise. Safe to call
364
+ * with a single digit (e.g. for IVR dialpad presses) or a whole string
365
+ * (e.g. `"123#"`).
366
+ */
367
+ async dtmf(digits: string): Promise<void> {
368
+ if (this.currentState !== TelnyxCallState.ACTIVE) {
369
+ throw new Error(`Cannot send DTMF in state: ${this.currentState}`);
370
+ }
371
+
372
+ try {
373
+ await this._telnyxCall.dtmf(digits);
374
+ } catch (error) {
375
+ console.error('Failed to send DTMF:', error);
376
+ throw error;
377
+ }
378
+ }
379
+
356
380
  /**
357
381
  * Set the call to connecting state (used for push notification calls when answered via CallKit)
358
382
  * @internal
@@ -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
  }