How To Setup WebRTC For Audio & Video Calling?


How To Setup WebRTC For Audio & Video Calling ?

Table Of Contents:

  1. Install Required Libraries.

Referred Websites

https://webrtc.org/getting-started/overview

(1) Install Required Libraries

npm install react-native-webrtc – save

(2) Add Required Permission On – AndroidManifest.xml

  • Add these below permissions in in android/app/src/main/AndroidManifest.xml:
<uses-feature android:name="android.hardware.camera" />
<uses-feature android:name="android.hardware.camera.autofocus" />
<uses-feature android:name="android.hardware.audio.output" />
<uses-feature android:name="android.hardware.microphone" />

<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.INTERNET" />

<! – Bluetooth permissions for headsets – >
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />

<! – Screen-sharing on Android 14+ – >
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />

(3) Enable Java 8 Support

  • In android/app/build.gradle add the following inside the android section.
compileOptions {
	sourceCompatibility JavaVersion.VERSION_1_8
	targetCompatibility JavaVersion.VERSION_1_8
}

(4) Audio Category Setup – Set Audio as Media

  • In Android, audio output categories define how your app’s audio is treated by the system. Different categories affect volume controls, audio routing, and interruptions (like phone calls).

  • When using WebRTC in React Native, the default audio output category is usually treated as a call/voice communication stream. This is because WebRTC is often used for voice and video calls.

  • if your Android files are written in Java, modify MainApplication.java:
  • if your Android files are written in Kotlin, modify MainApplication.kt:
// add imports
import com.oney.WebRTCModule.WebRTCModuleOptions;
import android.media.AudioAttributes
import org.webrtc.audio.JavaAudioDeviceModule;

class MainApplication : Application(), ReactApplication {
	override fun onCreate() {
		// append this before WebRTCModule initializes
		val options = WebRTCModuleOptions.getInstance()
		val audioAttributes = AudioAttributes.Builder()
			.setUsage(AudioAttributes.USAGE_MEDIA)
			.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
			.build()
		options.audioDeviceModule = JavaAudioDeviceModule.builder(this)
			.setAudioAttributes(audioAttributes)
			.createAudioDeviceModule()
	}
}

(5) Enable Screen Sharing

  • Starting with version 118.0.2 a foreground service is included in this library in order to make screen-sharing possible under Android 14 rules.

  • If you want to enable it, first declare the following permissions:

    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
  • Then enable the builtin service as follows, by adding the following code very early in your application, in your main activity’s onCreate for instance:
  • MainActivity.kt file Need to be changed.
// Import WebRTCModuleOptions
import com.oney.WebRTCModule.WebRTCModuleOptions

  override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // ✅ Enable Screen Sharing Foreground Service for Android 14+
        val options = WebRTCModuleOptions.getInstance()
        options.enableMediaProjectionService = true
    }

(6) Fix UnsatisfiedLinkError Crash

  • In android/gradle.properties: add this line.
  • This prevents an issue with WebRTC native libraries not loading correctly on some devices.
android.useFullClasspathForDexingTransform=true

(7) Test And Verify

  • After setting this up, clean and rebuild your project:
cd android
./gradlew clean
./gradlew assembleDebug

(8) Project Setup For Audio & Video Call

Step-1: Configure ICE Servers (STUN/TURN Servers) Create WebrtcConfig.ts File Under src/screens/WebrtcConfig.ts
interface RTCIceServer {
    urls: string | string[];
    username?: string;
    credential?: string;
  }
  
  interface RTCConfiguration {
    iceServers: RTCIceServer[];
    iceCandidatePoolSize?: number;
  }
  
  export const configuration: RTCConfiguration = {
    iceServers: [
      { urls: ['stun:stun1.l.google.com:19302', 'stun:stun2.l.google.com:19302'] },
    ],
    iceCandidatePoolSize: 10,
  };
  
Step-2: Create CallActionBox.tsx Component Under src/components/CallActionBox.tsx
import React from 'react';
import { View, TouchableOpacity, Text, StyleSheet } from 'react-native';

interface CallActionBoxProps {
  switchCamera: () => void;
  toggleMute: () => void;
  toggleCamera: () => void;
  endCall: () => void;
}

const CallActionBox: React.FC<CallActionBoxProps> = ({
  switchCamera,
  toggleMute,
  toggleCamera,
  endCall,
}) => {
  return (
    <View style={styles.actionContainer}>
      <TouchableOpacity style={styles.greenButton} onPress={switchCamera}>
        <Text style={styles.buttonText}>Switch Camera</Text>
      </TouchableOpacity>

      <TouchableOpacity style={styles.yellowButton} onPress={toggleMute}>
        <Text style={styles.buttonText}>Toggle Mute</Text>
      </TouchableOpacity>

      <TouchableOpacity style={styles.yellowButton} onPress={toggleCamera}>
        <Text style={styles.buttonText}>Toggle Camera</Text>
      </TouchableOpacity>

      <TouchableOpacity style={styles.redButton} onPress={endCall}>
        <Text style={styles.buttonText}>End Call</Text>
      </TouchableOpacity>
    </View>
  );
};

const styles = StyleSheet.create({
  actionContainer: {
    flexDirection: 'row',
    justifyContent: 'space-around',
    backgroundColor: '#1F2937', // Equivalent to bg-gray-800
    padding: 16, // Equivalent to p-4
    borderTopLeftRadius: 16, // Equivalent to rounded-t-2xl
    borderTopRightRadius: 16,
  },
  greenButton: {
    backgroundColor: '#10B981', // Equivalent to bg-green-500
    padding: 12, // Equivalent to p-3
    borderRadius: 8, // Equivalent to rounded-lg
  },
  yellowButton: {
    backgroundColor: '#F59E0B', // Equivalent to bg-yellow-500
    padding: 12,
    borderRadius: 8,
  },
  redButton: {
    backgroundColor: '#EF4444', // Equivalent to bg-red-500
    padding: 12,
    borderRadius: 8,
  },
  buttonText: {
    color: 'white',
    fontWeight: 'bold',
  },
});

export default CallActionBox;
Step-3: Create StartCallScreen.tsf File Under src/screens/StartCallScreen.tsx
import React, { useState, useEffect } from 'react';
import { Text, StyleSheet, Button, View } from 'react-native';
import {
  RTCPeerConnection,
  RTCView,
  RTCIceCandidate,
  RTCSessionDescription,
  MediaStream,
  mediaDevices,
  
} from 'react-native-webrtc';
import firestore from '@react-native-firebase/firestore';
import CallActionBox from '../components/CallActionBox'
import { configuration } from '../config/webrtcConfig';

type StartCallScreenProps = {
  roomId: string;
  screens: { ROOM: string; CALL: string };
  setScreen: (screen: string) => void;
};

interface MediaDeviceInfo {
    deviceId: string;
    kind: 'audioinput' | 'audiooutput' | 'videoinput';
    label: string;
    groupId?: string;
    facing?: 'front' | 'environment';
  }

export default function StartCallScreen({ roomId, screens, setScreen }: StartCallScreenProps) {
  const [localStream, setLocalStream] = useState<MediaStream | null>(null);
  const [remoteStream, setRemoteStream] = useState<MediaStream | null>(null);
  const [cachedLocalPC, setCachedLocalPC] = useState<RTCPeerConnection | null>(null);
  const [isMuted, setIsMuted] = useState(false);
  const [isOffCam, setIsOffCam] = useState(false);

  useEffect(() => {
    startLocalStream();
  }, []);

  useEffect(() => {
    if (localStream) {
      startCall(roomId);
    }
  }, [localStream]);

  const endCall = async () => {
    if (cachedLocalPC) {
      const senders = cachedLocalPC.getSenders();
      senders.forEach((sender) => cachedLocalPC.removeTrack(sender));
      cachedLocalPC.close();
    }

    const roomRef = firestore().collection('room').doc(roomId);
    await roomRef.update({ answer: firestore.FieldValue.delete(), connected: false });

    setLocalStream(null);
    setRemoteStream(null);
    setCachedLocalPC(null);

    setScreen(screens.ROOM);
  };

  const startLocalStream = async () => {
    const isFront = true;
    const devices = await mediaDevices.enumerateDevices() as MediaDeviceInfo[];
    const facingMode = isFront ? 'user' : 'environment';
    const videoSourceId = devices.find(
      (device) => device.kind === 'videoinput' && device.facing === (isFront ? 'front' : 'environment')
    );

    const constraints = {
      audio: true,
      video: {
        mandatory: {
          minWidth: 500,
          minHeight: 300,
          minFrameRate: 30,
        },
        facingMode,
        optional: videoSourceId ? [{ sourceId: videoSourceId.deviceId }] : [],
      },
    };

    const newStream = await mediaDevices.getUserMedia(constraints);
    setLocalStream(newStream);
  };

  const startCall = async (id: string) => {
    const roomRef = firestore().collection('room').doc(id);
    const localPC: RTCPeerConnection = new RTCPeerConnection(configuration);

    localStream?.getTracks().forEach((track) => {
      localPC.addTrack(track, localStream);
    });

    const callerCandidatesCollection = roomRef.collection('callerCandidates');
    const calleeCandidatesCollection = roomRef.collection('calleeCandidates');

    (localPC as any).onicecandidate = (event: { candidate: RTCIceCandidate | null }) => {
        if (event.candidate) {
          callerCandidatesCollection.add(event.candidate.toJSON());
        }
      };
      

    (localPC as any).ontrack = (event: any) => {
    const newStream = new MediaStream();
    event.streams[0].getTracks().forEach((track: any) => {
        newStream.addTrack(track);
    });
    setRemoteStream(newStream);
    };

    const offer = await localPC.createOffer({});
    await localPC.setLocalDescription(offer);

    await roomRef.set({ offer, connected: false });

    roomRef.onSnapshot(async (snapshot) => {
      const data = snapshot.data();
      if (!localPC.remoteDescription && data?.answer) {
        const answer = new RTCSessionDescription(data.answer);
        await localPC.setRemoteDescription(answer);
      }      
    });

    calleeCandidatesCollection.onSnapshot((snapshot) => {
      snapshot.docChanges().forEach((change) => {
        if (change.type === 'added') {
          const data = change.doc.data();
          localPC.addIceCandidate(new RTCIceCandidate(data));
        }
      });
    });

    setCachedLocalPC(localPC);
  };

  const switchCamera = () => {
    localStream?.getVideoTracks().forEach((track) => track._switchCamera());
  };

  const toggleMute = () => {
    localStream?.getAudioTracks().forEach((track) => {
      track.enabled = !track.enabled;
      setIsMuted(!track.enabled);
    });
  };

  const toggleCamera = () => {
    localStream?.getVideoTracks().forEach((track) => {
      track.enabled = !track.enabled;
      setIsOffCam(!track.enabled);
    });
  };

  return (
    <View style={{ flex: 1 }}>
      {remoteStream && <RTCView style={{ flex: 1 }} streamURL={remoteStream.toURL()} objectFit="cover" />}
      {localStream && !isOffCam && (
        <RTCView
          style={{ width: 100, height: 150, position: 'absolute', top: 10, right: 10 }}
          streamURL={localStream.toURL()}
        />
      )}
      <View style={{ position: 'absolute', bottom: 0, width: '100%' }}>
        <CallActionBox switchCamera={switchCamera} toggleMute={toggleMute} toggleCamera={toggleCamera} endCall={endCall} />
      </View>
    </View>
  );
}
Step-4: Create JoinCallScreen.tsf File Under src/screens/Audio&VideoScreens/StartCallScreen.tsx
import { useEffect, useState } from 'react';
import { View, Text, Button, Alert } from 'react-native';
import { RTCPeerConnection, mediaDevices, RTCIceCandidate, RTCSessionDescription, MediaStream, MediaStreamTrack } from 'react-native-webrtc';
import firestore from '@react-native-firebase/firestore';
import { configuration } from '../../config/webrtcConfig';

interface JoinScreenProps {
    roomId: string;
    setScreen: (screen: string) => void;
}

export default function JoinScreen({ roomId, setScreen }: JoinScreenProps) {
    const [localStream, setLocalStream] = useState<MediaStream | null>(null);
    const [remoteStream, setRemoteStream] = useState<MediaStream | null>(null);
    const [peerConnection, setPeerConnection] = useState<RTCPeerConnection | null>(null);

    useEffect(() => {
        startLocalStream();
    }, []);

    async function startLocalStream() {
        try {
            const stream = await mediaDevices.getUserMedia({ video: true, audio: true });
            setLocalStream(stream);
        } catch (error) {
            console.error('Failed to get local stream:', error);
        }
    }

    async function joinCall() {
        const roomRef = firestore().collection('room').doc(roomId);
        const roomSnapshot = await roomRef.get();

        if (!roomSnapshot.exists) {
            Alert.alert('Room does not exist!');
            return;
        }

        const pc = new RTCPeerConnection(configuration);

        localStream?.getTracks().forEach((track: MediaStreamTrack) => {
            pc.addTrack(track, localStream);
        });

        const callerCandidatesCollection = roomRef.collection('callerCandidates');
        const calleeCandidatesCollection = roomRef.collection('calleeCandidates');

        (pc as any).onicecandidate = (event: { candidate: RTCIceCandidate | null }) => {
            if (event.candidate) {
                callerCandidatesCollection.add(event.candidate.toJSON());
            }
        };


        (pc as any).ontrack = (event: any) => {
            const newStream = new MediaStream();
            event.streams[0].getTracks().forEach((track: any) => {
                newStream.addTrack(track);
            });
            setRemoteStream(newStream);
        };

        const offer = roomSnapshot.data()?.offer;
        if (!offer) {
            console.error('Offer not found in room data');
            return;
        }

        await pc.setRemoteDescription(new RTCSessionDescription(offer));

        const answer = await pc.createAnswer();
        await pc.setLocalDescription(answer);

        await roomRef.update({ answer });

        callerCandidatesCollection.onSnapshot((snapshot) => {
            snapshot.docChanges().forEach((change) => {
                if (change.type === 'added') {
                    const candidate = new RTCIceCandidate(change.doc.data());
                    pc.addIceCandidate(candidate);
                }
            });
        });

        setPeerConnection(pc);
    }

    return (
        <View>
            <Button title="Join Call" onPress={joinCall} />
            {remoteStream && <Text>Connected to Call</Text>}
        </View>
    );
}
Step-5: Define IncommingCallListener.ts file Under src/Services/IncommingCallListner.ts
  • You need to define the IncommingCallListner.ts file to listen the incoming call coming for the particular mobile number.
  • When you make a call from your device it will create a collection called ‘Calls’ in the firestore database.
  • It will check the receiverId if it is matching with the userContact number then the joinCall screen will be displayed.
/**
 * @fileoverview IncomingCallListner.tsx Will Listen The Incoming Calls From Firestore And Navigate To JoinCallScreen.tsx
 * @author Subrat Kumar Sahoo
 * @date 2025-01-30
 * @version 1.0.0
 */

import { useEffect } from "react";
import { useNavigation, NavigationContainerRef } from "@react-navigation/native";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import firestore from "@react-native-firebase/firestore";
import { RootStackParamList } from '../navigation/navigationTypes';
import StartCallScreen from "../screens/Audio&VideoScreens/StartCallScreen";
import JoinCallScreen from "../screens/Audio&VideoScreens/JoinCallScreen";
import { useUser } from '../context/UserContext';

type JoinCallScreenNavigationProp = NativeStackNavigationProp<RootStackParamList, 'JoinCallScreen'>;
type ContactsScreenNavigationProp = NativeStackNavigationProp<RootStackParamList, 'ContactsScreen'>;

const IncomingCallListener = ({ navigationRef }: { navigationRef: NavigationContainerRef<any> }) => {
  const joinCallScreenNavigation = useNavigation<JoinCallScreenNavigationProp>();
  const contactsScreenNavigation = useNavigation<ContactsScreenNavigationProp>();
  const { userId, userName, userContact } = useUser();
  // ✅ Without Country Code
  const removeCountryCode = (phoneNumber: string) => {
    return phoneNumber.replace(/^\+?\d+\s?/, "").replace(/\s/g, "");
  };
  // ✅ With Country Code
  const removeNonNumeric = (phoneNumber: string) => {
    return phoneNumber.replace(/\D/g, "");
  };

  useEffect(() => {
    const unsubscribe = firestore()
      .collection("calls")
      .where('receiverId', 'in', [removeCountryCode(userContact), removeNonNumeric(userContact)])
      .where("status", "==", "incoming") // Listen for active incoming calls
      .onSnapshot((querySnapshot) => {
        querySnapshot.forEach((doc) => {
          const callData = doc.data();
          if (callData) {
            console.log("📞 Incoming Call Detected:", callData);
            // ➡️ Auto Navigate To JoinCallScreen.
            navigationRef.navigate("JoinCallScreen", { callerId: callData.callerId, callerName: callData.callerName });
          }
        });
      });

    return () => unsubscribe(); // Cleanup listener
  }, [userContact]);

  return null;
};

export default IncomingCallListener;
  • Then you need to define the IncommingCallListner in the App.tsx file globally to listen to the incoming calls.
/**
 * @fileoverview Main Entry Point of the Application
 * @author Subrat Kumar Sahoo
 * @date 2025-01-30
 * @version 1.0.0
 */

import React, { useState, useEffect } from "react";
import { NavigationContainer, useNavigationContainerRef  } from "@react-navigation/native";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import SplashScreen from "./src/screens/SplashScreen"; // Lottie Splash Screen
import LoginScreen from "./src/screens/LoginScreen"; // Login Screen
import HomeScreen from "./src/screens/HomeScreen"; // Home Screen
import UserOnboarding from "./src/components/UserOnboarding";
import JoinCallScreen from "./src/screens/Audio&VideoScreens/JoinCallScreen";
import ContactsScreen from "./src/screens/ContactsScreen";
import DrawerNavigator from "./src/navigation/DrawerNavigator"; // Drawer Navigation
import { UserDetailsProvider } from './src/context/UserContext';
import IncomingCallListener from "./src/services/IncomingCallListener";

const Stack = createNativeStackNavigator();

const App: React.FC = () => {
  const navigationRef = useNavigationContainerRef();
  /************************************* Lottie Animation Logics Starts******************************* */
  /**
   * When the app starts, the splash screen (Lottie animation) will appear.
   * After the Lottie animation finishes (after 2 seconds in this case), 
   * the app will navigate to the Login Screen.
   */
  const [isLottieFinished, setIsLottieFinished] = useState(false);

  useEffect(() => {
    // Set a timer for the Lottie animation duration (e.g., 2 seconds)
    const timer = setTimeout(() => {
      setIsLottieFinished(true);
    }, 2000);

    return () => clearTimeout(timer); // Cleanup timer on unmount
  }, []);

  if (!isLottieFinished) {
    return <SplashScreen onAnimationFinish={() => setIsLottieFinished(true)} />;
  }
  /* ************************************ Lottie Animation Logics Ends*********************************** */

  /************************************ Application View (Navigation) Starts *********************************** */
  return (
    <UserDetailsProvider>
      <NavigationContainer ref={navigationRef}>
        <Stack.Navigator initialRouteName="LoginScreen">
          {/* ✅ Login Screen ✅ */}
          <Stack.Screen
            name="LoginScreen"
            component={LoginScreen}
            options={{ headerShown: false }}
          />

          {/* ✅ Drawer Navigator ✅ */}
          <Stack.Screen
            name="DrawerNavigator"
            component={DrawerNavigator}
            options={{ headerShown: false }}
          />
          {/* ✅ Incoming Call Screen (Must Be in Stack) ✅ */}
          <Stack.Screen
            name="JoinCallScreen"
            component={JoinCallScreen}
            options={{ headerShown: false }}
          />
        </Stack.Navigator>
        {/* ✅ IncomingCallListener Runs Globally To Detect Calls ✅ */}
        <IncomingCallListener navigationRef={navigationRef}/>
      </NavigationContainer>
    </UserDetailsProvider>
  );
  /************************************ Application View (Navigation) Ends *********************************** */
};

export default App;

Leave a Reply

Your email address will not be published. Required fields are marked *