DEV Community

Maxwell Lin
Maxwell Lin

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

Native incoming calls in React Native: CallKit on iOS, full-screen calls on Android

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 expo prebuild and a development build or EAS Build — Expo Go cannot load a native WebRTC runtime. Add @streaming-cdn/rtc-react-native to the Expo plugins list so the camera and microphone usage descriptions are generated.
  • Upgrading from a version without the native call layer means rebuilding the native project; until then native calls report that the module is unavailable.

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)