@telnyx/react-voice-commons-sdk 0.3.1 → 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,19 @@
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
+
3
17
  ## [0.3.1] (2026-04-16)
4
18
 
5
19
  ### Bug Fixing
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) {
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telnyx/react-voice-commons-sdk",
3
- "version": "0.3.1",
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
  },
@@ -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",
@@ -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