SDKs
React Native SDK: Video Calls with Hooks and Expo
Add video and voice calls to a React Native app with @voltkit-rtc/react-native: install, permissions, hooks, audio routing, background calls, chat and errors.
@voltkit-rtc/react-native is the React Native video call SDK for VoltKit RTC: video and voice calls, audio rooms and chat in a React Native app. It has the hooks of the React SDK, so a React call page ports by changing the import and its markup, and it runs the browser SDK's client on react-native-webrtc, with what a phone adds: camera and microphone permissions, audio routing to the earpiece, the loudspeaker and headsets, calls that go on in the background on Android, and an Expo config plugin. This page covers version 0.1.0, on npm, MIT licensed. A short overview is on React Native video calls; A call screen below is the whole client side of a call, and the tutorial builds it step by step.
| Kind | Exports |
|---|---|
| Provider | VoltkitProvider |
| Hooks | useRoom, useConnectionState, useParticipants, useRemoteTracks, useLocalMedia, useNetworkQuality, useWalletLow, useVoltkitEvent, useVoltkitClient |
| Media hooks | useAudioLevels, useActiveSpeakers, useIsSpeaking, useStreamFallback, usePublishFallback, useMediaWarnings, useCpuConstrained, useDevices |
| Audio, permissions and the call service | useAudioOutput, setSpeakerphoneOn, selectAudioOutput, usePermissions, requestMediaPermissions, checkMediaPermissions, useCallService |
| Components | VideoTrack, AudioTrack |
| Functions | createClient, reactNativeClientOptions, reactNativePlatform, preflight, registerVoltkitGlobals |
| Constants | SDK_VERSION |
Install
npm install @voltkit-rtc/react-native @voltkit-rtc/react @voltkit-rtc/client react-native-webrtc
The package has native Android code, so build the app again after installing it: npx react-native run-android in a React Native app, or a development build in an Expo app (React Native with Expo). Autolinking finds it. Nothing goes into MainApplication or MainActivity: a startup provider in the package sets up react-native-webrtc's audio before it starts, and release builds need no ProGuard or R8 rules of your own.
| Requirement | Value |
|---|---|
| React Native | 0.82 or newer, with the New Architecture; tested with 0.88.0 and Hermes |
| React | 19.1 or newer |
| react-native-webrtc | 155.0.1 or a later 155.x |
@voltkit-rtc/client | 0.9.12 or a later 0.9.x |
@voltkit-rtc/react | 0.1.1 or a later 0.1.x |
| Android | minSdk 24 (Android 7.0), react-native-webrtc's minimum |
| Expo | SDK 58 or newer, in a development build; not Expo Go |
| iOS | Preview: see iOS preview |
| Licence | MIT |
@voltkit-rtc/client, @voltkit-rtc/react and react-native-webrtc are peer dependencies. Install them beside the SDK, as above, so the app, the hooks and the video views share one copy of each. Importing @voltkit-rtc/react-native registers react-native-webrtc's globals, so there is no registerGlobals() to call.
TypeScript. Nothing to add to tsconfig.json: the SDK's streams and tracks are typed by shape (MediaStreamLike and MediaStreamTrackLike, react-native-webrtc's MediaStream and MediaStreamTrack), so useLocalMedia().stream?.getVideoTracks()[0] and a remote track's track compile with @react-native/typescript-config as it comes, without the DOM library, and under Expo's tsconfig.base alike.
One WebRTC per app. Do not add a second WebRTC build: a fork of react-native-webrtc, such as @livekit/react-native-webrtc or @stream-io/react-native-webrtc, or a native SDK that bundles its own, such as VoltKit's Android SDK in.voltkit:voltkit-rtc. Two copies of libwebrtc in one APK clash on native symbols. Test VoltKit in a build without your current call SDK: two call engines in one app also compete for the microphone and the audio routing.
How it relates to the browser and React SDKs
The SDK makes the same StreamClient as the JavaScript SDK, with mediasoup-client's ReactNative106 handler (a React Native environment with react-native-webrtc 106 or newer) and a platform layer for react-native-webrtc and Android. Rooms, roles, events, error codes, reconnection and video quality are the same, so those pages apply as they are.
- The hooks are the React SDK's.
useRoom,useRemoteTracks,useNetworkQualityand the rest are re-exported from@voltkit-rtc/react.VoltkitProvider,useLocalMedia,VideoTrackandAudioTrackkeep their names and are made for React Native. - What a phone adds: permissions asked before a device opens, audio routing, a foreground service for calls in the background, the client's timers run natively so a reconnect still happens with the screen off, and adaptive video from the size of each
VideoTrack. - Types are re-exported from the client (
StreamClient,JoinResult,RemoteTrack,ClosedReason,ErrorCodeand more). Values such asRpcError,ChatClientandisTerminalErrorare imported from@voltkit-rtc/client. - Not in React Native:
play(element)andautoplayFailed(remote audio plays by itself), in-app music (publishMusicrejects withmusic-unavailable), the noise suppression and video effects processors, device recording (startRecordingrejects withmedia-unavailable), andStreamClient.prepareConnection, which does nothing here. Screen sharing is Android only.
Permissions
Android, in android/app/src/main/AndroidManifest.xml. The Expo plugin writes these for you:
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- Only if you publish video: -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- Google Play reads RECORD_AUDIO and CAMERA as hardware the app cannot do without, and hides the app from
devices without them, unless the manifest marks them as not required. The camera entries go with CAMERA. -->
<uses-feature android:name="android.hardware.microphone" android:required="false" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
<uses-feature android:name="android.hardware.camera.front" android:required="false" />
INTERNET, ACCESS_NETWORK_STATE and MODIFY_AUDIO_SETTINGS come with the SDK's manifest, and Bluetooth headsets need no Bluetooth permission. The call service has entries of its own.
iOS (preview), in your app's Info.plist:
<key>NSCameraUsageDescription</key>
<string>Your camera is shown to the people in your call.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Your microphone is heard by the people in your call.</string>
publish() asks for what it is about to open, the microphone and the camera, before any device opens; a viewer who only watches is never asked. A refusal rejects publish() with RpcError media-unavailable, with data.permission ("camera" or "microphone") and data.status: "denied" when the system may ask again, "blocked" once the user has refused for good (on Android, after choosing "Don't allow" twice), when only the app's settings can grant it.
import { Linking } from "react-native";
import { RpcError } from "@voltkit-rtc/client";
import { useLocalMedia } from "@voltkit-rtc/react-native";
type PermissionProblem = { permission?: "camera" | "microphone"; status?: "denied" | "blocked" };
export function useStartMedia(showProblem: (text: string, fix?: () => void) => void) {
const local = useLocalMedia();
return async function start() {
try {
await local.publish();
} catch (err) {
const problem =
err instanceof RpcError && err.code === "media-unavailable" ? (err.data as PermissionProblem | undefined) : undefined;
if (problem?.status === "blocked") {
showProblem(`Allow the ${problem.permission} in Settings to join in.`, () => void Linking.openSettings());
} else if (problem?.permission) {
showProblem(`The ${problem.permission} is needed to join in.`);
} else {
showProblem("Could not start the camera and microphone.");
}
}
};
}
To ask earlier, on a screen before the call, requestMediaPermissions(["camera", "microphone"]) shows the system's dialogs for those not granted yet and resolves to each one's status: "granted", "denied" (the dialog may still show) or "blocked". It never rejects. checkMediaPermissions(kinds) reads the statuses without asking, and usePermissions(kinds) keeps them as state, checked again whenever the app comes back to the front. On Android 13 and later, ask for "notifications" too, so the call's notification shows in the notification shade; the call works without it, and older Android versions have nothing to ask:
import { Button, Linking, Text, View } from "react-native";
import { requestMediaPermissions, usePermissions } from "@voltkit-rtc/react-native";
/** A screen before the call: the camera and microphone, and the call notification on the way. */
export function BeforeTheCall({ onReady }: { onReady: () => void }) {
const media = usePermissions(["camera", "microphone"]);
const blocked = media.statuses?.camera === "blocked" || media.statuses?.microphone === "blocked";
async function ask() {
const statuses = await media.request();
await requestMediaPermissions(["notifications"]);
if (statuses.camera === "granted" && statuses.microphone === "granted") onReady();
}
return (
<View>
{blocked && <Text>Allow the camera and microphone in Settings to join in.</Text>}
<Button title={blocked ? "Open Settings" : "Continue"} onPress={blocked ? () => void Linking.openSettings() : ask} />
</View>
);
}
Get a token from your backend
The app never holds your API secret. Your backend checks who the user is, decides the room and the role, and returns a short-lived token and the wsUrl to connect to. Token server builds that endpoint in several languages, and Authentication explains tokens and roles. If you have not created a project yet, the Quickstart takes you from sign-up to a first room. In the app, ask your endpoint right before joining, with your app's own sign-in:
// token.ts
export type Credentials = { token: string; wsUrl: string };
export async function fetchCredentials(callId: string, accessToken: string): Promise<Credentials> {
const res = await fetch(`https://api.example.com/calls/${encodeURIComponent(callId)}/token`, {
method: "POST",
headers: { Authorization: `Bearer ${accessToken}` }, // your app's sign-in, never the RTC secret
});
if (!res.ok) throw new Error(`Could not get a token (${res.status}).`);
return (await res.json()) as Credentials;
}
Tokens are short-lived (ten minutes by default), and the service refreshes them during the session. Production wsUrls start with wss://. Android refuses ws:// and http:// in a release build unless the app allows cleartext traffic, so allow it only for a test server on your own network, in a debug build.
A call screen
The client side of a call: join, your camera and microphone, everyone's video, mute, camera off, flip, loudspeaker and leave. Remote sound needs no component: react-native-webrtc plays it.
// CallScreen.tsx
import { useState } from "react";
import { Button, StyleSheet, Text, View } from "react-native";
import {
VideoTrack,
VoltkitProvider,
useAudioOutput,
useLocalMedia,
useRemoteTracks,
useRoom,
type ClosedReason,
} from "@voltkit-rtc/react-native";
import { fetchCredentials, type Credentials } from "./token";
const ENDED: Record<ClosedReason, string> = {
left: "You left the call.",
kicked: "You were removed from the call.",
"room-closed": "The call has ended.",
replaced: "You joined this call from another device.",
"gave-up": "The connection was lost.",
};
export function CallScreen({ callId, accessToken }: { callId: string; accessToken: string }) {
const [credentials, setCredentials] = useState<Credentials | null>(null);
const [attempt, setAttempt] = useState(0);
const [notice, setNotice] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function join() {
setBusy(true);
setNotice(null);
try {
setCredentials(await fetchCredentials(callId, accessToken));
setAttempt((n) => n + 1); // a new key: a new provider with a fresh client
} catch (err) {
setNotice((err as Error).message);
} finally {
setBusy(false);
}
}
if (!credentials) {
return (
<View style={styles.center}>
{notice && <Text>{notice}</Text>}
<Button title={busy ? "Joining…" : "Join the call"} onPress={join} disabled={busy} />
</View>
);
}
return (
<VoltkitProvider
key={attempt}
wsUrl={credentials.wsUrl}
token={credentials.token}
onError={(err) => console.warn("[voltkit]", err.message)} // warnings too: see Errors
onClosed={(reason) => {
setCredentials(null);
setNotice(ENDED[reason]);
}}
>
<Call onRetry={join} />
</VoltkitProvider>
);
}
function Call({ onRetry }: { onRetry: () => void }) {
const { state, info, error, leave } = useRoom();
const local = useLocalMedia();
const audio = useAudioOutput();
const videos = useRemoteTracks({ kind: "video" });
const [problem, setProblem] = useState<string | null>(null);
const report = (err: Error) => setProblem(err.message);
if (state === "idle" && error) {
return (
<View style={styles.center}>
<Text>Could not join: {error.message}</Text>
<Button title="Try again" onPress={onRetry} />
</View>
);
}
if (state === "idle" || state === "connecting") {
return (
<View style={styles.center}>
<Text>Joining…</Text>
</View>
);
}
const canPublish = info?.role === "host" || info?.role === "cohost";
const camera = local.stream?.getVideoTracks()[0];
return (
<View style={styles.screen}>
{state === "reconnecting" && <Text>Reconnecting…</Text>}
{problem && <Text>{problem}</Text>}
<View style={styles.tiles}>
{camera && <VideoTrack track={camera} mirror={local.facingMode === "user"} style={styles.tile} />}
{videos.map((t) => (
<VideoTrack key={t.producerId} track={t.track} producerId={t.producerId} objectFit="cover" style={styles.tile} />
))}
</View>
<View style={styles.controls}>
{canPublish && !local.stream && (
<Button title="Start camera and microphone" onPress={() => local.publish().catch(report)} />
)}
{local.stream && (
<>
<Button title={local.micEnabled ? "Mute" : "Unmute"} onPress={() => local.setMicEnabled(!local.micEnabled).catch(report)} />
<Button title={local.camEnabled ? "Camera off" : "Camera on"} onPress={() => local.setCamEnabled(!local.camEnabled).catch(report)} />
<Button title="Flip" onPress={() => local.switchCamera().catch(report)} />
</>
)}
{audio.supported && (
<Button
title={audio.speakerphone ? "Earpiece" : "Loudspeaker"}
onPress={() => audio.setSpeakerphoneOn(!audio.speakerphone).catch(report)}
/>
)}
<Button title="Leave" onPress={leave} />
</View>
</View>
);
}
const styles = StyleSheet.create({
center: { flex: 1, alignItems: "center", justifyContent: "center", gap: 12 },
screen: { flex: 1, backgroundColor: "#000" },
tiles: { flex: 1, flexDirection: "row", flexWrap: "wrap" },
tile: { width: "50%", aspectRatio: 3 / 4 },
controls: { flexDirection: "row", flexWrap: "wrap", justifyContent: "center", gap: 8, padding: 12 },
});
// App.tsx
import { View } from "react-native";
import { CallScreen } from "./CallScreen";
export default function App() {
// Your own sign-in gives the access token; the call id comes from your app's data.
return (
<View style={{ flex: 1 }}>
<CallScreen callId="call-42" accessToken="your-session-token" />
</View>
);
}
What each part does:
- The provider makes the client, connects it, and closes it when it unmounts. On joining it takes the phone's call audio (call mode, audio focus, where sound plays) and, if the app declares it, starts the call service; it lets both go when the call ends.
onErrorhears what is not the answer to a call you made: warnings, a call service that could not start, and in Expo Go the missing native code.onClosedsays why a session ended. A closed session stays closed, so joining again mounts a new provider (a newkey) with a fresh token. publish()asks for the microphone and camera permissions, then opens the devices and publishes them; only hosts and co-hosts may. With no options it sends the highest quality the project allows, 1080p unless your project's settings say otherwise. That is your users' mobile data and your egress: pass{ quality: "720p" }where they matter more than the picture.- Your own tile is mirrored while the front camera is in use.
producerIdon a remoteVideoTrackturns on adaptive video: it is received at the size it is laid out, and paused while it is hidden or the app is in the background. - The loudspeaker button moves call audio between the loudspeaker and the earpiece; a connected headset still wins. Calls start on the loudspeaker. For a voice call held to the ear, pass
audio={{ speakerphone: false }}to the provider. - The hooks follow the client. After
setMicEnabled(false),micEnabledis false in every component that usesuseLocalMedia(), and a reconnect shows asreconnectingwithout unmounting anything.
VoltkitProvider
VoltkitProvider holds one StreamClient for the components below it. It takes the React provider's props and three of its own.
| Prop | Type | What it does |
|---|---|---|
wsUrl | string | The room's WebSocket URL, from your backend with the token |
token | string | A room token your backend minted |
connect | boolean | Connect the client when it is idle. Default true; with false, call useRoom().connect() yourself |
onConnected | (result: JoinResult) => void | After every successful join: the first, and each automatic reconnect |
onError | (error: Error) => void | Every error event (warnings included), a failed connect, a token the session refused, and a call service that could not start |
onClosed | (reason: ClosedReason) => void | The session ended for good: left, kicked, room-closed, gave-up or replaced |
client | StreamClient or null | A client you made with createClient and own, used instead of wsUrl and token |
clientOptions | ClientOptions | Options for the client the provider makes, laid over React Native's own. Read once, when the client is made |
audio | { speakerphone?, manage? } or false | speakerphone: the loudspeaker (true, the default) or the earpiece (false) when no headset is connected, unless setSpeakerphoneOn chose. false, or manage: false, leaves the phone's audio to your app |
background | { pauseCamera?, pauseMic? } | What switches off while the app is in the background: the camera (pauseCamera, default true) and the microphone (pauseMic, default false) |
callService | boolean or { title?, text?, leaveLabel? } | Start the call service when the call connects, if the app declares it. Default true; an object changes the notification's words |
- The client it makes has the options
reactNativeClientOptions({ background, clientOptions })returns: the mediasoup handler (ReactNative106), the React Native platform, andbackgroundas the client'spauseCameraInBackgroundandpauseMicInBackground, with theclientOptionsyou give laid over them. A client you make yourself for theclientprop comes fromcreateClient(wsUrl, token, { background, clientOptions }): a plainnew StreamClient(...)would look for a browser. GivecreateClientand the provider the samebackground: the client's copy decides what pauses, the provider's whether the call service runs with thecameratype for a camera kept on in the background. - Where the native code is missing, nothing throws and the hooks keep their defaults: in Expo Go, or an app not built again after installing react-native-webrtc, the provider makes no client and
onErrorgetsmedia-unavailablewithdata.reasonwebrtc-missing; on Android without the SDK's own native module,data.reasonisnative-missing, and the call works without background calls, audio routing and native timers. - A new
tokenfor the same session (the same project, room and identity, read from the token) goes to the live session withupdateToken, with no reconnect: that is how you refresh a token. A token for another room or identity closes the client and makes one for the new session. A newwsUrlreplaces the client, and takingtokenorwsUrlaway closes it. - A closed session stays closed. To join again, render the provider with a new
keyand a fresh token, as the call screen does. - With
clientthe provider uses yours as it is: it connects it when it is idle (unlessconnectisfalse) and never closes it. - The callbacks are always the latest you passed. A client the provider lets go of, on unmount or for a new
wsUrl, reports nothing more.
Hooks
The hooks are the React SDK's, with the same names, returns and defaults. Every hook returns its default until the provider has a client, then follows the client and renders again only when what it returns has changed. A hook outside any VoltkitProvider does not throw: it keeps returning its default.
| Hook | Returns | Default |
|---|---|---|
useRoom() | client, info, state, error, connect(), leave() | state is "idle"; client, info and error are null |
useConnectionState() | "idle", "connecting", "connected", "reconnecting" or "closed" | "idle" |
useParticipants() | Everyone in the room, you included | [] |
useRemoteTracks(filter?) | Every track this client receives: producerId, identity, kind, source, track, paused | [] |
useLocalMedia() | Your camera, microphone and screen, and the calls that change them, with switchCamera() and facingMode | nothing published |
useNetworkQuality() | level, rttMs, lossPct, availableKbps, about every 3 s | null |
useWalletLow() | active, remainingSeconds, notice | not active |
useVoltkitEvent(event, handler) | nothing: calls handler for each event while mounted | |
useVoltkitClient() | the provider's StreamClient | null |
useAudioOutput() | supported, outputs, current, speakerphone, select(deviceId), setSpeakerphoneOn(on): see Audio routing | |
usePermissions(kinds) | statuses (each one "granted", "denied" or "blocked"), granted, request(), refresh(): see Permissions | statuses is null until first checked |
useCallService() | declared, running, types: see Calls in the background |
The media hooks are the React SDK's too: useAudioLevels, useActiveSpeakers, useIsSpeaking(identity), useStreamFallback(producerId), usePublishFallback, useMediaWarnings, useCpuConstrained and useDevices(kind?). Two differences on a phone:
- Sound levels are read from the connection's statistics every 500 ms, where a browser reads them about four times a second, so
useAudioLevels,useActiveSpeakersanduseIsSpeakingchange twice a second. useDeviceslists the cameras ("videoinput", each withfacing:front,backorexternal), the microphones ("audioinput") and, on Android, the outputs ("audiooutput"), each with itstype. For an output picker,useAudioOutput()is simpler.
useMediaWarnings() also reports mic-interrupted while the system takes the microphone, such as during a phone call.
Components
VideoTrack
VideoTrack shows one video track in a native view (react-native-webrtc's RTCView).
| Prop | |
|---|---|
track | The video to show: a remote track's track, or your own camera, useLocalMedia().stream?.getVideoTracks()[0]. Nothing while null or undefined |
producerId | A remote video's producerId. The client then receives it at the size this view is laid out, and pauses it while the view is hidden or the app is in the background. Leave it out for your own camera |
mirror | Flip it horizontally, for your own front camera |
objectFit | "cover" (the default) fills the view and crops the edges; "contain" shows the whole picture, with bars |
zOrder | Which of two overlapping video views is drawn on top, as for RTCView: give a small preview over a full-screen video the higher number |
visible | false while the view is off screen or covered: with producerId, the video is paused at the server until it is true again. Default true |
onDimensionsChange | Called with the video's width and height when it first shows and when they change |
style, testID | As for any view |
Each track is shown through one native stream that the SDK makes, shares between the views of that track, and releases before the client closes the connection the track came on. Do not wrap tracks in new MediaStream() for an RTCView of your own: react-native-webrtc ends the app when a stream is released after its track's connection has closed, which happens on every reconnect and when a call ends.
AudioTrack
Remote sound plays by itself, so a call needs no AudioTrack. Render one only to change one person's volume, or to mute them on this phone. It shows nothing.
| Prop | |
|---|---|
track | A remote audio track. Nothing while null or undefined |
volume | 0 to 1: this track's playback volume, where 1 is the track as received. Unset leaves it as it is |
muted | Silence this track here only; it plays again when the prop is false or the component unmounts |
Audio routing
While a client under a provider is connected, the SDK runs the phone's call audio: it switches to call mode, takes audio focus, and plays sound on a Bluetooth headset, else a wired or USB headset, else the loudspeaker or the earpiece. When the last client closes, it puts everything back as it found it. Two clients in one app, such as the two rooms of a PK battle, share it. The call audio and the call service are the provider's work: a client you connect yourself, outside any provider, gets neither, so give such a client to a provider's client prop.
import { Pressable, Text, View } from "react-native";
import { useAudioOutput } from "@voltkit-rtc/react-native";
const NAMES: Record<string, string> = { speaker: "Loudspeaker", earpiece: "Phone", wiredHeadset: "Headset", bluetooth: "Bluetooth", usb: "USB" };
export function OutputPicker() {
const audio = useAudioOutput();
if (!audio.supported) return null; // false on iOS (preview) and in the media audio mode
return (
<View>
{audio.outputs.map((device) => (
<Pressable key={device.deviceId} onPress={() => void audio.select(device.deviceId)}>
<Text style={{ fontWeight: device.deviceId === audio.current?.deviceId ? "bold" : "normal" }}>
{device.label || NAMES[device.type ?? ""] || device.deviceId}
</Text>
</Pressable>
))}
</View>
);
}
- Where sound plays now is
audio.current; draw its icon fromtype:speaker,earpiece,wiredHeadset,bluetoothorusb. Headsets that come and go are followed. select(deviceId)plays call audio on that output while it stays connected, even over a headset; an output that is not there rejects withbad-params.setSpeakerphoneOn(true)picks the loudspeaker when no headset is connected, andfalsethe earpiece; a headset still wins. Both are also plain functions,selectAudioOutput(deviceId)andsetSpeakerphoneOn(on), which work before any client exists, so a screen before the call can set them.client.switchDevice("audiooutput", deviceId), fromuseVoltkitClient(), does whatselectdoes.- Android 12 and later route call audio with
setCommunicationDevice. On Android 7 to 11, Bluetooth call audio goes over SCO, which some headsets and car kits refuse. A headset that refuses, or says nothing for 3 seconds, is passed over: call audio goes to the next output, and that headset is not tried again until the user picks it, a device is added, or the call ends. - No Bluetooth permission is needed: the routing goes through Android's
AudioManager. - Echo on one phone model. The phone's own echo canceller and noise suppressor are used where it has them. If one model echoes, turn them off with the Expo plugin's
android.hardwareEchoCancellation: false(or thein.voltkit.rtc.HW_AECmeta-data, below), and WebRTC's own echo canceller does the work. - Apps whose users only listen can set the audio mode to
media(the plugin'sandroid.audioMode, or thein.voltkit.rtc.AUDIO_MODEmeta-data): sound plays at the media volume and on Bluetooth's high-quality profile, with no earpiece, and outputs follow the system:useAudioOutput().supportedis false, andselectandswitchDevice("audiooutput", ...)reject withbad-params. It is fixed for the life of the app's process: never use it where anyone may speak, since a listener brought on stage would echo. audio={{ manage: false }}on the provider leaves the phone's audio mode, focus and routing to your app.- The emulator has only the loudspeaker. Test the earpiece and headsets on a phone.
In a React Native app without Expo, the audio settings are meta-data in AndroidManifest.xml, read once when the app starts. These are the defaults:
<application>
<!-- communication (calls and audio rooms) or media (listen-only apps) -->
<meta-data android:name="in.voltkit.rtc.AUDIO_MODE" android:value="communication" />
<!-- the phone's own echo canceller and noise suppressor, where it has them -->
<meta-data android:name="in.voltkit.rtc.HW_AEC" android:value="true" />
<!-- react-native-webrtc's screen-capture service: see Screen sharing -->
<meta-data android:name="in.voltkit.rtc.SCREEN_SHARE" android:value="false" />
</application>
On iOS (preview) the SDK does not route audio: useAudioOutput().supported is false, and setSpeakerphoneOn and selectAudioOutput reject with media-unavailable (data.platform is "ios").
Cameras and microphones
import { useLocalMedia } from "@voltkit-rtc/react-native";
export function useBackCameraFirst() {
const local = useLocalMedia();
return async function start(): Promise<boolean> {
await local.publish({ facingMode: "environment" }); // start on the back camera
return local.switchCamera(); // resolves true: the front camera is in use now
};
}
switchCamera()turns the published camera to the other side on the same track and producer, so viewers see no gap, and resolves to whether the front camera is now in use;facingModesays the same ("user"or"environment"). Before a camera is published it rejects withmedia-unavailable.- Any camera:
useDevices("videoinput")lists them, each withfacing, andclient.switchDevice("videoinput", deviceId)switches the published one. An id no camera has rejects withbad-params; a switch that fails rejects withcamera-failed, and the old camera stays on air. - Microphones:
useDevices("audioinput")lists them, andclient.switchDevice("audioinput", deviceId)records from another one, such as a wired headset's, without opening a new track.
Calls in the background
Android stops a call that has gone to the background unless the app runs a foreground service that it started while it was visible:
- The network. Without one, Android cut the test app's connections about 6 seconds after it went to the background or the screen went off, and the call dropped (measured on the Android emulator while building this SDK).
- The microphone. An app in the background records silence unless a foreground service is running, which from Android 11 must have the
microphonetype. - Sound. Android 17 blocks background audio for an app that has neither a visible activity nor a foreground service.
- Timers. React Native's JavaScript timers stop in the background and while the screen is off. The SDK runs the client's own timers natively (the reconnect backoff, the connection checks), so with the service keeping the app alive, a call still reconnects with the screen off.
The SDK's foreground service, VoltkitCallService, keeps the call going. The provider starts it when the call connects, while the app is visible, and stops it when the call ends. It is not in the SDK's own manifest: the app declares it, so an app that never runs calls in the background has nothing to declare to Google Play.
Declaring the call service
In an Expo app, the config plugin declares it unless you set android.callService to false: see React Native with Expo. In a React Native app without Expo, add this to android/app/src/main/AndroidManifest.xml, then build the app again:
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<!-- Android 13+: shows the call notification in the notification shade (ask for it at run time). -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application>
<!-- Inside your existing <application> element. -->
<service
android:name="com.voltkit.rtc.reactnative.VoltkitCallService"
android:exported="false"
android:foregroundServiceType="microphone|mediaPlayback" />
</application>
</manifest>
The service picks its foreground service types from what the app declares, what the user has granted and what the call publishes:
| Type | When the service runs with it |
|---|---|
microphone | RECORD_AUDIO is granted: the call keeps its microphone |
mediaPlayback | Otherwise: a listener, who keeps hearing the call |
camera | Only if you declare it, with FOREGROUND_SERVICE_CAMERA, and keep the camera on in the background with background={{ pauseCamera: false }} |
mediaProjection | Only while a screen share is live, if you declare it (with FOREGROUND_SERVICE_MEDIA_PROJECTION on Android 14 and later) |
useCallService() says whether the app declares the service (declared), whether it runs (running) and with which types (types). If it cannot start, because a permission is missing from the manifest or the app was already in the background, onError gets media-unavailable with data.feature set to "callService" and a message that names what is missing. The call goes on, in the foreground only. callService={false} on the provider never starts it.
What happens in the background
The app counts as in the background while none of its activities is started, as Android's process lifecycle says. A permission dialog or the screen-share consent dialog on top of the app does not count. In the background:
- Received videos shown with a
producerIdpause at the server, so they use no data, and resume when the app returns. The sound keeps playing. - The camera switches off, so viewers see the camera off rather than a frozen picture, and back on when the app returns, unless the user or a moderator switched it meanwhile.
background={{ pauseCamera: false }}keeps it on, which needs the service'scameratype; without it Android stops a background app's camera anyway. - The microphone keeps sending, as a call needs.
background={{ pauseMic: true }}switches it off too; leave that off for calls. - What the user switches while the app is in the background is theirs to keep. A camera that comes on air there through something started before (a publish finishing, a reconnect publishing again) is held off until the app returns.
The notification
While the service runs, Android shows its notification: "Call in progress", "Tap to return to the call", and a Leave button. Tapping it opens the app; Leave ends the call (onClosed gets left, and the others see you leave). Change the words on the provider:
import type { ReactNode } from "react";
import { VoltkitProvider } from "@voltkit-rtc/react-native";
export function CallProvider({ wsUrl, token, children }: { wsUrl: string; token: string; children: ReactNode }) {
return (
<VoltkitProvider
wsUrl={wsUrl}
token={token}
callService={{ title: "Call with Asha", text: "Tap to return to the call", leaveLabel: "Hang up" }}
>
{children}
</VoltkitProvider>
);
}
The small icon is your app's drawable named voltkit_call_notification_icon (or notification_icon, or ic_notification), and the SDK's own white handset when there is none; never the launcher icon, which Android would draw as a solid square. On Android 13 and later the notification shows in the notification shade only if the user allowed notifications; the service runs either way. Swiping the app away from the recent apps ends the call: the others see you leave, and the service stops.
Google Play's declaration
An app that targets Android 14 or later and declares foreground service types declares each type in Play Console, under Monitor and improve → App content: what the feature does, what happens to the user if the task is deferred or interrupted, and a link to a video that shows a user starting the feature. For the call service, for example:
- Microphone: "Keeps the user's call audio going when they switch apps or turn the screen off during a call they started." The video starts a call, goes to the home screen, and keeps talking.
- Media playback: "Keeps a call audible for a listener who leaves the app." The video joins a room as a listener, goes to the home screen, and keeps listening.
- Camera and media projection, if you declare them: the camera kept on in the background, and screen sharing.
Without the service (the plugin's android.callService: false, or no <service> block) there is nothing to declare, and a call drops about 6 seconds after the app leaves the screen.
Xiaomi, Redmi and POCO phones
Some phone makers' battery savers stop background apps beyond what Android itself does, and Xiaomi's are known for it. VoltKit has tested the call service on the Android emulator, not on these phones. If calls end in the background on them although useCallService().running was true, ask users to set your app's battery saver to "No restrictions" in the app's settings, and to allow Autostart.
iOS
In the iOS preview there is no call service. Calls that keep their sound in the background need the audio background mode (UIBackgroundModes), which the Expo plugin adds unless you set ios.backgroundAudio to false. VoltKit has not tested background calls on iOS.
Screen sharing
Screen sharing runs on Android, through react-native-webrtc's screen-capture service. Turn it on with the Expo plugin's android.screenShare: true, or in a React Native app without Expo add the permission and the meta-data, and the mediaProjection type to the call service:
<!-- FOREGROUND_SERVICE is in the call service's entries above; react-native-webrtc's service needs it too. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<application>
<meta-data android:name="in.voltkit.rtc.SCREEN_SHARE" android:value="true" />
<service
android:name="com.voltkit.rtc.reactnative.VoltkitCallService"
android:exported="false"
android:foregroundServiceType="microphone|mediaPlayback|mediaProjection" />
</application>
import { Button } from "react-native";
import { RpcError } from "@voltkit-rtc/client";
import { useLocalMedia } from "@voltkit-rtc/react-native";
export function ShareScreenButton({ onProblem }: { onProblem: (text: string) => void }) {
const local = useLocalMedia();
async function toggle() {
try {
if (local.screenSharing) await local.stopScreenShare();
else await local.startScreenShare(); // Android asks the user first
} catch (err) {
const code = err instanceof RpcError ? err.code : "";
if (code === "screen-share-denied") return; // the user said no: nothing to show
if (code === "screen-share-service-required") onProblem("Screen sharing is not set up in this build.");
else if (code === "screen-share-unsupported") onProblem("Screen sharing needs Android.");
else onProblem("Could not share the screen.");
}
}
return <Button title={local.screenSharing ? "Stop sharing" : "Share screen"} onPress={toggle} />;
}
- Android asks the user before every share; on recent versions they choose one app or the entire screen. The screen goes out beside the camera as a second video, with
sourcescreen, and without sound. - Stopping from the status bar or the notification ends the share too (
screenShareEnded), and from Android 15 QPR1 the system also ends it when the screen locks. The SDK stops react-native-webrtc's screen-capture service when a share ends. screen-share-service-required: the build does not have the screen-capture service.screen-share-denied: the user cancelled.screen-share-unsupported: iOS.screen-share-active: a share is already running or starting.screen-share-failed: the capture could not start, with the reason in the message.
More on screen sharing in React Native setup.
Video quality
publish() takes the browser SDK's publish options (quality, frameRate, audioQuality, layers, codec, degradationPreference), and audio, video and facingMode as in the React SDK. setVideoQuality, setVideoFrameRate and setAudioQuality change them while live, within your project's settings.
- A phone sends two copies of its camera, half and full size, so each viewer receives the one their connection can carry.
codec: "auto"picks H.264 only where the phone has a hardware H.264 encoder, and VP8 everywhere else.- 60 fps is delivered only when the capture source provides it. Phone makers usually keep 60 fps for their own camera app, so most apps get 30 fps;
setVideoFrameRate(60)then resolves to30with aframe-rate-fallbackwarning.
See Video quality for every quality and its data use.
Chat
There are no chat hooks: chat is on the client the provider holds. Listen with useVoltkitEvent and send with useVoltkitClient():
import { useState } from "react";
import { Button, FlatList, Text, TextInput, View } from "react-native";
import { RpcError, type ChatMessage } from "@voltkit-rtc/client";
import { useVoltkitClient, useVoltkitEvent } from "@voltkit-rtc/react-native";
export function Chat() {
const client = useVoltkitClient();
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [draft, setDraft] = useState("");
const [notice, setNotice] = useState<string | null>(null);
useVoltkitEvent("message", (m) => setMessages((list) => [...list, m])); // yours included
useVoltkitEvent("messageDeleted", ({ id }) => setMessages((list) => list.filter((m) => m.id !== id)));
async function send() {
if (!client || !draft.trim()) return;
try {
await client.sendMessage(draft);
setDraft("");
setNotice(null);
} catch (err) {
if (err instanceof RpcError && err.code === "chat-muted") setNotice("A moderator has muted you in this chat.");
else if (err instanceof RpcError && err.code === "rate-limited") setNotice("Slow down a little.");
else setNotice("Your message was not sent.");
}
}
return (
<View style={{ flex: 1 }}>
<FlatList
data={messages}
keyExtractor={(m) => m.id}
renderItem={({ item }) => (
<Text>
<Text style={{ fontWeight: "bold" }}>{item.from}</Text> {item.text}
</Text>
)}
/>
{notice && <Text>{notice}</Text>}
<TextInput value={draft} onChangeText={setDraft} onSubmitEditing={() => void send()} placeholder="Message" />
<Button title="Send" onPress={() => void send()} disabled={!client} />
</View>
);
}
Render it inside the provider. A Text shows a message as text, whatever it contains. client.getMessages({ limit: 50 }) returns earlier messages. Chat without a room uses ChatClient from @voltkit-rtc/client. Direct messages, moderation and history are on Chat.
Moderation, seats and audio rooms
Everything that is not state is on the client: useVoltkitClient() returns it, and useVoltkitEvent() listens to any of its events. A listener raises a hand with raiseHand(), the host brings them on stage with promote(identity, "cohost"), and a moderator uses forceMute(identity, "audio"), kick(identity, reason) and endRoom(); seats are claim(key) and blockSlot(key). The service enforces each one from the role in the token.
import { useState } from "react";
import { Button, Text, View } from "react-native";
import { useVoltkitClient, useVoltkitEvent } from "@voltkit-rtc/react-native";
/** For the host: who raised a hand, and a button to bring each one on stage. */
export function RaisedHands() {
const client = useVoltkitClient();
const [hands, setHands] = useState<string[]>([]);
useVoltkitEvent("handRaised", ({ identity, handRaised }) =>
setHands((list) => [...list.filter((i) => i !== identity), ...(handRaised ? [identity] : [])]),
);
return (
<View>
{hands.map((identity) => (
<View key={identity}>
<Text>{identity} raised a hand</Text>
<Button title="Bring on stage" onPress={() => void client?.promote(identity, "cohost")} />
</View>
))}
</View>
);
}
One-to-one voice calls and Group audio rooms build both kinds of app; their calls have the same names here. For a voice call held to the ear, give the provider audio={{ speakerphone: false }}, and publish the microphone alone with publish({ video: false }).
Events and state
The hooks are state: what the room looks like now. useVoltkitEvent(event, handler) is for moments, such as someone joining or a notice to show. It listens to any client event while the component is mounted, and always calls the latest handler, so an inline function is fine. Every event and what it carries is in the JavaScript SDK's event table.
Token refresh
The service sends the session a fresh token over its connection by itself, so usually there is nothing to do. When it cannot (an older server, or a project that is out of credit, at a usage limit or suspended), the client fires tokenExpiring about a minute before its token expires. Mint a new one and render the provider with it:
import { useVoltkitEvent } from "@voltkit-rtc/react-native";
import { fetchCredentials } from "./token";
// Inside the provider, beside <Call />:
// <RefreshToken callId={callId} accessToken={accessToken} onToken={(token) => setCredentials((c) => (c ? { ...c, token } : c))} />
export function RefreshToken(props: { callId: string; accessToken: string; onToken: (token: string) => void }) {
useVoltkitEvent("tokenExpiring", async () => {
try {
const { token } = await fetchCredentials(props.callId, props.accessToken);
props.onToken(token);
} catch {
// The session keeps its token: only a reconnect after it has expired needs a new one.
}
});
return null;
}
The provider sees a new token for the same session and hands it to the live session, with no reconnect. A refresh the server refuses (bad-token) is reported through onError, and the session keeps its own token.
Wallet notices
With the VoltKit Wallet, the session is warned while the credits the project may spend cover less than about a minute. useWalletLow() turns the warnings into state:
import { Text } from "react-native";
import { useWalletLow } from "@voltkit-rtc/react-native";
export function CreditBanner() {
const { active, remainingSeconds } = useWalletLow();
if (!active) return null;
return <Text accessibilityRole="alert">Call credit runs out in about {remainingSeconds} s.</Text>;
}
A recharge clears it. If the credits run out, the room closes: onClosed gets room-closed, and roomClosed carries the reason quota-exhausted.
Errors
VoltKit's errors are RpcErrors from @voltkit-rtc/client, each with a stable code: the SDK's own, or the service's when it refused something. Branch on err.code, show your own words, and log err.message. err.data carries the details, such as which permission was refused. Errors lists every code.
Where errors reach you
| Where | What arrives |
|---|---|
onError on the provider | Every error event from the client, failures and warnings alike, a failed connect, a token the session refused, and a call service that could not start |
useRoom().error | The last of those, until a join succeeds |
| The promise of the call | useRoom().connect(), publish(), switchCamera() and every other useLocalMedia() call, and the client's own calls such as sendMessage(), reject with the error |
onClosed | The session ended for good, and why |
VideoTrack and AudioTrack throw nothing. Without a client (outside a provider, or before it has made one) every useLocalMedia() call rejects with not-connected. Where react-native-webrtc's native code is missing (Expo Go, or an app not built again), the provider makes no client at all: only onError hears it, with media-unavailable and data.reason webrtc-missing, while useRoom().state stays idle, so log onError while you develop. Some error events are warnings, such as frame-rate-fallback or audio-quality-not-allowed: something goes out lower than asked and nothing else changes, so do not end the call on them. The React SDK's error handling applies as it is: the warnings, the join problems and the reasons a call ended.
When joining fails
The provider connects once and does not retry a connect that failed: onError and useRoom().error get the error, and the state goes back to idle. isTerminalError(code) from @voltkit-rtc/client says whether the same token would be refused again. Turn the code into words for the user:
import { RpcError, isTerminalError } from "@voltkit-rtc/client";
export function joinProblem(err: Error): { text: string; canRetry: boolean } {
const code = err instanceof RpcError ? err.code : "";
switch (code) {
case "bad-token":
case "no-token":
return { text: "Your session expired. Join again.", canRetry: true }; // a fresh token fixes it
case "room-ended":
case "no-room":
return { text: "This call has ended.", canRetry: false };
case "kicked":
return { text: "You were removed from this call.", canRetry: false };
case "quota-exhausted":
case "daily-limit-reached":
case "monthly-limit-reached":
case "tenant-suspended":
case "billing-unavailable":
return { text: "Calls are unavailable right now.", canRetry: false }; // your project's account, not the user
case "participant-quota":
case "no-capacity":
return { text: "The call is full right now. Try again in a moment.", canRetry: true };
default:
return { text: "Could not connect. Check your connection and try again.", canRetry: !isTerminalError(code) };
}
}
When publishing fails
publish() refuses before it opens any device, so the camera light never comes on for a publish that cannot happen:
code | When |
|---|---|
media-unavailable | A permission was refused: data.permission and data.status, see Permissions |
not-connected | The session is not connected, there is no client yet, or the session ended while the permission dialog was open |
forbidden | Your role is not host or cohost |
source-already-published | A camera and microphone are already published, or a publish is under way: unpublish() first |
media-disabled | The project switched that kind off. Check permissions.audio and permissions.video, or pass audio: false or video: false |
A camera or microphone that another app holds, or that fails to open, rejects with media-unavailable, with data.reason set to the capture error's name (or capture-failed) and its words in the message: offer audio only with publish({ video: false }). The other calls refuse with codes too: setMicEnabled(true) and setCamEnabled(true) with force-muted while a moderator holds that device off, switchCamera() with media-unavailable before a camera is published, and startScreenShare() with the screen-share codes.
Codes from React Native
code | What happened | What to do |
|---|---|---|
media-unavailable with data.permission | The user refused the camera or microphone; data.status is denied or blocked | Ask again, or send them to the app's settings when it is blocked |
media-unavailable with data.reason webrtc-missing | The app has no react-native-webrtc native code: Expo Go, or an app not built again after installing the SDK. The message says it needs a development build | Build the app again; use a development build, not Expo Go |
media-unavailable with data.reason native-missing | The SDK's own native module is not in the app (Android): background calls, audio routing and native timers are off; data.feature names what asked | Build the app again |
media-unavailable with another data.reason | The camera or microphone could not open: the reason is the capture error's name, or capture-failed | Offer audio only, or try again |
media-unavailable with data.feature callService | The call service could not start: a manifest entry is missing, or the app was in the background | Add what the message names; the call goes on in the foreground |
media-unavailable with data.platform ios | Audio routing on iOS: setSpeakerphoneOn, selectAudioOutput or switchDevice("audiooutput", ...) | Leave the control out on iOS |
media-unavailable with no data | switchCamera() before a camera is published, or a failure inside the audio router: selectAudioOutput (useAudioOutput().select), switchDevice("audiooutput", ...), switchDevice("audioinput", ...), or the call audio the provider holds, which reaches onError; the message has the native side's words | Publish a camera first; for the router, try again or leave the output as it is |
camera-failed | The camera could not switch; the old one stays on air | Try again |
bad-params | An id no camera, microphone or output has, or an output chosen in media audio mode | Use an id from useDevices or useAudioOutput |
screen-share-service-required, screen-share-denied, screen-share-unsupported, screen-share-active, screen-share-failed | Screen sharing is not set up in the build, the user cancelled, iOS, a share is already starting, or the capture could not start | See Screen sharing |
music-unavailable | In-app music needs a browser | Leave it out of the app |
Reconnection
The client reconnects by itself, with backoff, and publishes your camera and microphone again when it is back. useConnectionState() and useRoom().state go to reconnecting, then back to connected: keep the call on screen and say it is reconnecting. A network change, such as Wi-Fi to mobile data, is handled the same way: the media path is repaired where it can be (the mediaReconnecting event), or the client rejoins. The SDK also listens to Android's network callback, so a reconnect waiting out its backoff tries again as soon as the network is back, also with the screen off while the call service runs; it waits half a second after Android reports a network, and tries only if that network is still the default, because Android reports a network before it has switched the app's routes to it, and a connection made at once can fail. If it cannot reconnect, onError gets why (reconnect-gave-up, or a refusal such as room-ended), then onClosed gets gave-up, room-closed or kicked. The client gives up after eight attempts, about a minute offline; the session is then closed for good, so offer a rejoin: a new provider with a fresh token, as the call screen does.
Troubleshooting
These are the problems React Native apps meet, named after what the build, the log or the user shows. Troubleshooting covers echo, silence, frozen video and blocked connections, which are the same on every platform.
WebRTC native module not found
react-native-webrtc's own error when its native code is not in the app. The app was not built again after npm install: run npx react-native run-android, or make a new development build. Expo Go cannot load it at all: see React Native with Expo.
"needs a development build"
The SDK's media-unavailable error when native code is missing: data.reason is webrtc-missing for react-native-webrtc's module, and native-missing for the SDK's own. The app runs in Expo Go, or was not built again after installing or upgrading the packages. Nothing crashes; build the app again.
"the app's native code is @voltkit-rtc/react-native ... rebuild the app"
A warning in the log when the app's native code was built from another version of the SDK than its JavaScript: the package was upgraded without building the app again, or a development build is older than the JavaScript it loads. Build the app again.
ERROR: R8: Missing class org.webrtc.Dav1dDecoder$Natives
A release build with react-native-webrtc 155.0.1 fails with Missing class org.webrtc.Dav1dDecoder$Natives (referenced from: org.webrtc.Dav1dDecoder$Natives org.webrtc.Dav1dDecoderJni.get() and 2 other contexts). The WebRTC build it brings references a class it does not ship, and nothing calls it. @voltkit-rtc/react-native's R8 rules silence it, so with the SDK installed release builds need nothing more. If you see it, the build is not using the SDK's rules: add the line to android/app/proguard-rules.pro, or in Expo to expo-build-properties' android.extraProguardRules:
-dontwarn org.webrtc.Dav1dDecoder$Natives
The call drops about 6 seconds after the app goes to the background
Or after the screen turns off. The call service is not running: Android cuts the app's network a few seconds after it leaves the screen. Check useCallService(): declared false means the manifest has no <service> block, or the Expo plugin's android.callService is false; running false with declared true means it could not start, and onError said why (data.feature is callService). It must start while the app is visible, which the provider does when the call connects. See Calls in the background, and for Xiaomi phones their battery settings.
No video stream for react tag
react-native-webrtc's RTCView logs this when it is handed a stream it cannot find, and shows black (react-native-webrtc issue 1611). Show video with VideoTrack, which makes the native stream for each track itself and keeps it while the track is shown. If you render RTCView yourself, make one stream per track, keep it while the view is shown, and never release it after the call has ended.
forgot to call registerGlobals()
mediasoup-client's warning, or UnsupportedError: device not supported, when @voltkit-rtc/client runs in React Native without the SDK: react-native-webrtc's globals are not registered and no handler is named. Import @voltkit-rtc/react-native before anything that uses the client (it registers them), and make clients with createClient or the provider, never with new StreamClient(...).
Media projections require a foreground service of type ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
Android 14 refuses screen capture that starts without a running screen-capture service. With the SDK, sharing in a build without the service fails before that, with screen-share-service-required: turn on the Expo plugin's android.screenShare, or add the meta-data and permission from Screen sharing, and build the app again.
MissingForegroundServiceTypeException: Starting FGS without a type
Android 14 throws this when an app starts a foreground service whose manifest entry has no android:foregroundServiceType. There the SDK's call service never starts without a type: if its <service> entry declares neither microphone nor mediaPlayback, it does not start, and onError gets media-unavailable (data.feature callService) saying so. If you copied a foreground service for calls from another SDK's guide, remove it: VoltkitCallService does that job.
Silence or a black picture on the emulator
The Android emulator's microphone records silence, and its cameras are simulated and slow. Test publishing sound and video on a phone; on the emulator, check receiving from a browser in the same room.
No earpiece on the emulator
The emulator has only the loudspeaker, so useAudioOutput().outputs lists one output and setSpeakerphoneOn(false) has nowhere else to go. Test the earpiece and headsets on a phone.
Echo on one phone model
Turn off the phone's own echo canceller with the Expo plugin's android.hardwareEchoCancellation: false (or in.voltkit.rtc.HW_AEC set to false) and build again; WebRTC's own canceller takes over. A headset or the earpiece removes echo entirely. Echo explains where it comes from.
Google Play rejects the app for 16 KB page sizes
Google Play requires apps that target Android 15 and later to support 16 KB memory pages. The WebRTC build that react-native-webrtc 155 brings is 16 KB aligned, as react-native-webrtc's maintainers say and VoltKit's emulator build (x86_64) showed; a rejection usually means a second copy of WebRTC from another SDK in the app. Look for more than one libjingle_peerconnection_so.so (or another SDK's WebRTC library) in the APK, and check the alignment with zipalign -c -P 16 -v 4 app-release.apk, which ends with "Verification successful".
CLEARTEXT communication ... not permitted
The wsUrl (or your token endpoint) is ws:// or http://, which Android refuses in a release build. Production URLs are wss:// and https://. For a test server on your own network, allow cleartext for that host in a debug build only.
Build problems
| Message | Fix |
|---|---|
@voltkit-rtc/react-native needs react-native-webrtc: npm install react-native-webrtc, then rebuild the app | Install react-native-webrtc, a peer dependency, then build again |
Duplicate class org.webrtc... | Another package bundles WebRTC's Java classes, such as a react-native-webrtc fork or a native call SDK: remove it |
minSdkVersion 23 cannot be smaller than version 24 | Raise minSdkVersion to 24 (in Expo, with expo-build-properties) |
iOS preview
iOS is JavaScript only in 0.1.0: the SDK has no native iOS module, and react-native-webrtc's iOS code does the media (cd ios && pod install installs it). It is written to run there, but VoltKit has not compiled or run it on an iPhone or a simulator, so treat it as a preview.
- Works as on Android, untested: joining, publishing and receiving, the hooks,
VideoTrack, chat, moderation and reconnection. Permissions go through react-native-webrtc's permission requests, and the app counts as visible unless iOS reports it in the background. - Android only: audio routing (
useAudioOutput().supportedis false;setSpeakerphoneOnandselectAudioOutputreject withmedia-unavailable,data.platform"ios"), the call service, and screen sharing (screen-share-unsupported). - Info.plist:
NSCameraUsageDescription,NSMicrophoneUsageDescription, andUIBackgroundModeswithaudiofor calls that keep their sound in the background. The Expo plugin writes them. - CocoaPods plans to stop accepting new pods on its trunk on 2 December 2026. react-native-webrtc's iOS WebRTC build comes from it, so follow react-native-webrtc's notes for its later versions.
The Flutter SDK supports iOS 13 and later. Write to contact@voltkit.in to try the React Native SDK on iOS with us.
Coming from Agora
react-native-agora apps map almost call for call: <VoltkitProvider> replaces createAgoraRtcEngine() and initialize, the provider's connect replaces joinChannel, VideoTrack replaces RtcSurfaceView, useLocalMedia().switchCamera() replaces switchCamera(), and setEnableSpeakerphone becomes setSpeakerphoneOn. Roles come from the token your backend mints, not from setClientRole. The whole table, with the callbacks, is in Migrating from Agora.
The example app
VoltKit's example app for this SDK is an Expo development build with two screens: a video call, one-to-one or in a group, and an audio room with seats, raised hands, moderation and chat. The SDK's device tests run in it too. Its call screen is the one in A call screen, the tutorial builds it step by step, and React Native with Expo has its build setup: the config plugin, expo-dev-client and the EAS profiles.
Versions and compatibility
Tested on Android (emulator, API 37.1); iOS in preview; earpiece and Bluetooth routing tested in unit tests, not on hardware. Tested with:
| Version | |
|---|---|
| React Native | 0.88.0, New Architecture, Hermes |
| React | 19.3.0 |
| react-native-webrtc | 155.0.1 (WebRTC org.jitsi:webrtc 155.0.1) |
| Expo | SDK 58 (expo 58.0.19), development build |
@voltkit-rtc/client | 0.9.12 |
@voltkit-rtc/react | 0.1.1 |
| Android | The Android emulator, API 37.1; earpiece and Bluetooth routing in unit tests, not on hardware |
| iOS | Not tested: preview |
The SDK's peer ranges are wider (see Install). After upgrading @voltkit-rtc/react-native or react-native-webrtc, build the app again: the native code changes with them. Until 1.0, a breaking change is a minor version and everything else a patch. The changelog ships with the package (CHANGELOG.md) and is on npm.
| Version | What changed |
|---|---|
| 0.1.0 | The first release: the React SDK's hooks with VideoTrack and AudioTrack on react-native-webrtc, permissions, audio routing and the call service on Android, native timers, the Expo config plugin; on @voltkit-rtc/client 0.9.12. Tested on Android (emulator, API 37.1); iOS in preview; earpiece and Bluetooth routing tested in unit tests, not on hardware. |
Help
- Troubleshooting covers echo, silence, frozen video, firewalls and devices, and Errors every code.
- For a support ticket, attach the client's diagnostics snapshot. It names the SDK, react-native-webrtc and the Android version, and holds no token and no media:
import { Button, Share } from "react-native";
import { useVoltkitClient } from "@voltkit-rtc/react-native";
export function ShareDiagnostics() {
const client = useVoltkitClient();
async function share() {
if (!client) return;
await Share.share({ message: JSON.stringify(await client.getDiagnostics()) });
}
return <Button title="Share diagnostics" onPress={share} />;
}
preflight(wsUrl, token, options?)is a pre-call check: it joins, publishes the microphone for a few seconds when its permission is already granted (it asks for nothing; passmediato test streams of your own), and resolves to a report of how media travelled, the round trip, loss, upload and any device problem. It never rejects: readreport.ok, and the steps say what failed. Give it a token for a test room of its own, since the same identity joining anywhere replaces its live call.- Getting technical support lists what to include and where to send it. Questions and bug reports also go to contact@voltkit.in.
- The React Native SDK reference lists every export, prop and option, and React Native with Expo the config plugin.
Last updated . Something unclear or wrong? Tell us.