Did you know? All Video & Audio API plans include a $100 free usage credit each month so you can build and test risk-free. View Plans ->

React Native Ringing Tutorial

The following tutorial shows you how to quickly build a Video Ringing app leveraging Stream's Video API and the Stream Video React Native components. The underlying API is very flexible and allows you to build nearly any type of video experience.

example of react video and audio sdk

In this tutorial, we'll walk through building a simple video and audio calling application with ringing functionality using the Stream Video React Native SDK. The goal is to have an application that will:

  • Allow users to make calls that ring on the recipient's device.
  • Work on both Android and iOS.
  • Support both audio and video calls.
  • Ensure ringing works even when the app is in the background or closed.

If you prefer to skip this tutorial but still want to implement the same functionality, refer to the official React Native Ringing Documentation.

⚠️ Note: You can find the complete code for this tutorial sample in the "stream-video-js" Repository

Let's get started! If you have any questions or feedback, use the feedback button — we're here to help.

Step 1 - Create a new React Native app

We will use Expo as a React Native framework of choice for this tutorial. If you haven't done this already, let's set up your environment for local expo development builds. Once ready, let's continue with the steps below to create an application and start developing.

Let's create a new Expo app project called ringing-tutorial by running the following command in your terminal of choice:

Terminal (bash)
1
npx create-expo-app@latest ringing-tutorial

⚠️ Note: The SDK also supports creating React Native Apps without Expo. We are using Expo here for a minimalistic boilerplate and simplicity.

The next step is to add the Stream Video React Native SDK and its peer dependencies to your app. To do that, run the following command in your terminal of choice:

Terminal (bash)
1
2
3
4
5
6
7
8
9
cd ringing-tutorial npx expo install \ @stream-io/video-react-native-sdk \ @stream-io/react-native-webrtc \ @config-plugins/react-native-webrtc \ react-native-incall-manager \ react-native-svg \ @react-native-community/netinfo \ expo-build-properties

Add config plugins

Add the config plugin for @stream-io/video-react-native-sdk and react-native-webrtc to your app.json file:

app.json (json)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{ "expo": { ... "plugins": [ ... "@stream-io/video-react-native-sdk", [ "@config-plugins/react-native-webrtc", { "cameraPermission": "$(PRODUCT_NAME) requires camera access in order to capture and transmit video", "microphonePermission": "$(PRODUCT_NAME) requires microphone access in order to capture and transmit audio" } ] ] } }

Set the application ID

The following is an example app.json that sets the Application ID to the example value of com.mycorp.myapp. You should change this to your own unique identifier. This is important for later, at the stage where we add Push Notifications.

We should also add the aps-environment: production entitlement as it is necessary for iOS Push Notifications.

app.json (json)
1
2
3
4
5
6
7
8
9
10
11
12
13
{ "expo": { "ios": { "entitlements": { "aps-environment": "production" }, "bundleIdentifier": "com.mycorp.myapp" }, "android": { "package": "com.mycorp.myapp" }, } }

After this point, it is good to commit your changes to version control.

Terminal (bash)
1
2
git add . git commit -m "Add Stream Video React Native SDK and dependencies"

Generate native code directories

Now, let's generate the native code directories for the app after the new config plugins by running the following command:

Terminal (bash)
1
npx expo prebuild --clean

Step 2 - Authentication

To run this sample, we need valid user tokens. These tokens are typically generated by your server-side API and are used to authenticate users when they log in to your app.

For the ringing functionality to work, we need at least two users who can log in on separate devices.

Generating User Tokens

  1. First, create a new application in the Stream Dashboard.
  2. Then, use this token generation form to generate sample tokens.
  • Provide your App Secret and User ID to create tokens for each user.

Creating Hardcoded Users

To simplify testing, we'll create a UserWithToken type in a new file to store hardcoded user data. Let's create this file at constants/Users.ts. Replace the id, name, and token fields with the values you generated for three different users.

constants/Users.ts (tsx)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
export type UserWithToken = { id: string; name: string; token: string; }; export const Users: UserWithToken[] = [ { id: '{REPLACE_WITH_USER_1_ID}', name: '{REPLACE_WITH_USER_1_NAME}', token: '{REPLACE_WITH_USER_1_TOKEN}', }, { id: '{REPLACE_WITH_USER_2_ID}', name: '{REPLACE_WITH_USER_2_NAME}', token: '{REPLACE_WITH_USER_2_TOKEN}', }, { id: '{REPLACE_WITH_USER_3_ID}', name: '{REPLACE_WITH_USER_3_NAME}', token: '{REPLACE_WITH_USER_3_TOKEN}', }, ];

Building a simple login screen and the main screen

We will use the Expo Router library for navigation flow. We shall use runtime logic to redirect users away from main app screens depending on whether they are authenticated. This is achieved in an organized way by using React Context and Route Groups.

We will also persist the logged-in user. This will allow us to keep the user logged in even after the app is closed and receive and join ringing calls even when the app is in the background.

We'll use @react-native-async-storage/async-storage to store the user ID in the device's local storage.

Terminal (bash)
1
npx expo install @react-native-async-storage/async-storage

Now, let's set up a React Context provider that will provide the authentication session to the entire app. Let's start by creating contexts/authentication-provider.tsx.

We will use this context provider to wrap the component tree with StreamVideo provider after login.

We'll also initialize the Stream Video client using your API key and the token of the logged-in user.

The Stream Video client instance will:

  • Establish a connection with the Stream backend.
  • Listen for events and enable call creation.
  • Handle incoming calls via push notifications.
contexts/authentication-provider.tsx (tsx)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
import React, { createContext, type PropsWithChildren, useContext, useEffect, useState, } from 'react'; import { StreamVideo, StreamVideoClient, StreamVideoRN, } from '@stream-io/video-react-native-sdk'; import { Users, UserWithToken } from '../constants/Users'; import AsyncStorage from '@react-native-async-storage/async-storage'; const API_KEY = '{REPLACE_WITH_STREAM_API_KEY}'; const AuthContext = createContext<{ signIn: (userId: string) => void; signOut: () => void; userWithToken?: UserWithToken; isLoading: boolean; }>({ signIn: () => null, signOut: () => null, isLoading: false, }); export function useAuthentication() { const value = useContext(AuthContext); if (!value) { throw new Error( 'useAuthentication must be wrapped in a <AuthenticationProvider />', ); } return value; } export function AuthenticationProvider({ children }: PropsWithChildren) { const [userId, setUserId] = useState<string>(); const [isLoading, setIsLoading] = useState(true); const userWithToken = Users.find((user) => user.id === userId); const client = userWithToken && StreamVideoClient.getOrCreateInstance({ apiKey: API_KEY, token: userWithToken.token, user: { id: userWithToken.id, name: userWithToken.name, image: `https://robohash.org/${userWithToken.id}`, }, options: { logLevel: 'debug' }, }); useEffect(() => { AsyncStorage.getItem('@userid-key') .then((id) => { if (id) setUserId(id); }) .catch((error) => console.error(`Can't find user-id`, error)) .finally(() => setIsLoading(false)); }, []); return ( <AuthContext.Provider value={{ signIn: (id: string) => { AsyncStorage.setItem('@userid-key', id); setUserId(id); }, signOut: () => { AsyncStorage.removeItem('@userid-key'); client?.disconnectUser(); StreamVideoRN.onPushLogout(); setUserId(undefined); }, userWithToken, isLoading, }} > {client ? ( <StreamVideo client={client}>{children}</StreamVideo> ) : ( <>{children}</> )} </AuthContext.Provider> ); }

In the code above, we:

  • Store and retrieve user data to maintain login sessions, even if the app is closed.
  • Initialize the Stream Video client using the user token and API key.
  • On sign-out, we disconnect the user from the Stream Video client and remove the login session.

Now, let's create common components that will be shared across multiple screens in this app. They will be created inside the components folder.

user-button.tsx
action-button.tsx
components/user-button.tsx (tsx)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import { Pressable, type PressableProps, StyleSheet, Text } from 'react-native'; type UserButtonProps = PressableProps & { userName: string; selected: boolean; }; export const UserButton = ({ userName, selected, ...rest }: UserButtonProps) => { // Determine which color to use based on selection state const currentBorderColor = selected ? '#4CAF50' : '#757575'; return ( <Pressable style={({ pressed }) => [ styles.userButton, { borderColor: currentBorderColor }, { opacity: pressed ? 0.8 : 1 }, ]} {...rest} > <Text style={[styles.userButtonText, { color: currentBorderColor }]}> {userName} </Text> </Pressable> ); }; const styles = StyleSheet.create({ userButton: { backgroundColor: '#fff', borderWidth: 1, borderRadius: 30, paddingVertical: 12, paddingHorizontal: 24, marginBottom: 24, minWidth: 120, alignItems: 'center', elevation: 2, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.2, shadowRadius: 1, }, userButtonText: { fontSize: 16, fontWeight: '600', }, });

Now, let's remove all the files with the app folder.

This is the folder where all the route components will be present. We will create new routes for the authentication flow.

Let's add the below files inside the app folder:

  • _layout.tsx: the entry point of your app.
  • login.tsx: the screen to authenticate a user.
  • (app)/_layout.tsx: the entry point to the (app) route group. This route group has a stack of screens for authenticated users.
  • (app)/index.tsx: the first screen in the (app) route group's stack. The screen displays a list of users available to call.
_layout.tsx
login.tsx
app/(app)/_layout.tsx
app/(app)/index.tsx
app/_layout.tsx (tsx)
1
2
3
4
5
6
7
8
9
10
11
import React from 'react'; import { Slot } from 'expo-router'; import { AuthenticationProvider } from '../contexts/authentication-provider'; export default function Root() { return ( <AuthenticationProvider> <Slot /> </AuthenticationProvider> ); }

Step 3 - Add In-app Incoming Calls

When the app is in the foreground, we can display an in-app incoming call screen.

To do that, let's first implement the onRing in the app/(app)/index.tsx file.

app/(app)/index.tsx (tsx)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { useStreamVideoClient } from "@stream-io/video-react-native-sdk"; export default function Index() { // ...the other code const client = useStreamVideoClient(); const onRing = async () => { const callId = "tutorial-" + Math.random().toString(16).substring(2); const myCall = client!.call("default", callId); await myCall.getOrCreate({ ring: true, data: { members: [ // include self { user_id: userWithToken!.id }, // include the userId of the callees ...selectedUsers.map((userId) => ({ user_id: userId })), ], }, }); }; // .. the rest of the code }

In the code above, we:

  • Create a unique call-id for every ring call.
  • Create or retrieve the call
    • We call client.getOrCreate() to either create a new call or fetch an existing one.
    • We pass:
      • ring: true: Marks the call as ringing. When configured, this operation will trigger a push notification to the callee(s).
      • members: An array of user IDs to include in the call. This includes the caller and the callee(s). The callee(s) will receive a push notification.

Next, let's watch for ringing kind of calls in the same component and navigate to a new ringing screen.

Replace the code in the file with the one below:

app/(app)/index.tsx (tsx)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { useCalls } from '@stream-io/video-react-native-sdk'; export default function Index() { // ...the other code const client = useStreamVideoClient(); const calls = useCalls().filter((c) => c.ringing); //.. the other code // right before render if (ringingCall) { return <Redirect href="/(app)/ringing" />; } // the main render return (...) };

In the code above, we:

  • Watch for incoming and outgoing calls using the useCalls hook. We are only interested in calls that have the ringing property set to true.
  • When a call is found, we redirect to the ringing screen that we will create next.

Create a new file app/(app)/ringing.tsx with the following content:

app/(app)/ringing.tsx (tsx)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import React, { useEffect } from 'react'; import { StyleSheet } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { RingingCallContent, StreamCall, useCalls, } from '@stream-io/video-react-native-sdk'; import { router } from 'expo-router'; export default function Ringing() { const calls = useCalls().filter((c) => c.ringing); const ringingCall = calls[0]; useEffect(() => { if (!ringingCall) { // redirect to the main app, when the call is removed from the client's call list router.replace("/"); } }, [ringingCall]); if (!ringingCall) return null; return ( <StreamCall call={ringingCall}> <SafeAreaView style={styles.container}> <RingingCallContent /> </SafeAreaView> </StreamCall> ); } const styles = StyleSheet.create({ container: { flex: 1, }, });

In the code above, we:

  • We use the StreamCall wrapper and RingingCallContent component to display an active call.
  • When a call is left and removed from the client, we redirect to the main screen.

At this point, we haven't yet handled push notifications for incoming calls when the app is in background. However, when you initiate a call, the caller's device should now display the ongoing call UI. Or when there is an incoming call the device should display an incoming call UI.

  • Incoming call screen preview

  • Outgoing call screen preview

  • Active call screen preview

Next, we'll add ringing push notifications so that recipients get notified when someone calls them when the app is in terminated or background state.

Step 4 - Add Push Notification support

Configuring Push Notification Providers

For push notification support, the Stream backend requires push providers to be configured. Let's use the following guides to configure these providers in the Stream Dashboard:

Install dependencies

Install the following peer dependencies for the SDK to be able to get push notification support.

Terminal (bash)
1
2
3
4
5
6
7
npx expo install \ @react-native-firebase/app \ @react-native-firebase/messaging \ @notifee/react-native \ react-native-voip-push-notification \ react-native-callkeep \ @config-plugins/react-native-callkeep

Add Firebase credentials

  1. In the Firebase console, click the setting icon next to Project overview and open Project settings. Then, under Your apps, click the Android icon to open Add Firebase to your Android app and follow the steps. Make sure that the Android package name you enter is the same as the value of expo.android.package from your app.json.

  2. After registering the app, download the google-services.json file and place it in your project's root directory.

  3. In app.json, add an android.googleServicesFile field with the relative path to the downloaded google-services.json file. If you placed it in the root directory, the path is ./google-services.json.

  4. Similarly, for iOS, in the console, click the setting icon next to Project overview and open Project settings. Then, under Your apps, click the iOS icon to open Add Firebase to your Apple app and follow the steps. Make sure that the Apple bundle ID you enter is the same as the value of expo.ios.bundleIdentifier from your app.json..

  5. After registering the app, download the GoogleService-Info.plist file and place it in your project's root directory.

  6. In app.json, add an ios.googleServicesFile field with the relative path to the downloaded GoogleService-Info.plist file. If you placed it in the root directory, the path is ./GoogleService-Info.plist.

  7. The app.json file should look like below if the new files were placed in the root directory.

app.json (json)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{ "expo": { "android": { "entitlements": { "aps-environment": "production" }, "googleServicesFile": "./google-services.json", "package": "com.mycorp.myapp" }, "ios": { "googleServicesFile": "./GoogleService-Info.plist", "bundleIdentifier": "com.mycorp.myapp" }, } }

Add the config plugin properties

In app.json, in the plugins field, add the ringingPushNotifications property to the @stream-io/video-react-native-sdk plugin. Also, add the @config-plugins/react-native-callkeep and @react-native-firebase plugins.

app.json (json)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
{ "plugins": [ [ "@stream-io/video-react-native-sdk", { "ringingPushNotifications": { "disableVideoIos": false, "includesCallsInRecentsIos": false, "showWhenLockedAndroid": true }, "androidKeepCallAlive": true, } ], "@config-plugins/react-native-callkeep", [ "@config-plugins/react-native-webrtc", { "cameraPermission": "$(PRODUCT_NAME) requires camera access in order to capture and transmit video", "microphonePermission": "$(PRODUCT_NAME) requires microphone access in order to capture and transmit audio" } ], "@react-native-firebase/app", "@react-native-firebase/messaging", [ "expo-build-properties", { "android": { "extraMavenRepos": [ "$rootDir/../../../node_modules/@notifee/react-native/android/libs" ] }, "ios": { "useFrameworks": "static" } } ] ] }

Generate native code directories

Now, do the following to regenerate the native code directories for the app after the new config plugins.

Terminal (bash)
1
npx expo prebuild --clean

Add Firebase message handlers

Disable Firebase initialization on iOS

We do not use Firebase on iOS, rather we use react-native-voip-push-notification. But the React Native Firebase Messaging library automatically registers the device with APNs to receive remote messages. Hence, we must disable it via the firebase.json file that we are about to create.

{projectRoot}/firebase.json (json)
1
2
3
4
5
{ "react-native": { "messaging_ios_auto_register_for_remote_messages": false } }

Request for notification permissions in Android

As soon as we login, we will ask the user to grant push notification permissions. This is an appropriate place to request in this app. This permission must be granted so that push notifications can work.

Let's update the app/(app)/index.tsx file to request for the notification permissions.

app/(app)/index.tsx (tsx)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { useEffect } from 'react'; import { PermissionsAndroid, Platform } from 'react-native'; export default function Index() { //... rest useEffect(() => { if (Platform.OS === 'android') { PermissionsAndroid.request( PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS, ); } }, []); //... rest }

Add Firebase event handlers for Android

To process the incoming push notifications from Firebase, the SDK provides the utility functions that you must add to your existing or new Firebase notification listeners. This should be only added for Android using the Platform-specific extensions for React Native.

To do this, let's add the following files in our project inside the folder utils:

utils/setFirebaseListeners.ts (ts)
1
2
3
export const setFirebaseListeners = () => { // do nothing };

We keep the setFirebaseListeners.ts to be empty so that nothing is processed on iOS.

Now, for Android, let's register the utility functions from the SDK to process the events from coming from Firebase:

utils/setFirebaseListeners.android.ts (ts)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import messaging from '@react-native-firebase/messaging'; import { firebaseDataHandler, isFirebaseStreamVideoMessage, isNotifeeStreamVideoEvent, onAndroidNotifeeEvent, } from '@stream-io/video-react-native-sdk'; import notifee from '@notifee/react-native'; export const setFirebaseListeners = () => { // Set up the background message handlers messaging().setBackgroundMessageHandler(async (msg) => { if (isFirebaseStreamVideoMessage(msg)) { await firebaseDataHandler(msg.data); } }); notifee.onBackgroundEvent(async (event) => { if (isNotifeeStreamVideoEvent(event)) { await onAndroidNotifeeEvent({ event, isBackground: true }); } }); // Set up the foreground message handlers messaging().onMessage((msg) => { if (isFirebaseStreamVideoMessage(msg)) { firebaseDataHandler(msg.data); } }); notifee.onForegroundEvent((event) => { if (isNotifeeStreamVideoEvent(event)) { onAndroidNotifeeEvent({ event, isBackground: false }); } }); };

Setup the push notifications configuration for the SDK

Add the following configuration in a new file at utils/setPushConfig.ts.

utils/setPushConfig.ts (ts)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
import { StreamVideoClient, StreamVideoRN, } from '@stream-io/video-react-native-sdk'; import { AndroidImportance } from '@notifee/react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { Users } from '../constants/Users'; const API_KEY = '{REPLACE_WITH_STREAM_API_KEY}'; export function setPushConfig() { StreamVideoRN.setPushConfig({ isExpo: true, ios: { pushProviderName: '{REPLACE_WITH_APN_PUSH_PROVIDER_NAME_FROM_DASHBOARD}', }, android: { pushProviderName: '{REPLACE_WITH_FIREBASE_PUSH_PROVIDER_NAME_FROM_DASHBOARD}', callChannel: { id: 'stream_call_notifications', name: 'Call notifications', importance: AndroidImportance.HIGH, sound: 'default', }, incomingCallChannel: { id: 'stream_incoming_call', name: 'Incoming call notifications', importance: AndroidImportance.HIGH, }, incomingCallNotificationTextGetters: { getTitle: (createdUserName: string) => `Incoming call from ${createdUserName}`, getBody: () => 'Tap to open the call', }, }, createStreamVideoClient, }); } const createStreamVideoClient = async () => { const userId = await AsyncStorage.getItem('@userid-key'); const userWithToken = Users.find((user) => user.id === userId); if (!userWithToken) { console.error( 'Push - createStreamVideoClient -- userWithToken is undefined', ); return; } return StreamVideoClient.getOrCreateInstance({ apiKey: API_KEY, token: userWithToken.token, user: { id: userWithToken.id, name: userWithToken.name }, }); };

In the code above, we:

  • Provide the pushProviderName that we created in the Stream Dashboard to the SDK.
  • Add the Android notification channels, the title, and the body for the Android notifications.
  • Add the createStreamVideoClient function to allows the SDK to instantiate the client when app is not opened.

Initialize the push notification configuration

Let's call the methods we have created outside of the regular application cycle. index.js is a good place do this as the app can be opened from a dead state through a push notification, and in that case, we need to use the configuration and notification callbacks as soon as the JS bridge is initialized.

index.js (js)
1
2
3
4
5
6
import 'expo-router/entry'; import { setPushConfig } from './utils/setPushConfig'; import { setFirebaseListeners } from './utils/setFirebaseListeners'; setPushConfig(); setFirebaseListeners();

The index.js file must be made as the entry point file to your app. This can be done via editing the main property in the package.json file:

package.json (json)
1
2
3
4
5
{ ... "main": "index.js", ... }

Testing Incoming Call

To test properly:

  • Use a real device (APNs do not work in simulators).
  • Ensure your Firebase/APNs credentials are correctly configured in the Stream Dashboard.

Run the Android and iOS app to the respective devices using the following command:

bash
1
2
3
4
npx expo prebuild --clean npm run ios -- -d npm run android -- -d

And that's it! Now when you receive a call on an Android/iOS device you should see the incoming call notification.

  • iOS preview

  • Android preview

Congratulations! 🎉 You've successfully built a fully functional ringing experience using Stream Video in a React Native app.

What We Covered:

  • Setting up Stream Video and initializing the client.
  • Building UI for login, home, and call screens.
  • Handled authentication and user data storage.
  • Configured FCM and APNs providers in the Stream Dashboard.
  • Creating and handling calls.
  • Implementing push notifications for incoming calls for handling background and terminated states on Android and iOS.

At this point, your app should be able to send and receive calls with ringing notifications across devices, even when in the background or terminated.

Next Steps

To further improve your app, check out these helpful links:

If you prefer to skip this tutorial but still want to implement the same functionality, refer to the official React Native Ringing Documentation.

Give us feedback!

Did you find this tutorial helpful in getting you up and running with your project? Either good or bad, we're looking for your honest feedback so we can improve.

Start coding for free

No credit card required.
If you're interested in a custom plan or have any questions, please contact us.