@dialstack/sdk-native
v0.2.0
Published
React Native softphone components for the DialStack SDK
Readme
@dialstack/sdk-native
The React Native softphone components for the DialStack SDK — a headless
SoftphoneProvider, a batteries-included <Softphone>, and the composable
pieces (<DialPad> / <IncomingCall> / <OngoingCall>).
import { Softphone, SoftphoneProvider } from '@dialstack/sdk-native';React components for the web live in
@dialstack/sdk-react.
This package is self-contained: install it alone, and nothing from the web SDK
comes with it.
Install
npm install @dialstack/sdk-native \
'github:dialstack/react-native-webrtc#7e9a0eb55e068b2ba452f22e02e4b789aff9e4ed' \
react-native-incall-manager react-native-svg libphonenumber-jsThese are peer dependencies — install the versions that match your app.
Install exactly one WebRTC package. Two of them autolink two copies of the
native library and the Android build fails with Duplicate class org.webrtc.*.
We recommend DialStack's fork at the pinned commit above: it is the only build
that bridges DTMF (see below). If your app already ships a different WebRTC
package — LiveKit's, Stream's, or stock react-native-webrtc — keep that one
instead and skip ours. The SDK never imports a WebRTC package; it reads the
globals registerGlobals() installs, so any spec-compliant build works. The
react-native-webrtc peer is optional precisely so a differently-named
package can satisfy it without npm installing a second copy alongside.
Installing our fork from GitHub: its entry points are generated by its
preparescript at install time. If your npm or CI blocks install scripts, the package installs without them and the failure surfaces later asCannot find module, not as an install error — allow install scripts for that dependency.
Install the WebRTC globals
The SDK core is written to the standard browser WebRTC surface
(RTCPeerConnection, MediaStream, navigator.mediaDevices), so call
registerGlobals() from your WebRTC package at your app's entry point, before
anything imports the SDK:
// index.js
import { registerGlobals } from 'react-native-webrtc';
registerGlobals();Two RN-only gaps — outbound ringback audio and E911 persistence — are supplied
by the softphone provider (an InCallManager-backed ringback and your storage
adapter).
Permissions (required)
Without microphone permission a call fails with Call failed and no other
symptom — getUserMedia never runs, so there is nothing else to see. Declare
these before your first call.
Expo
{
"expo": {
"plugins": ["@config-plugins/react-native-webrtc"],
"android": {
"permissions": [
"android.permission.RECORD_AUDIO",
"android.permission.MODIFY_AUDIO_SETTINGS",
"android.permission.INTERNET",
"android.permission.ACCESS_NETWORK_STATE",
"android.permission.VIBRATE"
]
},
"ios": {
"infoPlist": {
"NSMicrophoneUsageDescription": "… needs the microphone to place and receive calls.",
"UIBackgroundModes": ["audio", "voip"]
}
}
}
}react-native-webrtc is a native module, so it needs a development build —
it cannot run in Expo Go. Re-run expo prebuild after changing any of the above.
The config plugin also injects permissions of its own that this SDK does not use
— including CAMERA, SYSTEM_ALERT_WINDOW and BLUETOOTH — and writes a
default camera-usage string. If a store listing or privacy review asks about
them, drop the ones you don't need with Expo's
android.blockedPermissions.
Bare React Native
android/app/src/main/AndroidManifest.xml:
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.VIBRATE" />VIBRATE is what gives the incoming call its haptic buzz: Android enforces it
by throwing rather than no-op'ing, so without it the call still rings but never
vibrates. The SDK catches that throw, so omitting the permission degrades
gracefully — it does not break the call. Neither react-native nor
react-native-incall-manager declares it, so your app must.
ios/<YourApp>/Info.plist:
<key>NSMicrophoneUsageDescription</key>
<string>… needs the microphone to place and receive calls.</string>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
<string>voip</string>
</array>DTMF
DTMF (RFC 4733 telephone-event over the media path) requires DialStack's fork
of react-native-webrtc, which adds the RTCRtpSender.dtmf bridge that no
published build ships:
npm install 'github:dialstack/react-native-webrtc#7e9a0eb55e068b2ba452f22e02e4b789aff9e4ed'With any other WebRTC package — including LiveKit's and Stream's forks, neither
of which exposes RTCRtpSender.dtmf — Call.canSendDtmf is false, the in-call
keypad stays hidden, Call.sendDtmf() throws call_failed, and the SDK logs
[dialstack] DTMF is unavailable … once per app process. Calls are otherwise
unaffected, but there is no way to navigate an IVR or phone tree. That is the
tradeoff to weigh if another library forces a particular WebRTC package on you.
Storage (required)
<SoftphoneProvider> requires a storage prop — a small PlatformStorage
adapter used to persist the selected E911 address id across launches. The SDK
takes no persistence dependency of its own (react-native-mmkv and
AsyncStorage vary across versions/architectures and can't be defaulted safely),
so you supply one:
import type { PlatformStorage } from '@dialstack/sdk-native';
import { createMMKV } from 'react-native-mmkv';
const mmkv = createMMKV();
const storage: PlatformStorage = {
getItem: (k) => mmkv.getString(k) ?? null,
setItem: (k, v) => mmkv.set(k, v),
removeItem: (k) => mmkv.remove(k),
};
<SoftphoneProvider token={token} storage={storage}>
<Softphone />
</SoftphoneProvider>;storage just needs getItem/setItem/removeItem.
Call audio
By default <SoftphoneProvider> owns call audio through
react-native-incall-manager: it rings inbound calls, plays the outbound
ringback, and holds the audio session (which also turns on the proximity sensor)
while a call is connected. The ringback goes only to a phone the provider creates
from token: a phone passed as existingPhone keeps the ringback it was
created with, so pass one to createPhone({ ringback }) yourself.
When the operating system owns the call instead, because you report calls to
CallKit or Telecom, the provider must stay out of it: the OS rings the phone and
activates the session itself, and ringing on top of it costs the answered call
its microphone. Set callAudio="host" and the provider does none of the above.
With a bridge it is always "host". Anything you still want, such as the proximity
sensor, is then yours to drive (InCallManager.startProximitySensor() does not
touch the audio session).
The output route is yours too. Without an audioOutput controller the in-call
speaker button is disabled. To enable it, pass one built over your OS integration:
const audioOutput: AudioOutputController = {
current: () => lastReportedRoute, // what the OS reports, same object until it changes
onChange: (listener) => subscribeToRouteChanges(listener),
setSpeaker: (on) => requestSpeaker(on),
};
<SoftphoneProvider bridge={bridge} audioOutput={audioOutput}>
…
</SoftphoneProvider>;The button shows only the route the OS reports. A switch made from the CallKit
or Telecom UI, a headset, or a car therefore shows up in-app as well.
useAudioOutput() exposes the same state if you build your own UI.
Example apps
Three runnable apps, all wired with the permissions and plugin above:
- Expo — ships the MMKV storage adapter
- Bare React Native — ships an AsyncStorage adapter
- OS integration
— the worked example for the OS call surface: calls on a locked or killed
phone via push wake,
OsCallAdapterover expo-callkit-telecom. Built and verified on both Android (FCM + Telecom) and iOS (PushKit + CallKit).
Scope
This package provides the in-app calling surface — the provider, the call state, and the React Native UI — plus the bridge between the SDK and the operating system's own call experience.
Binding the two remains the integrator's work, but not from scratch:
NativeCallBridge keeps an OS call session and an SDK Call in step in both
directions, and OsCallAdapter is the interface it drives, so the part you write
is one adapter over your chosen call library rather than the whole state machine.
A contract suite in @dialstack/sdk-native/testing exercises an adapter against
the behaviours the bridge depends on.
What the package does not ship is the platform wiring itself: the native call library (iOS PushKit + CallKit, Android FCM + Telecom/ConnectionService) and the push delivery. DialStack stores no device tokens — arranging the push that wakes your app is yours. The OS-integration example above is a complete worked wiring of both on Android.
The lower-level seams stay public too: useSoftphone() exposes calls,
incomingCalls, activeCall and actions.callActionsFor(call), and every
Call carries a stable id and state, so an integrator who wants to drive the
OS session directly can still do so.
