Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 186 additions & 0 deletions examples/mobile-client/voip-call/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
# iOS VoIP Push Notifications — setup

This document lists everything the **app** has to change to receive VoIP push
notifications and surface them as CallKit calls. Most of the heavy lifting lives
in the `@fishjam-cloud/react-native-webrtc` pod; the app only has to (1) register
for VoIP pushes and (2) declare the right capabilities.

## How the flow works

```
APNs VoIP push
→ iOS wakes the app (even if killed) UIBackgroundModes: voip
→ PKPushRegistry delegate VoipManager (pod)
→ reports the call to CallKit CallKitManager (pod) → system call UI
→ fires onIncomingPush block WebRTCModule+PushKit (pod)
→ sendEventWithName("callKitActionPerformed", {incoming}) → JS
→ user answers / ends on the CallKit UI
→ CallKitManager callbacks WebRTCModule+CallKit (pod)
→ JS events: answer / ended / muted / held
```

What the **pod already does for you** (no app code needed):

- `PKPushRegistry` + `PKPushRegistryDelegate` — `VoipManager.m`.
- Reporting the incoming call to CallKit on push receipt —
`VoipManager.m` → `[[CallKitManager shared] reportIncomingCallWithDisplayName:isVideo:]`.
- Bridging native → JS events. `WebRTCModule` subclasses `RCTEventEmitter`; when
JS adds its first listener, `startObserving` runs and calls
`startObservingPushKit` (`WebRTCModule+PushKit.m`), which wires the
`registered` (VoIP token) and `incoming` (push payload) events. See
[`RCTEventEmitter`](https://reactnative.dev/docs/legacy/native-modules-ios#sending-events-to-javascript).

What the **app must do** is below.

---

## 1. AppDelegate — register for VoIP pushes

The pod never registers `PKPushRegistry` on its own (a push can wake the app
before any JS is running, so registration must happen at launch). Add the call
in `application(_:didFinishLaunchingWithOptions:)`.

`ios/voipcall/AppDelegate.swift`:

```swift
public override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
let delegate = ReactNativeDelegate()
let factory = ExpoReactNativeFactory(delegate: delegate)
delegate.dependencyProvider = RCTAppDependencyProvider()

reactNativeDelegate = delegate
reactNativeFactory = factory
bindReactNativeFactory(factory)

// 👇 Register for VoIP pushes as early as possible.
VoipManager.registerForVoIPPushes()

#if os(iOS) || os(tvOS)
window = UIWindow(frame: UIScreen.main.bounds)
factory.startReactNative(
withModuleName: "main",
in: window,
launchOptions: launchOptions)
#endif

return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
```

> Registration triggers the APNs handshake. Once the VoIP token is issued, the
> pod forwards it to JS as the `registered` event. Because the token may arrive
> before your component mounts, prefer reading it on demand rather than relying
> only on catching the event.

## 2. Bridging header — expose the pod's class to Swift

`VoipManager` is Objective-C; Swift needs it imported through the bridging
header. `ios/voipcall/voipcall-Bridging-Header.h`:

~~~objc
//
// Use this file to import your target's public headers that you would like to expose to Swift.
//
#import "VoipManager.h"
~~~

> If `"VoipManager.h"` doesn't resolve, use the pod-qualified form instead:
> `#import <FishjamReactNativeWebrtc/VoipManager.h>`.

## 3. Entitlements — Push Notifications

`ios/voipcall/voipcall.entitlements` must contain the `aps-environment` key
(add the **Push Notifications** capability in Xcode → Signing & Capabilities,
which writes this for you). Already present in this app:

~~~xml
<key>aps-environment</key>
<string>development</string> <!-- use "production" for TestFlight / App Store -->
~~~

## 4. Info.plist — background modes & permissions

`ios/voipcall/Info.plist` needs (all already present in this app):

```xml
<key>UIBackgroundModes</key>
<array>
<string>voip</string> <!-- required: lets a VoIP push wake the app -->
<string>processing</string>
<!-- add <string>audio</string> if you keep an audio session alive during the call -->
</array>

<key>NSMicrophoneUsageDescription</key>
<string>Allow $(PRODUCT_NAME) to access your microphone.</string>
```

In Xcode → Signing & Capabilities this corresponds to **Background Modes →
Voice over IP** and **Push Notifications**.

---

## 5. Server / APNs side

- Create a **VoIP Services certificate** (or use an APNs Auth Key) in the Apple
Developer portal. VoIP pushes use a dedicated token, not the regular APNs one.
See Apple's [PushKit](https://developer.apple.com/documentation/pushkit) and
[Reporting incoming calls](https://developer.apple.com/documentation/callkit/cxprovider)
docs.
- Send the push with header `apns-push-type: voip` and `apns-topic:
<bundle-id>.voip`, to the VoIP token your app reported via the `registered`
event.
- Payload fields the pod reads (`VoipManager.m`):

```json
{ "displayName": "Alice", "isVideo": false }
```

`username` is accepted as a fallback for `displayName`. The entire payload is
also forwarded to JS as the `incoming` event, so you can include routing data
(e.g. `roomId`).

> iOS 13+ requires that **every** received VoIP push reports an incoming call to
> CallKit, immediately, inside the push handler — otherwise the system
> terminates the app. The pod already does this for you.

---

## 6. JS side — subscribe to events

```ts
import { useCallKitEvent } from "./src/callkit";

useCallKitEvent("registered", (token) => console.log("VoIP token:", token));
useCallKitEvent("incoming", (payload) =>
console.log("incoming push:", payload),
);
useCallKitEvent("answer", (payload) => console.log("answer:", payload));
useCallKitEvent("ended", (payload) => console.log("ended:", payload));
```

---

## 7. Expo caveat

This `ios/` directory is generated by `expo prebuild`. Direct edits to
`AppDelegate.swift`, `Info.plist`, the entitlements, and the bridging header
**will be overwritten by `expo prebuild --clean`**. For a durable setup, move
these changes into an [Expo config plugin](https://docs.expo.dev/config-plugins/introduction/)
(`withAppDelegate`, `withInfoPlist`, `withEntitlementsPlist`) so they are
re-applied on every prebuild.

---

## 8. Testing

1. Run on a **real device** (VoIP pushes don't work on the Simulator).
2. Launch the app and confirm `VoIP token:` is logged — that token is your push
destination.
3. Send a VoIP push to that token (e.g. via a script using your VoIP key, or a
tool like Pusher/Knuff). The CallKit incoming-call UI should appear even if
the app is backgrounded or killed.
4. Tap **Answer** / **End** on the system UI and confirm the `answer` / `ended`
events log in Metro.
1 change: 1 addition & 0 deletions examples/mobile-client/voip-call/app/.env.example
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
EXPO_PUBLIC_FISHJAM_ID=
EXPO_PUBLIC_SANDBOX_API_URL=
EXPO_PUBLIC_VOIP_SERVER_URL=http://localhost:4400
147 changes: 114 additions & 33 deletions examples/mobile-client/voip-call/app/App.tsx
Original file line number Diff line number Diff line change
@@ -1,53 +1,134 @@
import { FishjamProvider } from '@fishjam-cloud/react-native-client';
import {
FishjamProvider,
useSandbox,
} from '@fishjam-cloud/react-native-client';
import { StatusBar } from 'expo-status-bar';
import { useCallback } from 'react';
import { StyleSheet, Text } from 'react-native';
import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';
import { useCallback, useEffect } from 'react';
import { ActivityIndicator, StyleSheet, View } from 'react-native';
import { SafeAreaProvider } from 'react-native-safe-area-context';

import { useCallKitEvent } from './src/callkit';
import type { PropsWithChildren } from 'react';
import { DirectoryScreen } from './src/screens/DirectoryScreen';
import { InCallScreen } from './src/screens/InCallScreen';
import { LoginScreen } from './src/screens/LoginScreen';
import { OutgoingCallScreen } from './src/screens/OutgoingCallScreen';
import { BrandColors } from './src/theme/colors';
import { UserProvider, useUser } from './src/user';
import { VoipProvider, useVoip } from './src/voip';

const EventLog = () => {
// TODO: Implement on the native side.
useCallKitEvent(
'incoming',
useCallback((payload) => {}, []),
);
useCallKitEvent(
'answer',
useCallback((payload) => {}, []),
);
useCallKitEvent(
'end',
useCallback((payload) => {}, []),
const SERVER_URL =
process.env.EXPO_PUBLIC_VOIP_SERVER_URL ?? 'http://localhost:4400';
const SANDBOX_API_URL = process.env.EXPO_PUBLIC_SANDBOX_API_URL ?? '';

function VoipWrapper({ children }: PropsWithChildren) {
const { username } = useUser();
const { getSandboxPeerToken } = useSandbox({
sandboxApiUrl: SANDBOX_API_URL,
});

const getPeerToken = useCallback(
(roomName: string) =>
getSandboxPeerToken(roomName, username ?? 'unknown', 'conference'),
[getSandboxPeerToken, username],
);
useCallKitEvent(
'registered',
useCallback((payload) => {}, []),

const requestCall = useCallback(
async ({
to,
roomName,
isVideo,
}: {
to: string;
roomName: string;
isVideo: boolean;
}) => {
const res = await fetch(`${SERVER_URL}/call`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ from: username, to, roomName, isVideo }),
});
if (!res.ok) throw new Error('Failed to initiate call');
},
[username],
);

return (
<SafeAreaView style={styles.container}>
<StatusBar style="auto" />
<Text style={styles.title}>CallKit events</Text>
</SafeAreaView>
<VoipProvider
getPeerToken={getPeerToken}
requestCall={requestCall}
isVideo={true}>
<DeviceRegistration />
{children}
</VoipProvider>
);
};
}

function DeviceRegistration() {
const { username } = useUser();
const { voipToken } = useVoip();

useEffect(() => {
if (!username || !voipToken) return;
fetch(`${SERVER_URL}/register`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username, voipToken }),
}).catch(() => {});
}, [username, voipToken]);

return null;
}

function AppScreens() {
const { username, isLoading } = useUser();
const { status } = useVoip();

if (isLoading) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color={BrandColors.darkBlue80} />
</View>
);
}

if (!username) {
return <LoginScreen />;
}

if (status === 'connecting') {
return <OutgoingCallScreen />;
}

if (status === 'active') {
return <InCallScreen />;
}

return <DirectoryScreen />;
}

const App = () => (
<FishjamProvider fishjamId={process.env.EXPO_PUBLIC_FISHJAM_ID ?? ''}>
<SafeAreaProvider>
<EventLog />
<UserProvider>
<VoipWrapper>
<View style={styles.root}>
<StatusBar style="auto" />
<AppScreens />
</View>
</VoipWrapper>
</UserProvider>
</SafeAreaProvider>
</FishjamProvider>
);

export default App;

const styles = StyleSheet.create({
container: { flex: 1, padding: 24, gap: 16 },
title: { fontSize: 24, fontWeight: '600' },
log: { flex: 1, borderTopColor: '#EAECF0', borderTopWidth: 1 },
logContent: { paddingTop: 12, gap: 6 },
muted: { color: 'gray' },
line: { fontFamily: 'Courier', fontSize: 14 },
root: { flex: 1, backgroundColor: BrandColors.seaBlue20 },
center: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: BrandColors.seaBlue20,
},
});
4 changes: 3 additions & 1 deletion examples/mobile-client/voip-call/app/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@
"bundleIdentifier": "io.fishjam.example.voipcall",
"infoPlist": {
"NSMicrophoneUsageDescription": "Allow $(PRODUCT_NAME) to access your microphone.",
"NSCameraUsageDescription": "Allow $(PRODUCT_NAME) to access your camera for video calls.",
"ITSAppUsesNonExemptEncryption": false
}
},
"appleTeamId": "J5FM626PE2"
},
"android": {
"package": "io.fishjam.example.voipcall",
Expand Down
1 change: 1 addition & 0 deletions examples/mobile-client/voip-call/app/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { registerRootComponent } from 'expo';
import 'react-native-get-random-values';

import App from './App';

Expand Down
3 changes: 3 additions & 0 deletions examples/mobile-client/voip-call/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@
},
"dependencies": {
"@fishjam-cloud/react-native-client": "workspace:*",
"@react-native-async-storage/async-storage": "^3.1.1",
"expo": "~54.0.30",
"expo-status-bar": "~3.0.9",
"react": "19.1.0",
"react-native": "0.81.5",
"react-native-get-random-values": "^2.0.0",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0"
},
"devDependencies": {
Expand Down
2 changes: 0 additions & 2 deletions examples/mobile-client/voip-call/app/src/callkit/index.ts

This file was deleted.

Loading