Skip to content

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.

KindExports
ProviderVoltkitProvider
HooksuseRoom, useConnectionState, useParticipants, useRemoteTracks, useLocalMedia, useNetworkQuality, useWalletLow, useVoltkitEvent, useVoltkitClient
Media hooksuseAudioLevels, useActiveSpeakers, useIsSpeaking, useStreamFallback, usePublishFallback, useMediaWarnings, useCpuConstrained, useDevices
Audio, permissions and the call serviceuseAudioOutput, setSpeakerphoneOn, selectAudioOutput, usePermissions, requestMediaPermissions, checkMediaPermissions, useCallService
ComponentsVideoTrack, AudioTrack
FunctionscreateClient, reactNativeClientOptions, reactNativePlatform, preflight, registerVoltkitGlobals
ConstantsSDK_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.

RequirementValue
React Native0.82 or newer, with the New Architecture; tested with 0.88.0 and Hermes
React19.1 or newer
react-native-webrtc155.0.1 or a later 155.x
@voltkit-rtc/client0.9.12 or a later 0.9.x
@voltkit-rtc/react0.1.1 or a later 0.1.x
AndroidminSdk 24 (Android 7.0), react-native-webrtc's minimum
ExpoSDK 58 or newer, in a development build; not Expo Go
iOSPreview: see iOS preview
LicenceMIT

@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, useNetworkQuality and the rest are re-exported from @voltkit-rtc/react. VoltkitProvider, useLocalMedia, VideoTrack and AudioTrack keep 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, ErrorCode and more). Values such as RpcError, ChatClient and isTerminalError are imported from @voltkit-rtc/client.
  • Not in React Native: play(element) and autoplayFailed (remote audio plays by itself), in-app music (publishMusic rejects with music-unavailable), the noise suppression and video effects processors, device recording (startRecording rejects with media-unavailable), and StreamClient.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. onError hears 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. onClosed says why a session ended. A closed session stays closed, so joining again mounts a new provider (a new key) 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. producerId on a remote VideoTrack turns 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), micEnabled is false in every component that uses useLocalMedia(), and a reconnect shows as reconnecting without unmounting anything.

VoltkitProvider

VoltkitProvider holds one StreamClient for the components below it. It takes the React provider's props and three of its own.

PropTypeWhat it does
wsUrlstringThe room's WebSocket URL, from your backend with the token
tokenstringA room token your backend minted
connectbooleanConnect the client when it is idle. Default true; with false, call useRoom().connect() yourself
onConnected(result: JoinResult) => voidAfter every successful join: the first, and each automatic reconnect
onError(error: Error) => voidEvery error event (warnings included), a failed connect, a token the session refused, and a call service that could not start
onClosed(reason: ClosedReason) => voidThe session ended for good: left, kicked, room-closed, gave-up or replaced
clientStreamClient or nullA client you made with createClient and own, used instead of wsUrl and token
clientOptionsClientOptionsOptions for the client the provider makes, laid over React Native's own. Read once, when the client is made
audio{ speakerphone?, manage? } or falsespeakerphone: 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)
callServiceboolean 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, and background as the client's pauseCameraInBackground and pauseMicInBackground, with the clientOptions you give laid over them. A client you make yourself for the client prop comes from createClient(wsUrl, token, { background, clientOptions }): a plain new StreamClient(...) would look for a browser. Give createClient and the provider the same background: the client's copy decides what pauses, the provider's whether the call service runs with the camera type 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 onError gets media-unavailable with data.reason webrtc-missing; on Android without the SDK's own native module, data.reason is native-missing, and the call works without background calls, audio routing and native timers.
  • A new token for the same session (the same project, room and identity, read from the token) goes to the live session with updateToken, 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 new wsUrl replaces the client, and taking token or wsUrl away closes it.
  • A closed session stays closed. To join again, render the provider with a new key and a fresh token, as the call screen does.
  • With client the provider uses yours as it is: it connects it when it is idle (unless connect is false) 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.

HookReturnsDefault
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 facingModenothing published
useNetworkQuality()level, rttMs, lossPct, availableKbps, about every 3 snull
useWalletLow()active, remainingSeconds, noticenot active
useVoltkitEvent(event, handler)nothing: calls handler for each event while mounted
useVoltkitClient()the provider's StreamClientnull
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 Permissionsstatuses 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, useActiveSpeakers and useIsSpeaking change twice a second.
  • useDevices lists the cameras ("videoinput", each with facing: front, back or external), the microphones ("audioinput") and, on Android, the outputs ("audiooutput"), each with its type. 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
trackThe video to show: a remote track's track, or your own camera, useLocalMedia().stream?.getVideoTracks()[0]. Nothing while null or undefined
producerIdA 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
mirrorFlip 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
zOrderWhich of two overlapping video views is drawn on top, as for RTCView: give a small preview over a full-screen video the higher number
visiblefalse while the view is off screen or covered: with producerId, the video is paused at the server until it is true again. Default true
onDimensionsChangeCalled with the video's width and height when it first shows and when they change
style, testIDAs 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
trackA remote audio track. Nothing while null or undefined
volume0 to 1: this track's playback volume, where 1 is the track as received. Unset leaves it as it is
mutedSilence 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 from type: speaker, earpiece, wiredHeadset, bluetooth or usb. 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 with bad-params. setSpeakerphoneOn(true) picks the loudspeaker when no headset is connected, and false the earpiece; a headset still wins. Both are also plain functions, selectAudioOutput(deviceId) and setSpeakerphoneOn(on), which work before any client exists, so a screen before the call can set them.
  • client.switchDevice("audiooutput", deviceId), from useVoltkitClient(), does what select does.
  • 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 the in.voltkit.rtc.HW_AEC meta-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's android.audioMode, or the in.voltkit.rtc.AUDIO_MODE meta-data): sound plays at the media volume and on Bluetooth's high-quality profile, with no earpiece, and outputs follow the system: useAudioOutput().supported is false, and select and switchDevice("audiooutput", ...) reject with bad-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; facingMode says the same ("user" or "environment"). Before a camera is published it rejects with media-unavailable.
  • Any camera: useDevices("videoinput") lists them, each with facing, and client.switchDevice("videoinput", deviceId) switches the published one. An id no camera has rejects with bad-params; a switch that fails rejects with camera-failed, and the old camera stays on air.
  • Microphones: useDevices("audioinput") lists them, and client.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 microphone type.
  • 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:

TypeWhen the service runs with it
microphoneRECORD_AUDIO is granted: the call keeps its microphone
mediaPlaybackOtherwise: a listener, who keeps hearing the call
cameraOnly if you declare it, with FOREGROUND_SERVICE_CAMERA, and keep the camera on in the background with background={{ pauseCamera: false }}
mediaProjectionOnly 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 producerId pause 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's camera type; 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 source screen, 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 to 30 with a frame-rate-fallback warning.

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

WhereWhat arrives
onError on the providerEvery 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().errorThe last of those, until a join succeeds
The promise of the calluseRoom().connect(), publish(), switchCamera() and every other useLocalMedia() call, and the client's own calls such as sendMessage(), reject with the error
onClosedThe 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:

codeWhen
media-unavailableA permission was refused: data.permission and data.status, see Permissions
not-connectedThe session is not connected, there is no client yet, or the session ended while the permission dialog was open
forbiddenYour role is not host or cohost
source-already-publishedA camera and microphone are already published, or a publish is under way: unpublish() first
media-disabledThe 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

codeWhat happenedWhat to do
media-unavailable with data.permissionThe user refused the camera or microphone; data.status is denied or blockedAsk again, or send them to the app's settings when it is blocked
media-unavailable with data.reason webrtc-missingThe 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 buildBuild the app again; use a development build, not Expo Go
media-unavailable with data.reason native-missingThe SDK's own native module is not in the app (Android): background calls, audio routing and native timers are off; data.feature names what askedBuild the app again
media-unavailable with another data.reasonThe camera or microphone could not open: the reason is the capture error's name, or capture-failedOffer audio only, or try again
media-unavailable with data.feature callServiceThe call service could not start: a manifest entry is missing, or the app was in the backgroundAdd what the message names; the call goes on in the foreground
media-unavailable with data.platform iosAudio routing on iOS: setSpeakerphoneOn, selectAudioOutput or switchDevice("audiooutput", ...)Leave the control out on iOS
media-unavailable with no dataswitchCamera() 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 wordsPublish a camera first; for the router, try again or leave the output as it is
camera-failedThe camera could not switch; the old one stays on airTry again
bad-paramsAn id no camera, microphone or output has, or an output chosen in media audio modeUse an id from useDevices or useAudioOutput
screen-share-service-required, screen-share-denied, screen-share-unsupported, screen-share-active, screen-share-failedScreen sharing is not set up in the build, the user cancelled, iOS, a share is already starting, or the capture could not startSee Screen sharing
music-unavailableIn-app music needs a browserLeave 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

MessageFix
@voltkit-rtc/react-native needs react-native-webrtc: npm install react-native-webrtc, then rebuild the appInstall 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 24Raise 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().supported is false; setSpeakerphoneOn and selectAudioOutput reject with media-unavailable, data.platform "ios"), the call service, and screen sharing (screen-share-unsupported).
  • Info.plist: NSCameraUsageDescription, NSMicrophoneUsageDescription, and UIBackgroundModes with audio for 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 Native0.88.0, New Architecture, Hermes
React19.3.0
react-native-webrtc155.0.1 (WebRTC org.jitsi:webrtc 155.0.1)
ExpoSDK 58 (expo 58.0.19), development build
@voltkit-rtc/client0.9.12
@voltkit-rtc/react0.1.1
AndroidThe Android emulator, API 37.1; earpiece and Bluetooth routing in unit tests, not on hardware
iOSNot 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.

VersionWhat changed
0.1.0The 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; pass media to 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: read report.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.