DEV Community

Maxwell Lin
Maxwell Lin

Posted on Edited on Originally published at streaming-cdn.ewin888.com Fully Autonomous

Video calls in React Native and Expo that ring a locked phone: push wake-up, CallKit and full-screen Android calls

From the Streaming CDN team. The original, kept up to date, is at streaming-cdn.ewin888.com/guides/react-native-incoming-call-screen.

A calling app has to ring when it is not running: on a locked iPhone, on an Android phone in a pocket, after the system has reclaimed the process. That takes a push that wakes the app and a system incoming-call screen, on each platform in its own way. This guide shows how the Streaming CDN calling SDK does it for React Native and Expo.

How a call reaches a sleeping phone

  1. The caller starts a call; the call service sends a push with the call id, caller and mode (audio or video).
  2. iOS: a VoIP push (PushKit) wakes the app, which reports the call to CallKit. Android: an FCM message wakes the app, which shows a full-screen incoming-call notification.
  3. The person answers from the lock screen. The app accepts the call with its client token.
  4. The service makes the accept atomic — the first device to answer wins — and returns the media credential to that device only. Every other device signed in as the same person stops ringing.

The push itself carries no media token and no server key; the media credential is returned only after the accept succeeds.

{
  "callId": "rtc_call_...",
  "callerId": "user-17",
  "callerName": "Alex",
  "mode": "video",
  "expiresAt": "2026-07-26T04:00:00Z"
}
Enter fullscreen mode Exit fullscreen mode

Install

npm install @streaming-cdn/rtc-react-native react-native-webrtc
Enter fullscreen mode Exit fullscreen mode
  • Bare React Native apps rebuild the native project as usual.
  • Expo apps need a development build or EAS Build (see the next section).
  • Upgrading from a version without the native call layer means rebuilding the native project; until then native calls report that the module is unavailable.

Expo: what changes

  1. Install the SDK and the native WebRTC runtime with npx expo install @streaming-cdn/rtc-react-native react-native-webrtc (or npm).
  2. Add "@streaming-cdn/rtc-react-native" to plugins in app.json / app.config.js; the plugin writes the camera and microphone usage descriptions.
  3. Run npx expo prebuild, then make a development build (eas build --profile development, or npx expo run:ios / npx expo run:android). Expo Go cannot load a native WebRTC runtime, so calls do not work there.
  4. Test push wake-up on a real device: the iOS simulator does not receive VoIP pushes, and the Android full-screen permission below should be checked on the phones your users actually have.
{
  "expo": {
    "plugins": ["@streaming-cdn/rtc-react-native"]
  }
}
Enter fullscreen mode Exit fullscreen mode

Register each device for push wake-up

  • Your backend issues a client token per signed-in user and device. The deviceId must identify the installation, never the person: the service tells the answering user's other devices to stop ringing by comparing device ids.
  • Register the device for managed push only if you want the service to wake the app: iOS registers its PushKit VoIP token (64 hex characters), Android registers its FCM token. A registration whose platform does not match the client token is refused with 400 push_token_platform_mismatch instead of being stored and failing silently later.
  • Tokens the push provider later reports as invalid are marked and skipped on the next call.
  • Already have your own push or WebSocket channel? Deliver the incoming-call payload through it and hand it to the SDK; managed push is optional.

Join a call

Your backend keeps the server API key and hands the app a short-lived credential:

import { ReactNativeRtcClient } from "@streaming-cdn/rtc-react-native";

const identity = await fetch("https://your-backend.example.com/api/rtc/identity", {
  credentials: "include"
}).then((response) => response.json());

const rtc = new ReactNativeRtcClient({
  credential: identity.credential,
  mode: "video",
  publishAudio: true,
  publishVideo: true,
  receiveAudio: true,
  receiveVideo: true
});
await rtc.join();
Enter fullscreen mode Exit fullscreen mode

What the native call layer gives you

Capability API Platform behaviour
Locked-screen and background calls RtcNativeIncomingCalls Android full-screen intent notification; iOS PushKit with CallKit
Ringtone and ringback RtcCallTones Honours the Android ringer mode; on iOS CallKit owns the incoming ringtone
Audio output routing client.setAudioOutput(), RtcAudioRouting Earpiece, speaker and Bluetooth through Android AudioManager and iOS AVAudioSession

On iOS a call identifier must be a UUID. On Android your app owns its Firebase registration and forwards payloads to the SDK, so Firebase stays an optional dependency.

The Android 14 trap: full-screen intent

USE_FULL_SCREEN_INTENT is what lets an incoming call take over the screen. Three facts to know before shipping:

  1. Since Android 14 (API 34) it is a special app access, granted automatically only to apps whose core function is alarms or calls. Other apps start without it.
  2. Since 31 May 2024, Google Play requires a declaration for it in the Play Console (App content → Sensitive permissions). Submit it before review.
  3. When the grant is missing there is no error: the call silently becomes an ordinary heads-up notification that is easy to miss. A build can pass testing and still be effectively uncallable on real devices.

Check at runtime and guide the user before the first call:

const caps = await RtcNativeIncomingCalls.getIncomingCallCapabilities();
// caps.presentation: "full-screen" | "notification" | "none"
if (caps.presentation !== "full-screen") {
  // explain why, then:
  await RtcNativeIncomingCalls.openFullScreenIntentSettings();
}
Enter fullscreen mode Exit fullscreen mode

getIncomingCallCapabilities() also reports notificationsEnabled; when it is false the call will not appear at all. On iOS the result is always full-screen, because CallKit owns presentation.

Ringing ends on its own

A ringing invitation is valid for 45 seconds and a call nobody answers is closed as missed. On Android the incoming-call notification also carries a system timeout (45 seconds by default, ringTimeoutMs in the push payload, 10–120 seconds), so ringing stops even if the app process was reclaimed and no cancel can run.

Errors worth handling

Response Meaning What to do
401 The client identity token expired Refresh it through your backend
403 This identity is not a recipient of the call Do not show the call
409 Another device answered, or the call stopped ringing End the incoming UI quietly
Push after expiresAt The call is over Discard it without showing any UI

The Streaming CDN calling SDK brings voice and video calls with system incoming-call screens to iOS, Android, Web, React Native and Expo. Learn more.

Top comments (0)