diff --git a/react-native-expo-app/README.md b/react-native-expo-app/README.md
index 55eeca4..532ad0b 100644
--- a/react-native-expo-app/README.md
+++ b/react-native-expo-app/README.md
@@ -1,3 +1,47 @@
[](https://github.com/react-native-webrtc/examples)
# React Native Expo App
+
+This is a demo app of using WebRTC with React Native. It's built with Expo and uses a [custom development build](https://docs.expo.dev/develop/development-builds/introduction/).
+
+
+
+| Join | Outgoing call | Incoming call | On call |
+| -------------------------------------------------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------------- | -------------------------------------------------------------------- |
+| | | | |
+
+## How to build
+
+The easy way to build this app is to use Expo's build service (EAS). Although you can [build it locally](https://docs.expo.dev/build-reference/local-builds/) if you prefer.
+
+Before you can build the app you need a few things:
+
+1. An Expo account
+2. Create a new project on EAS
+3. Update `extra.eas.projectId` in `client/app.json` with your project ID
+
+After you linked your project to EAS, you can run `yarn build:dev` on the `client` directory to start a dev build.
+
+This will generate a custom Expo client that contains `react-native-webrtc` and other packages that are not available on the Expo Go app.
+
+Take a look at `client/package.json` to see other build scripts.
+
+## How to run
+
+1. Clone this repo
+2. Run `yarn install` on both `client` and `server` directories (in separate terminals)
+3. Update `EXPO_PUBLIC_SIGNALING_SERVER_URL` in `client/.env` with your signaling server URL (this can be your computer's IP address)
+4. Run `yarn start` on both `client` and `server` directories
+5. Install the custom dev client on your mobile devices
+6. Make sure the server and both devices are on the same network
+7. Open the app on two devices and start a video call
+
+## How it works
+
+For the client, there are three files that you want to take a look at (the rest can be ignored):
+
+1. `client/App.tsx` - This is the main entry point of the app. It contains the UI and links everything together.
+2. `client/src/signaling.ts` - This contains a class that lets you create [signaling channels](https://webrtc.org/getting-started/peer-connections#signaling).
+3. `client/src/useWebRTC.ts` - This is the most interesting file. It contains all the logic for creating and managing WebRTC connections.
+
+The server is only used for signling. It's a simple [Socket.IO](https://socket.io/) server that relays messages between clients.
diff --git a/react-native-expo-app/client/.env b/react-native-expo-app/client/.env
new file mode 100644
index 0000000..5cdf7ee
--- /dev/null
+++ b/react-native-expo-app/client/.env
@@ -0,0 +1 @@
+EXPO_PUBLIC_SIGNALING_SERVER_URL=http://192.168.1.200:3000
\ No newline at end of file
diff --git a/react-native-expo-app/client/.eslintrc.json b/react-native-expo-app/client/.eslintrc.json
new file mode 100644
index 0000000..28e162c
--- /dev/null
+++ b/react-native-expo-app/client/.eslintrc.json
@@ -0,0 +1,68 @@
+{
+ "env": {
+ "es2021": true,
+ "react-native/react-native": true
+ },
+ "extends": [
+ "eslint:recommended",
+ "plugin:react/recommended",
+ "plugin:@typescript-eslint/recommended",
+ "plugin:react/jsx-runtime",
+ // "plugin:import/recommended",
+ "plugin:import/typescript"
+ ],
+ "parser": "@typescript-eslint/parser",
+ "parserOptions": {
+ "ecmaFeatures": {
+ "jsx": true
+ },
+ "ecmaVersion": 12,
+ "sourceType": "module"
+ },
+ "plugins": [
+ "react",
+ "@typescript-eslint",
+ "react-hooks",
+ "react-native",
+ "import"
+ ],
+ "rules": {
+ "@typescript-eslint/explicit-module-boundary-types": "off",
+ "@typescript-eslint/no-extra-semi": "off",
+ "@typescript-eslint/no-explicit-any": "off",
+ "react-hooks/rules-of-hooks": "error",
+ "react-hooks/exhaustive-deps": "warn",
+ "react-native/no-unused-styles": "off",
+ "react-native/no-single-element-style-arrays": "warn",
+ "@typescript-eslint/ban-ts-comment": "off",
+ "@typescript-eslint/no-var-requires": "off",
+ "react/prop-types": "off",
+ "no-undef": "off",
+ "no-useless-escape": "off",
+ "@typescript-eslint/no-non-null-assertion": "off",
+ "react-native/no-raw-text": "warn",
+ "react/display-name": "off",
+ "react/no-unescaped-entities": "off",
+ "no-case-declarations": "off",
+ "import/no-unused-modules": [
+ 1,
+ {
+ "unusedExports": true,
+ "ignoreExports": [
+ "./app",
+ "./redux",
+ "app.config.js"
+ ]
+ }
+ ]
+ },
+ "settings": {
+ "react": {
+ "version": "detect"
+ },
+ "import/resolver": {
+ "typescript": true,
+ "node": true
+ }
+ }
+}
\ No newline at end of file
diff --git a/react-native-expo-app/client/.gitignore b/react-native-expo-app/client/.gitignore
new file mode 100644
index 0000000..05647d5
--- /dev/null
+++ b/react-native-expo-app/client/.gitignore
@@ -0,0 +1,35 @@
+# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
+
+# dependencies
+node_modules/
+
+# Expo
+.expo/
+dist/
+web-build/
+
+# Native
+*.orig.*
+*.jks
+*.p8
+*.p12
+*.key
+*.mobileprovision
+
+# Metro
+.metro-health-check*
+
+# debug
+npm-debug.*
+yarn-debug.*
+yarn-error.*
+
+# macOS
+.DS_Store
+*.pem
+
+# local env files
+.env*.local
+
+# typescript
+*.tsbuildinfo
diff --git a/react-native-expo-app/client/.prettierrc b/react-native-expo-app/client/.prettierrc
new file mode 100644
index 0000000..302bb66
--- /dev/null
+++ b/react-native-expo-app/client/.prettierrc
@@ -0,0 +1,7 @@
+{
+ "printWidth": 100,
+ "tabWidth": 2,
+ "singleQuote": false,
+ "trailingComma": "es5",
+ "semi": false
+}
diff --git a/react-native-expo-app/client/App.tsx b/react-native-expo-app/client/App.tsx
new file mode 100644
index 0000000..f631607
--- /dev/null
+++ b/react-native-expo-app/client/App.tsx
@@ -0,0 +1,52 @@
+import { useRef, useState } from "react"
+import IncomingView from "src/IncomingView"
+import JoinView from "src/JoinView"
+import MeetingView from "src/MeetingView"
+import OutgoingView from "src/OutgoingView"
+import { useWebRTC } from "src/useWebRTC"
+
+const App = () => {
+ const localClientId = useRef(Math.floor(100000 + Math.random() * 900000).toString())
+ const [remoteClientId, setRemoteClientId] = useState("")
+ const {
+ callState,
+ localStream,
+ remoteStream,
+ makeCall,
+ acceptCall,
+ endCall,
+ isMicEnabled,
+ isCameraEnabled,
+ isFrontCamera,
+ toggleMute,
+ toggleCamera,
+ toggleCameraMode,
+ } = useWebRTC(localClientId.current, setRemoteClientId)
+
+ return callState === "idle" ? (
+ {
+ setRemoteClientId(remoteClientIdArg)
+ makeCall(remoteClientIdArg)
+ }}
+ />
+ ) : callState === "incoming" ? (
+
+ ) : callState === "outgoing" ? (
+ null} />
+ ) : callState === "connected" ? (
+
+ ) : null
+}
+export default App
diff --git a/react-native-expo-app/client/app.json b/react-native-expo-app/client/app.json
new file mode 100644
index 0000000..664c08c
--- /dev/null
+++ b/react-native-expo-app/client/app.json
@@ -0,0 +1,32 @@
+{
+ "expo": {
+ "name": "WebRTC Expo Example",
+ "slug": "webrtc-expo-example",
+ "version": "1.0.0",
+ "orientation": "portrait",
+ "icon": "./assets/icon.png",
+ "userInterfaceStyle": "light",
+ "splash": {
+ "image": "./assets/splash.png",
+ "resizeMode": "contain",
+ "backgroundColor": "#ffffff"
+ },
+ "plugins": [
+ "@config-plugins/react-native-webrtc"
+ ],
+ "extra": {
+ "eas": {
+ "projectId": "298b5afc-c9dd-42ff-9188-bac1105c6a0b"
+ }
+ },
+ "experiments": {
+ "tsconfigPaths": true
+ },
+ "ios": {
+ "bundleIdentifier": "com.example.webrtcexpoexample"
+ },
+ "android": {
+ "package": "com.example.webrtcexpoexample"
+ }
+ }
+}
\ No newline at end of file
diff --git a/react-native-expo-app/client/assets/icon.png b/react-native-expo-app/client/assets/icon.png
new file mode 100644
index 0000000..bbb8f40
Binary files /dev/null and b/react-native-expo-app/client/assets/icon.png differ
diff --git a/react-native-expo-app/client/assets/splash.png b/react-native-expo-app/client/assets/splash.png
new file mode 100644
index 0000000..0e89705
Binary files /dev/null and b/react-native-expo-app/client/assets/splash.png differ
diff --git a/react-native-expo-app/client/assets/svgs/CallAnswer.js b/react-native-expo-app/client/assets/svgs/CallAnswer.js
new file mode 100644
index 0000000..189fba1
--- /dev/null
+++ b/react-native-expo-app/client/assets/svgs/CallAnswer.js
@@ -0,0 +1,16 @@
+import * as React from 'react';
+import Svg, {Path} from 'react-native-svg';
+
+const SvgComponent = props => (
+
+);
+
+export default SvgComponent;
diff --git a/react-native-expo-app/client/assets/svgs/CallEnd.js b/react-native-expo-app/client/assets/svgs/CallEnd.js
new file mode 100644
index 0000000..6971f9e
--- /dev/null
+++ b/react-native-expo-app/client/assets/svgs/CallEnd.js
@@ -0,0 +1,15 @@
+import * as React from "react"
+import Svg, { Path } from "react-native-svg"
+
+function SvgComponent(props) {
+ return (
+
+ )
+}
+
+export default SvgComponent
diff --git a/react-native-expo-app/client/assets/svgs/CameraSwitch.js b/react-native-expo-app/client/assets/svgs/CameraSwitch.js
new file mode 100644
index 0000000..6e8b5a3
--- /dev/null
+++ b/react-native-expo-app/client/assets/svgs/CameraSwitch.js
@@ -0,0 +1,22 @@
+import * as React from "react";
+import Svg, { Defs, Path } from "react-native-svg";
+
+function CameraSwitch(props) {
+ return (
+
+ );
+}
+
+export default CameraSwitch;
diff --git a/react-native-expo-app/client/assets/svgs/Leave.js b/react-native-expo-app/client/assets/svgs/Leave.js
new file mode 100644
index 0000000..8e5c4df
--- /dev/null
+++ b/react-native-expo-app/client/assets/svgs/Leave.js
@@ -0,0 +1,27 @@
+import * as React from "react";
+import Svg, { G, Path, Defs, ClipPath } from "react-native-svg";
+
+function Leave(props) {
+ return (
+
+ );
+}
+
+export default Leave;
diff --git a/react-native-expo-app/client/assets/svgs/MicOff.js b/react-native-expo-app/client/assets/svgs/MicOff.js
new file mode 100644
index 0000000..e02cf3b
--- /dev/null
+++ b/react-native-expo-app/client/assets/svgs/MicOff.js
@@ -0,0 +1,16 @@
+import * as React from 'react';
+import Svg, {Path} from 'react-native-svg';
+
+function MicOff(props) {
+ return (
+
+ );
+}
+
+export default MicOff;
diff --git a/react-native-expo-app/client/assets/svgs/MicOn.js b/react-native-expo-app/client/assets/svgs/MicOn.js
new file mode 100644
index 0000000..119c396
--- /dev/null
+++ b/react-native-expo-app/client/assets/svgs/MicOn.js
@@ -0,0 +1,32 @@
+import * as React from 'react';
+import Svg, {Defs, ClipPath, Path, G} from 'react-native-svg';
+
+function MicOn(props) {
+ return (
+
+ );
+}
+
+export default MicOn;
diff --git a/react-native-expo-app/client/assets/svgs/VideoOff.js b/react-native-expo-app/client/assets/svgs/VideoOff.js
new file mode 100644
index 0000000..100ab8f
--- /dev/null
+++ b/react-native-expo-app/client/assets/svgs/VideoOff.js
@@ -0,0 +1,24 @@
+import * as React from "react";
+import Svg, { Defs, ClipPath, Path, G } from "react-native-svg";
+
+function VideoOff(props) {
+ return (
+
+ );
+}
+
+export default VideoOff;
diff --git a/react-native-expo-app/client/assets/svgs/VideoOn.js b/react-native-expo-app/client/assets/svgs/VideoOn.js
new file mode 100644
index 0000000..d31dd7b
--- /dev/null
+++ b/react-native-expo-app/client/assets/svgs/VideoOn.js
@@ -0,0 +1,15 @@
+import * as React from "react";
+import Svg, { Path } from "react-native-svg";
+
+function VideoOn(props) {
+ return (
+
+ );
+}
+
+export default VideoOn;
diff --git a/react-native-expo-app/client/babel.config.js b/react-native-expo-app/client/babel.config.js
new file mode 100644
index 0000000..2900afe
--- /dev/null
+++ b/react-native-expo-app/client/babel.config.js
@@ -0,0 +1,6 @@
+module.exports = function(api) {
+ api.cache(true);
+ return {
+ presets: ['babel-preset-expo'],
+ };
+};
diff --git a/react-native-expo-app/client/eas.json b/react-native-expo-app/client/eas.json
new file mode 100644
index 0000000..d629d8d
--- /dev/null
+++ b/react-native-expo-app/client/eas.json
@@ -0,0 +1,23 @@
+{
+ "cli": {
+ "version": ">= 5.0.0"
+ },
+ "build": {
+ "development": {
+ "developmentClient": true,
+ "distribution": "internal",
+ "android": {
+ "buildType": "apk"
+ }
+ },
+ "preview": {
+ "distribution": "internal",
+ "android": {
+ "buildType": "apk"
+ }
+ },
+ "production": {
+ "distribution": "store"
+ }
+ }
+}
\ No newline at end of file
diff --git a/react-native-expo-app/client/package.json b/react-native-expo-app/client/package.json
new file mode 100644
index 0000000..2c9cace
--- /dev/null
+++ b/react-native-expo-app/client/package.json
@@ -0,0 +1,37 @@
+{
+ "name": "react-native-webrtc-expo-example-client",
+ "version": "1.0.0",
+ "main": "node_modules/expo/AppEntry.js",
+ "author": {
+ "name": "Mohammed Handa",
+ "email": "mohamadhenda@gmail.com",
+ "url": "https://mohammedhanda.com"
+ },
+ "scripts": {
+ "start": "expo start",
+ "android": "expo start --android",
+ "ios": "expo start --ios",
+ "web": "expo start --web",
+ "build:dev": "eas build --profile=development",
+ "build:preview": "eas build --profile=preview"
+ },
+ "dependencies": {
+ "@config-plugins/react-native-webrtc": "^8.0.0",
+ "event-target-shim": "^6.0.2",
+ "events": "^3.3.0",
+ "expo": "~50.0.7",
+ "expo-dev-client": "~3.3.8",
+ "expo-status-bar": "~1.11.1",
+ "react": "18.2.0",
+ "react-native": "0.73.4",
+ "react-native-svg": "14.1.0",
+ "react-native-webrtc": "^118.0.1",
+ "socket.io-client": "^4.7.4"
+ },
+ "devDependencies": {
+ "@babel/core": "^7.20.0",
+ "@types/react": "^18.2.56",
+ "typescript": "^5.3.3"
+ },
+ "private": true
+}
diff --git a/react-native-expo-app/client/src/IconContainer.tsx b/react-native-expo-app/client/src/IconContainer.tsx
new file mode 100644
index 0000000..7038039
--- /dev/null
+++ b/react-native-expo-app/client/src/IconContainer.tsx
@@ -0,0 +1,31 @@
+import React, { FC } from "react"
+import { TouchableOpacity } from "react-native"
+
+type Props = {
+ backgroundColor?: string
+ onPress: () => void
+ Icon: FC
+ style?: any
+}
+
+const IconContainer: FC = ({ backgroundColor, onPress, Icon, style }) => {
+ return (
+
+
+
+ )
+}
+export default IconContainer
diff --git a/react-native-expo-app/client/src/IncomingView.tsx b/react-native-expo-app/client/src/IncomingView.tsx
new file mode 100644
index 0000000..99d38c6
--- /dev/null
+++ b/react-native-expo-app/client/src/IncomingView.tsx
@@ -0,0 +1,63 @@
+import CallAnswer from "assets/svgs/CallAnswer"
+import { FC } from "react"
+import { StyleProp, Text, TouchableOpacity, View, ViewStyle } from "react-native"
+
+type Props = {
+ style?: StyleProp
+ remoteClientId: string
+ onAnswer: () => void
+}
+
+const IncomingView: FC = ({ remoteClientId, onAnswer }) => {
+ return (
+
+
+
+ {remoteClientId} is calling..
+
+
+
+ {
+ onAnswer()
+ }}
+ style={{
+ backgroundColor: "green",
+ borderRadius: 30,
+ height: 60,
+ aspectRatio: 1,
+ justifyContent: "center",
+ alignItems: "center",
+ }}
+ >
+
+
+
+
+ )
+}
+export default IncomingView
diff --git a/react-native-expo-app/client/src/JoinView.tsx b/react-native-expo-app/client/src/JoinView.tsx
new file mode 100644
index 0000000..d6524c4
--- /dev/null
+++ b/react-native-expo-app/client/src/JoinView.tsx
@@ -0,0 +1,112 @@
+import TextInputContainer from "./TextInputContainer"
+import { FC, useState } from "react"
+import { Keyboard, Text, TouchableOpacity, TouchableWithoutFeedback, View } from "react-native"
+
+type Props = {
+ localClientId: string
+ onCall: (remoteClientId: string) => void
+}
+
+const JoinView: FC = ({ localClientId, onCall }) => {
+ const [otherUserId, setOtherUserId] = useState("")
+
+ return (
+
+
+ <>
+
+
+ Your Caller ID
+
+
+
+ {localClientId}
+
+
+
+
+
+
+ Enter the other user's ID
+
+
+ {
+ onCall(otherUserId)
+ }}
+ style={{
+ height: 50,
+ backgroundColor: "#5568FE",
+ justifyContent: "center",
+ alignItems: "center",
+ borderRadius: 12,
+ marginTop: 16,
+ }}
+ >
+
+ Call Now
+
+
+
+ >
+
+
+ )
+}
+export default JoinView
diff --git a/react-native-expo-app/client/src/MeetingView.tsx b/react-native-expo-app/client/src/MeetingView.tsx
new file mode 100644
index 0000000..b0e6680
--- /dev/null
+++ b/react-native-expo-app/client/src/MeetingView.tsx
@@ -0,0 +1,148 @@
+import CallEnd from "assets/svgs/CallEnd"
+import CameraSwitch from "assets/svgs/CameraSwitch"
+import MicOff from "assets/svgs/MicOff"
+import MicOn from "assets/svgs/MicOn"
+import VideoOff from "assets/svgs/VideoOff"
+import VideoOn from "assets/svgs/VideoOn"
+import IconContainer from "./IconContainer"
+import { FC } from "react"
+import { View } from "react-native"
+import { MediaStream, RTCView } from "react-native-webrtc"
+
+type Props = {
+ localStream?: MediaStream
+ remoteStream?: MediaStream
+ onLeave?: () => void
+ onToggleMic?: () => void
+ onToggleCamera?: () => void
+ onSwitchCamera?: () => void
+ isMuted?: boolean
+ isCameraOn?: boolean
+ isFrontCamera?: boolean
+}
+
+const MeetingView: FC = ({
+ localStream,
+ remoteStream,
+ onLeave,
+ onToggleCamera,
+ onSwitchCamera,
+ onToggleMic,
+ isCameraOn,
+ isMuted,
+ isFrontCamera,
+}) => {
+
+ return (
+
+
+ {remoteStream ? (
+
+ ) : (
+
+ )}
+
+
+ {localStream ? (
+
+ ) : (
+
+ )}
+
+
+ {
+ onLeave?.()
+ }}
+ Icon={() => {
+ return
+ }}
+ />
+ {
+ onToggleMic?.()
+ }}
+ Icon={() => {
+ return isMuted ? (
+
+ ) : (
+
+ )
+ }}
+ />
+ {
+ onToggleCamera?.()
+ }}
+ Icon={() => {
+ return isCameraOn ? (
+
+ ) : (
+
+ )
+ }}
+ />
+ {
+ onSwitchCamera?.()
+ }}
+ Icon={() => {
+ return
+ }}
+ />
+
+
+ )
+}
+export default MeetingView
diff --git a/react-native-expo-app/client/src/OutgoingView.tsx b/react-native-expo-app/client/src/OutgoingView.tsx
new file mode 100644
index 0000000..b16242b
--- /dev/null
+++ b/react-native-expo-app/client/src/OutgoingView.tsx
@@ -0,0 +1,72 @@
+import CallEnd from "assets/svgs/CallEnd"
+import { FC } from "react"
+import { Text, TouchableOpacity, View } from "react-native"
+
+type Props = {
+ remoteClientId: string
+ onCancel: () => void
+}
+
+const OutgoingView: FC = ({ remoteClientId, onCancel }) => {
+ return (
+
+
+
+ Calling to...
+
+
+
+ {remoteClientId}
+
+
+
+ {
+ onCancel()
+ }}
+ style={{
+ backgroundColor: "#FF5D5D",
+ borderRadius: 30,
+ height: 60,
+ aspectRatio: 1,
+ justifyContent: "center",
+ alignItems: "center",
+ }}
+ >
+
+
+
+
+ )
+}
+export default OutgoingView
diff --git a/react-native-expo-app/client/src/TextInputContainer.tsx b/react-native-expo-app/client/src/TextInputContainer.tsx
new file mode 100644
index 0000000..2011fde
--- /dev/null
+++ b/react-native-expo-app/client/src/TextInputContainer.tsx
@@ -0,0 +1,45 @@
+import React, { ComponentProps, FC } from "react"
+import { View, TextInput } from "react-native"
+
+type Props = {
+ placeholder: string
+ value: string
+ setValue: (text: string) => void
+ keyboardType: ComponentProps["keyboardType"]
+}
+
+const TextInputContainer: FC = ({ placeholder, value, setValue, keyboardType }) => {
+ return (
+
+
+
+ )
+}
+
+export default TextInputContainer
diff --git a/react-native-expo-app/client/src/signaling.ts b/react-native-expo-app/client/src/signaling.ts
new file mode 100644
index 0000000..c8f867a
--- /dev/null
+++ b/react-native-expo-app/client/src/signaling.ts
@@ -0,0 +1,96 @@
+import { EventEmitter } from "events"
+import { io, Socket } from "socket.io-client"
+
+export type SignalingMessage = { type: string; payload: any }
+
+/**
+ * Serves as a bridge between two clients to pass messages
+ *
+ * **Note**: Messages are first sent to the server and then forwarded to the desired client
+ */
+export class SignalingChannel extends EventEmitter {
+ private socket: Socket
+ private _remoteClientId: string | null = null
+ get remoteClientId() {
+ return this._remoteClientId
+ }
+
+ /**
+ *
+ * @param localClientId id of the current client
+ */
+ constructor(private localClientId: string) {
+ super()
+ if (!process.env.EXPO_PUBLIC_SIGNALING_SERVER_URL) {
+ throw new Error("EXPO_PUBLIC_SIGNALING_SERVER_URL is not defined")
+ }
+ this.socket = io(process.env.EXPO_PUBLIC_SIGNALING_SERVER_URL, {
+ transports: ["websocket"],
+ query: {
+ localClientId,
+ },
+ })
+ this.socket.on("message", (message: SignalingMessage) => {
+ this.emit("message", message)
+ })
+ this.socket.on("connectTo", (remoteClientId: string) => {
+ this._remoteClientId = remoteClientId
+ console.log(`${this.localClientId} accepted connectTo ${remoteClientId}`)
+ })
+ this.socket.on("disconnectFrom", (remoteClientId: string) => {
+ if (this._remoteClientId !== remoteClientId) {
+ throw new Error("Remote client id did not match while disconnecting")
+ }
+ this._remoteClientId = null
+ this.emit("disconnected")
+ console.log(`${this.localClientId} accepted disconnect`)
+ })
+ }
+
+ /**
+ * Connect to the remote client by sending the current client id to the remote client
+ * This will let the other client know which client it's connecting to
+ * @param remoteClientId id of the remote client
+ */
+ connect(remoteClientId: string) {
+ this._remoteClientId = remoteClientId
+ console.log(`${this.localClientId} want to connectTo ${remoteClientId}`)
+ this.socket.emit("connectTo", remoteClientId)
+ }
+
+ /**
+ * Disconnect from the remote client
+ */
+ disconnect() {
+ this.socket.emit("disconnectFrom", this._remoteClientId)
+ this._remoteClientId = null
+ console.log(`${this.localClientId} want to disconnect`)
+ }
+
+ /**
+ * Send message to the remote client
+ * @param message message to send
+ */
+ send(message: SignalingMessage) {
+ if (!this._remoteClientId) {
+ throw new Error(`Attempted to send message without remote client id | type: ${message.type}`)
+ }
+ this.socket.emit(
+ "message",
+ {
+ remoteClientId: this._remoteClientId,
+ message,
+ },
+ (ack: any) => {
+ console.log(`ack`, ack)
+ }
+ )
+ }
+
+ /**
+ * Close the connection
+ */
+ close() {
+ this.socket.close()
+ }
+}
diff --git a/react-native-expo-app/client/src/useWebRTC.ts b/react-native-expo-app/client/src/useWebRTC.ts
new file mode 100644
index 0000000..dfdf409
--- /dev/null
+++ b/react-native-expo-app/client/src/useWebRTC.ts
@@ -0,0 +1,266 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react"
+import {
+ MediaStream,
+ RTCIceCandidate,
+ RTCPeerConnection,
+ RTCSessionDescription,
+ mediaDevices,
+} from "react-native-webrtc"
+import RTCIceCandidateEvent from "react-native-webrtc/lib/typescript/RTCIceCandidateEvent"
+import RTCTrackEvent from "react-native-webrtc/lib/typescript/RTCTrackEvent"
+import { SignalingChannel, SignalingMessage } from "./signaling"
+
+export const useWebRTC = (
+ localClientId: string,
+ onIncomingCall?: (remoteClientId: string) => void
+) => {
+ //* state and config ----------------------------------------------------------------------------
+ const [callState, setCallState] = useState<"idle" | "outgoing" | "incoming" | "connected">("idle")
+ const [isMicEnabled, setIsMicEnabled] = useState(true)
+ const [isCameraEnabled, setIsCameraEnabled] = useState(true)
+ const [isFrontCamera, setIsFrontCamera] = useState(true)
+ const [pendingOffer, setPendingOffer] = useState()
+ const remoteCandidates = useRef([])
+ const signalingChannel = useMemo(() => new SignalingChannel(localClientId), [localClientId])
+ const localStream = useRef()
+ const remoteStream = useRef(new MediaStream())
+ const mediaConstraints = useMemo(
+ () => ({
+ audio: true,
+ video: {
+ width: 1080,
+ height: 1920,
+ frameRate: 60,
+ },
+ }),
+ []
+ )
+ const iceServers = useMemo(
+ () => [
+ { urls: "stun:stun.l.google.com:19302" },
+ { urls: "stun:stun1.l.google.com:19302" },
+ { urls: "stun:stun2.l.google.com:19302" },
+ ],
+ []
+ )
+ const [peerConnection, setPeerConnection] = useState(
+ new RTCPeerConnection({
+ iceServers,
+ })
+ )
+ //* reinitialize --------------------------------------------------------------------------------
+ /**
+ * Reset everything to the initial state
+ *
+ * Should be called after the call ends
+ */
+ const reset = useCallback(() => {
+ peerConnection.close()
+ localStream.current?.getTracks().forEach((track) => {
+ track.stop()
+ })
+ remoteStream.current.getTracks().forEach((track) => {
+ track.stop()
+ })
+ localStream.current = new MediaStream()
+ remoteStream.current = new MediaStream()
+ setPeerConnection(
+ new RTCPeerConnection({
+ iceServers,
+ })
+ )
+ setCallState("idle")
+ setPendingOffer(undefined)
+ remoteCandidates.current = []
+ }, [iceServers, peerConnection])
+ //* set up local stream -------------------------------------------------------------------------
+ useEffect(() => {
+ ;(async () => {
+ const stream = await mediaDevices.getUserMedia(mediaConstraints)
+ stream?.getTracks().forEach((track) => {
+ peerConnection.addTrack(track, stream)
+ })
+ localStream.current = stream
+ })()
+ }, [mediaConstraints, peerConnection])
+ //* make call -----------------------------------------------------------------------------------
+ const makeCall = useCallback(
+ async (otherUserIdArg: string) => {
+ // connect to the remote client through the signaling server
+ signalingChannel?.connect(otherUserIdArg)
+ // create offer and send it to the other client
+ const offer = await peerConnection.createOffer({
+ offerToReceiveAudio: true,
+ offerToReceiveVideo: true,
+ })
+ await peerConnection.setLocalDescription(offer)
+ signalingChannel?.send({ type: "offer", payload: offer })
+ setCallState("outgoing")
+ },
+ [peerConnection, signalingChannel]
+ )
+ //* listen for incoming calls -------------------------------------------------------------------
+ useEffect(() => {
+ const offerEventListener = async (message: SignalingMessage) => {
+ if (message.type === "offer") {
+ setPendingOffer(message.payload)
+ onIncomingCall?.(signalingChannel.remoteClientId ?? "")
+ setCallState("incoming")
+ }
+ }
+ signalingChannel?.on("message", offerEventListener)
+ return () => {
+ signalingChannel?.off("message", offerEventListener)
+ }
+ }, [onIncomingCall, signalingChannel])
+ //* accept call ---------------------------------------------------------------------------------
+ const acceptCall = useCallback(async () => {
+ if (!pendingOffer) {
+ throw new Error("Attempted to accept call without pending offer")
+ }
+ // set remote description and send answer
+ await peerConnection.setRemoteDescription(new RTCSessionDescription(pendingOffer))
+ const answer = await peerConnection.createAnswer()
+ await peerConnection.setLocalDescription(answer)
+ signalingChannel?.send({ type: "answer", payload: answer })
+ // add remote candidates that arrived before the remote description was set
+ remoteCandidates.current.forEach((candidate) => {
+ peerConnection.addIceCandidate(candidate)
+ })
+ }, [peerConnection, pendingOffer, signalingChannel])
+ //* listen for answer to call -------------------------------------------------------------------
+ useEffect(() => {
+ const answerEventListener = async (message: SignalingMessage) => {
+ if (message.type === "answer") {
+ await peerConnection.setRemoteDescription(new RTCSessionDescription(message.payload))
+ }
+ }
+ signalingChannel?.on("message", answerEventListener)
+ return () => {
+ signalingChannel?.off("message", answerEventListener)
+ }
+ }, [peerConnection, signalingChannel])
+ //* listen for ICE candidates -------------------------------------------------------------------
+ useEffect(() => {
+ // send ice candidates to the other peer
+ const iceCandidateEventListener = (event: RTCIceCandidateEvent<"icecandidate">) => {
+ if (event.candidate) {
+ signalingChannel?.send({ type: "new-ice-candidate", payload: event.candidate })
+ }
+ }
+ peerConnection.addEventListener("icecandidate", iceCandidateEventListener)
+ // receive ice candidates from the other peer
+ const newCandidateEventListener = (message: SignalingMessage) => {
+ if (message.type === "new-ice-candidate") {
+ const iceCandidate = new RTCIceCandidate(message.payload)
+ // some candidates might arrive before the remote description is set
+ // (we'll add them after the remote description is set)
+ if (peerConnection.remoteDescription == null) {
+ remoteCandidates.current.push(iceCandidate)
+ } else {
+ peerConnection.addIceCandidate(iceCandidate)
+ }
+ }
+ }
+ signalingChannel.on("message", newCandidateEventListener)
+ return () => {
+ peerConnection.removeEventListener("icecandidate", iceCandidateEventListener)
+ signalingChannel?.off("message", newCandidateEventListener)
+ }
+ }, [localClientId, peerConnection, signalingChannel])
+ //* set up remote stream ------------------------------------------------------------------------
+ useEffect(() => {
+ const trackEventListener = (event: RTCTrackEvent<"track">) => {
+ if (event.track) remoteStream.current.addTrack(event.track)
+ }
+ peerConnection.addEventListener("track", trackEventListener)
+ return () => {
+ peerConnection.removeEventListener("track", trackEventListener)
+ }
+ }, [localClientId, peerConnection])
+ //* update call state ---------------------------------------------------------------------------
+ useEffect(() => {
+ const connectionStateChangeListener = () => {
+ switch (peerConnection.connectionState) {
+ case "connected":
+ setCallState("connected")
+ break
+ case "closed":
+ console.log(`${localClientId} closed connection`)
+ break
+
+ default:
+ break
+ }
+ }
+ peerConnection.addEventListener("connectionstatechange", connectionStateChangeListener)
+ return () => {
+ peerConnection.removeEventListener("connectionstatechange", connectionStateChangeListener)
+ }
+ }, [localClientId, peerConnection])
+ //* end call ------------------------------------------------------------------------------------
+ const endCall = useCallback(async () => {
+ // tell the remote client to disconnect
+ signalingChannel?.disconnect()
+ reset()
+ }, [reset, signalingChannel])
+ //* listen for remote disconnection -------------------------------------------------------------
+ useEffect(() => {
+ const disconnectEventListener = () => {
+ reset()
+ }
+ signalingChannel?.on("disconnected", disconnectEventListener)
+ return () => {
+ signalingChannel?.off("disconnected", disconnectEventListener)
+ }
+ }, [reset, signalingChannel])
+ //* controls ------------------------------------------------------------------------------------
+ const toggleMute = useCallback(() => {
+ setIsMicEnabled((prev) => !prev)
+ }, [])
+ const toggleCamera = useCallback(() => {
+ setIsCameraEnabled((prev) => !prev)
+ }, [])
+ const toggleCameraMode = useCallback(() => {
+ localStream.current?.getVideoTracks()[0]?._switchCamera()
+ setIsFrontCamera((prev) => !prev)
+ }, [])
+ useEffect(() => {
+ localStream.current?.getAudioTracks().forEach((track) => {
+ track.enabled = isMicEnabled
+ })
+ }, [isMicEnabled])
+ useEffect(() => {
+ localStream.current?.getVideoTracks().forEach((track) => {
+ track.enabled = isCameraEnabled
+ })
+ }, [isCameraEnabled])
+ //* debug logs ----------------------------------------------------------------------------------
+ useEffect(() => {
+ if (signalingChannel) {
+ signalingChannel.on("message", (message: SignalingMessage) => {
+ console.log(`currentUserId: ${localClientId}, received message`, message.type)
+ })
+ }
+ peerConnection.addEventListener("connectionstatechange", () => {
+ console.log(
+ `currentUserId: ${localClientId}, connection state: ${peerConnection.connectionState}`
+ )
+ })
+ }, [localClientId, peerConnection, signalingChannel])
+
+ return {
+ callState,
+ localStream,
+ remoteStream,
+ makeCall,
+ acceptCall,
+ endCall,
+ isMicEnabled,
+ isCameraEnabled,
+ isFrontCamera,
+ toggleMute,
+ toggleCamera,
+ toggleCameraMode,
+ }
+}
diff --git a/react-native-expo-app/client/tsconfig.json b/react-native-expo-app/client/tsconfig.json
new file mode 100644
index 0000000..956e0ec
--- /dev/null
+++ b/react-native-expo-app/client/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "extends": "expo/tsconfig.base",
+ "compilerOptions": {
+ "strict": true,
+ "baseUrl": ".",
+ "target": "ES6",
+ "lib": [
+ "es6"
+ ]
+ },
+}
\ No newline at end of file
diff --git a/react-native-expo-app/docs/incoming-call.png b/react-native-expo-app/docs/incoming-call.png
new file mode 100644
index 0000000..81e6fa6
Binary files /dev/null and b/react-native-expo-app/docs/incoming-call.png differ
diff --git a/react-native-expo-app/docs/join.png b/react-native-expo-app/docs/join.png
new file mode 100644
index 0000000..6cc57a0
Binary files /dev/null and b/react-native-expo-app/docs/join.png differ
diff --git a/react-native-expo-app/docs/on-call.png b/react-native-expo-app/docs/on-call.png
new file mode 100644
index 0000000..b699ec7
Binary files /dev/null and b/react-native-expo-app/docs/on-call.png differ
diff --git a/react-native-expo-app/docs/outgoing-call.png b/react-native-expo-app/docs/outgoing-call.png
new file mode 100644
index 0000000..a3f626e
Binary files /dev/null and b/react-native-expo-app/docs/outgoing-call.png differ
diff --git a/react-native-expo-app/server/.gitignore b/react-native-expo-app/server/.gitignore
new file mode 100644
index 0000000..dc930cb
--- /dev/null
+++ b/react-native-expo-app/server/.gitignore
@@ -0,0 +1,10 @@
+
+
+# node.js
+#
+node_modules/
+npm-debug.log
+yarn-error.log
+
+# compiled output
+dist/
diff --git a/react-native-expo-app/server/index.ts b/react-native-expo-app/server/index.ts
new file mode 100644
index 0000000..d7fb0c7
--- /dev/null
+++ b/react-native-expo-app/server/index.ts
@@ -0,0 +1,60 @@
+import { createServer } from 'node:http'
+import { Server } from 'socket.io'
+
+export type SignalingMessage = { type: string; payload: any }
+type FullSignalingMessage = {
+ remoteClientId: string
+ message: SignalingMessage
+}
+
+const server = createServer()
+const io = new Server(server)
+
+io.on('connection', socket => {
+ const { localClientId } = socket.handshake.query
+ console.log('client connected', localClientId)
+ if (!localClientId) {
+ socket.disconnect()
+ console.warn('client disconnected due to invalid query params')
+ return
+ }
+ // join a room with the client id so that other clients can find it
+ socket.join(localClientId as string)
+ socket.on('connectTo', (remoteClientId: string) => {
+ console.log(`${localClientId} wants to connect to ${remoteClientId}`)
+ // check if room exists
+ if (!io.sockets.adapter.rooms.has(remoteClientId)) {
+ console.warn('remote client not found while connecting')
+ return
+ }
+ // send event to remote client
+ io.to(remoteClientId).emit('connectTo', localClientId)
+ })
+ socket.on('disconnectFrom', (remoteClientId: string) => {
+ console.log(`${localClientId} wants to disconnect from ${remoteClientId}`)
+ // check if room exists
+ if (!io.sockets.adapter.rooms.has(remoteClientId)) {
+ console.warn('remote client not found while disconnecting')
+ return
+ }
+ // send event to remote client
+ io.to(remoteClientId).emit('disconnectFrom', localClientId)
+ })
+ socket.on('message', (message: FullSignalingMessage) => {
+ const remoteClientId = message.remoteClientId
+ console.log(
+ `message from ${localClientId} to ${remoteClientId} | type: ${message.message.type}`
+ )
+ // check if room exists
+ if (!io.sockets.adapter.rooms.has(remoteClientId)) {
+ console.warn('remote client not found while sending message')
+ return
+ }
+ // send message to remote client
+ io.to(remoteClientId).emit('message', message.message)
+ })
+})
+
+server.listen(3000, () => {
+ console.log('server running at http://localhost:3000')
+})
diff --git a/react-native-expo-app/server/package.json b/react-native-expo-app/server/package.json
new file mode 100644
index 0000000..c691074
--- /dev/null
+++ b/react-native-expo-app/server/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "react-native-webrtc-expo-example-server",
+ "version": "1.0.0",
+ "description": "",
+ "main": "index.js",
+ "author": {
+ "name": "Mohammed Handa",
+ "email": "mohamadhenda@gmail.com",
+ "url": "https://mohammedhanda.com"
+ },
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1",
+ "start": "tsc && node dist/index.js"
+ },
+ "dependencies": {
+ "socket.io": "^4.0.2"
+ },
+ "devDependencies": {
+ "@types/express": "^4.17.21",
+ "typescript": "^5.3.3"
+ },
+ "private": true
+}
diff --git a/react-native-expo-app/server/tsconfig.json b/react-native-expo-app/server/tsconfig.json
new file mode 100644
index 0000000..90438aa
--- /dev/null
+++ b/react-native-expo-app/server/tsconfig.json
@@ -0,0 +1,65 @@
+{
+ "compilerOptions": {
+ /* Visit https://aka.ms/tsconfig.json to read more about this file */
+ /* Basic Options */
+ // "incremental": true, /* Enable incremental compilation */
+ "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */
+ "module": "CommonJS", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
+ // "lib": [], /* Specify library files to be included in the compilation. */
+ // "allowJs": true, /* Allow javascript files to be compiled. */
+ // "checkJs": true, /* Report errors in .js files. */
+ // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */
+ // "declaration": true, /* Generates corresponding '.d.ts' file. */
+ // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
+ "sourceMap": true, /* Generates corresponding '.map' file. */
+ // "outFile": "./", /* Concatenate and emit output to single file. */
+ // "outDir": "./", /* Redirect output structure to the directory. */
+ "outDir": "dist", /* Redirect output structure to the directory. */
+ // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
+ // "composite": true, /* Enable project compilation */
+ // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
+ // "removeComments": true, /* Do not emit comments to output. */
+ // "noEmit": true, /* Do not emit outputs. */
+ // "importHelpers": true, /* Import emit helpers from 'tslib'. */
+ // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
+ // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
+ /* Strict Type-Checking Options */
+ "strict": true, /* Enable all strict type-checking options. */
+ // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
+ // "strictNullChecks": true, /* Enable strict null checks. */
+ // "strictFunctionTypes": true, /* Enable strict checking of function types. */
+ // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
+ // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
+ // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
+ // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
+ /* Additional Checks */
+ // "noUnusedLocals": true, /* Report errors on unused locals. */
+ // "noUnusedParameters": true, /* Report errors on unused parameters. */
+ // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
+ // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
+ // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
+ // "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */
+ /* Module Resolution Options */
+ "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
+ // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
+ // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
+ // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
+ // "typeRoots": [], /* List of folders to include type definitions from. */
+ // "types": [], /* Type declaration files to be included in compilation. */
+ // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
+ "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
+ // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
+ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
+ /* Source Map Options */
+ // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
+ // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
+ // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
+ // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
+ /* Experimental Options */
+ // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
+ // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
+ /* Advanced Options */
+ "skipLibCheck": true, /* Skip type checking of declaration files. */
+ "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
+ }
+}
\ No newline at end of file