diff --git a/examples/mobile-client/voip-call/README.md b/examples/mobile-client/voip-call/README.md
deleted file mode 100644
index bd4c5ac21..000000000
--- a/examples/mobile-client/voip-call/README.md
+++ /dev/null
@@ -1,62 +0,0 @@
-# VoIP call example
-
-A two-user calling app: an Expo React Native client (`app/`) and a small Deno
-push + signaling server (`server/`). Calls ring through the native call UI
-(CallKit on iOS, Telecom on Android) even when the app is backgrounded or
-killed, and connect through a Fishjam room.
-
-How it all works (native configuration, `VoIPProvider`, and the JS call flow)
-is documented in the
-[VoIP calls guide](https://documentation.fishjam.io/docs/how-to/client/voip-calls);
-this README only covers running the example.
-
-## Running the example
-
-VoIP pushes can only be delivered through your own Apple and Firebase accounts,
-so the first step is minting push credentials. Configure APNs to call iOS
-devices, FCM to call Android devices, or both to call between them.
-
-1. **Server credentials.** Create the APNs VoIP certificate (`apns.pem`) and/or
- the FCM service account key (`fcm-credentials.json`) as described in
- [`server/README.md`](./server/README.md), then start the server:
-
- ```bash
- cd server
- deno task start # listens on :4400
- ```
-
-2. **`google-services.json` (Android only).** The file is gitignored, so you
- have to fetch your own. In the
- [Firebase console](https://console.firebase.google.com/), open the same
- project the server's `fcm-credentials.json` came from → _Project settings_ →
- _Your apps_ → add an **Android** app if none exists → download
- `google-services.json` → save it at `app/google-services.json`.
-
- Its `package_name` must match `android.package` in `app.json` exactly
- (`io.fishjam.example.voipcall`), or the build fails with
- `No matching client found for package name`. Keep the file at the app root:
- `expo prebuild` copies it into `android/` for you, and a copy placed inside
- `android/` by hand doesn't survive `expo prebuild --clean`.
-
-3. **App environment.** Copy `app/.env.example` to `app/.env` and fill it in:
-
- - `EXPO_PUBLIC_FISHJAM_ID`: your Fishjam app id.
- - `EXPO_PUBLIC_SANDBOX_API_URL`: the Sandbox API url from your Fishjam
- dashboard, used to mint peer tokens.
- - `EXPO_PUBLIC_VOIP_SERVER_URL`: where the devices can reach the server
- above. Not `localhost` when running on a physical phone; use your
- machine's LAN address.
-
-4. **Run on real devices.** VoIP pushes never reach the iOS Simulator, and FCM
- needs Google Play services. Install dependencies from the repo root
- (`yarn`), then:
-
- ```bash
- cd app
- yarn ios # or: yarn android
- ```
-
-To test it, register two users on two devices and call between them. The
-[guide's checklist](https://documentation.fishjam.io/docs/how-to/client/voip-calls#11-test-it)
-lists the paths worth checking: ringing with the app killed, Recents redial,
-and Hold & Accept.
diff --git a/examples/mobile-client/voip-call/app/.env.example b/examples/mobile-client/voip-call/app/.env.example
deleted file mode 100644
index 6b4e7bee2..000000000
--- a/examples/mobile-client/voip-call/app/.env.example
+++ /dev/null
@@ -1,3 +0,0 @@
-EXPO_PUBLIC_FISHJAM_ID=
-EXPO_PUBLIC_SANDBOX_API_URL=
-EXPO_PUBLIC_VOIP_SERVER_URL=http://localhost:4400
diff --git a/examples/mobile-client/voip-call/app/.eslintrc.js b/examples/mobile-client/voip-call/app/.eslintrc.js
deleted file mode 100644
index 61c762552..000000000
--- a/examples/mobile-client/voip-call/app/.eslintrc.js
+++ /dev/null
@@ -1,16 +0,0 @@
-module.exports = {
- root: true,
- extends: ['expo'],
- ignorePatterns: [
- 'dist/*',
- 'node_modules/*',
- 'ios/*',
- 'android/*',
- 'server/*',
- '.eslintrc.js',
- 'prettier.config.js',
- ],
- rules: {
- 'import/no-unresolved': 'off',
- },
-};
diff --git a/examples/mobile-client/voip-call/app/.gitignore b/examples/mobile-client/voip-call/app/.gitignore
deleted file mode 100644
index 935c0172d..000000000
--- a/examples/mobile-client/voip-call/app/.gitignore
+++ /dev/null
@@ -1,24 +0,0 @@
-# dependencies
-node_modules/
-
-# Expo
-.expo/
-dist/
-expo-env.d.ts
-
-# debug
-npm-debug.*
-yarn-debug.*
-yarn-error.*
-
-# macOS
-.DS_Store
-
-# typescript
-*.tsbuildinfo
-
-# generated native folders
-/ios
-/android
-
-google-services.json
\ No newline at end of file
diff --git a/examples/mobile-client/voip-call/app/App.tsx b/examples/mobile-client/voip-call/app/App.tsx
deleted file mode 100644
index 2eec33bb9..000000000
--- a/examples/mobile-client/voip-call/app/App.tsx
+++ /dev/null
@@ -1,107 +0,0 @@
-import {
- FishjamProvider,
- useVoIP,
- VoIPProvider,
-} from '@fishjam-cloud/react-native-client';
-import { StatusBar } from 'expo-status-bar';
-import { useCallback, useEffect, useRef } from 'react';
-import { ActivityIndicator, StyleSheet, View } from 'react-native';
-import { SafeAreaProvider } from 'react-native-safe-area-context';
-
-import type { VoIPIncomingPayload } from '@fishjam-cloud/react-native-client';
-import {
- useCallSignaling,
- type SendSignalRef,
-} from './src/hooks/useCallSignaling';
-import { useDeviceRegistration } from './src/hooks/useDeviceRegistration';
-import { useRecentsRedial } from './src/hooks/useRecentsRedial';
-import { useRequestPermissions } from './src/hooks/useRequestPermissions';
-import { CallScreen } from './src/screens/CallScreen';
-import { LoginScreen } from './src/screens/LoginScreen';
-import { UsersScreen } from './src/screens/UsersScreen';
-import { BrandColors } from './src/theme/colors';
-import { useUser } from './src/user/UserContext';
-import { UserProvider } from './src/user/UserProvider';
-
-function Main({ sendSignalRef }: { sendSignalRef: SendSignalRef }) {
- const { username, isLoading } = useUser();
- const { callStatus, lastEndedReason } = useVoIP();
-
- useRequestPermissions();
- useCallSignaling(sendSignalRef);
- useDeviceRegistration();
- useRecentsRedial();
-
- useEffect(() => {
- if (!lastEndedReason) return;
- console.log(
- `On user: ${username}, [VoIP] Call ended — reason: ${lastEndedReason}`,
- );
- }, [lastEndedReason, username]);
-
- // Checked before the session gates below: a call can exist while the user session
- // is still loading (answering a VoIP push right after a cold start) or missing
- // (still registered for pushes after a logout), and it must connect regardless.
- if (callStatus === 'connecting' || callStatus === 'active') {
- return ;
- }
-
- if (isLoading) {
- return (
-
-
-
- );
- }
-
- if (!username) {
- return ;
- }
-
- return ;
-}
-
-function VoIPApp() {
- const sendSignalRef: SendSignalRef = useRef(undefined);
-
- // A call we declined while another one was ringing never reaches the callee's
- // signaling flow, so tell the caller ourselves.
- const onWaitingCallDeclined = useCallback((payload: VoIPIncomingPayload) => {
- sendSignalRef.current?.({
- type: 'call-rejected',
- to: payload.handle,
- roomName: payload.roomName,
- });
- }, []);
-
- return (
-
-
-
-
-
-
-
-
- );
-}
-
-const App = () => (
-
-
-
-
-
-);
-
-export default App;
-
-const styles = StyleSheet.create({
- root: { flex: 1, backgroundColor: BrandColors.seaBlue20 },
- center: {
- flex: 1,
- alignItems: 'center',
- justifyContent: 'center',
- backgroundColor: BrandColors.seaBlue20,
- },
-});
diff --git a/examples/mobile-client/voip-call/app/README.md b/examples/mobile-client/voip-call/app/README.md
deleted file mode 100644
index 89382b031..000000000
--- a/examples/mobile-client/voip-call/app/README.md
+++ /dev/null
@@ -1,26 +0,0 @@
-# voip-call-app
-
-The Expo React Native client of the VoIP call example. Setup and run
-instructions (push credentials, `google-services.json`, `.env`) are in the
-[example README](../README.md) one level up; how the integration works is in
-the [VoIP calls guide](https://documentation.fishjam.io/docs/how-to/client/voip-calls).
-
-## Run
-
-With the [server](../server/README.md) running and `.env` filled in:
-
-```bash
-yarn ios # or: yarn android
-```
-
-Real devices only: VoIP pushes never reach the iOS Simulator, and FCM needs
-Google Play services.
-
-## iOS native registration
-
-On Expo, the app depends on
-[`@fishjam-cloud/ios-expo-voip`](https://www.npmjs.com/package/@fishjam-cloud/ios-expo-voip),
-which registers the native subscriptions the SDK needs at launch: PushKit and
-the Recents redial intent forwarding. Without the PushKit registration the app
-never receives `didReceiveIncomingPush`, so VoIP pushes don't work at all, and
-tap-to-redial silently does nothing.
diff --git a/examples/mobile-client/voip-call/app/app.json b/examples/mobile-client/voip-call/app/app.json
deleted file mode 100644
index 2f1e7ceb2..000000000
--- a/examples/mobile-client/voip-call/app/app.json
+++ /dev/null
@@ -1,69 +0,0 @@
-{
- "expo": {
- "name": "voip-call",
- "slug": "voip-call",
- "version": "1.0.0",
- "orientation": "portrait",
- "icon": "./assets/images/icon.png",
- "scheme": "voipcall",
- "userInterfaceStyle": "automatic",
- "newArchEnabled": true,
- "ios": {
- "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
- },
- "entitlements": {
- "aps-environment": "development"
- },
- "appleTeamId": "J5FM626PE2"
- },
- "android": {
- "package": "io.fishjam.example.voipcall",
- "googleServicesFile": "./google-services.json",
- "adaptiveIcon": {
- "foregroundImage": "./assets/images/adaptive-icon.png",
- "monochromeImage": "./assets/images/adaptive-icon.png",
- "backgroundColor": "#F1FAFE"
- },
- "edgeToEdgeEnabled": true,
- "permissions": [
- "android.permission.CAMERA",
- "android.permission.RECORD_AUDIO",
- "android.permission.MODIFY_AUDIO_SETTINGS",
- "android.permission.ACCESS_NETWORK_STATE",
- "android.permission.ACCESS_WIFI_STATE"
- ]
- },
- "web": {
- "favicon": "./assets/images/favicon.png"
- },
- "plugins": [
- [
- "@fishjam-cloud/react-native-client",
- {
- "android": {
- "enableVoIP": true
- },
- "ios": {
- "enableVoIPBackgroundMode": true
- },
- "voip": {
- "incomingCallTimeout": 20,
- "outgoingCallTimeout": 20
- }
- }
- ],
- [
- "expo-splash-screen",
- {
- "image": "./assets/images/splash.png",
- "resizeMode": "contain",
- "backgroundColor": "#F1FAFE"
- }
- ]
- ]
- }
-}
diff --git a/examples/mobile-client/voip-call/app/assets/images/adaptive-icon.png b/examples/mobile-client/voip-call/app/assets/images/adaptive-icon.png
deleted file mode 100644
index 471f7c36f..000000000
Binary files a/examples/mobile-client/voip-call/app/assets/images/adaptive-icon.png and /dev/null differ
diff --git a/examples/mobile-client/voip-call/app/assets/images/favicon.png b/examples/mobile-client/voip-call/app/assets/images/favicon.png
deleted file mode 100644
index f8d9dd101..000000000
Binary files a/examples/mobile-client/voip-call/app/assets/images/favicon.png and /dev/null differ
diff --git a/examples/mobile-client/voip-call/app/assets/images/icon.png b/examples/mobile-client/voip-call/app/assets/images/icon.png
deleted file mode 100644
index 348683ffa..000000000
Binary files a/examples/mobile-client/voip-call/app/assets/images/icon.png and /dev/null differ
diff --git a/examples/mobile-client/voip-call/app/assets/images/splash.png b/examples/mobile-client/voip-call/app/assets/images/splash.png
deleted file mode 100644
index 63aec3ce0..000000000
Binary files a/examples/mobile-client/voip-call/app/assets/images/splash.png and /dev/null differ
diff --git a/examples/mobile-client/voip-call/app/babel.config.js b/examples/mobile-client/voip-call/app/babel.config.js
deleted file mode 100644
index 9d89e1311..000000000
--- a/examples/mobile-client/voip-call/app/babel.config.js
+++ /dev/null
@@ -1,6 +0,0 @@
-module.exports = function (api) {
- api.cache(true);
- return {
- presets: ['babel-preset-expo'],
- };
-};
diff --git a/examples/mobile-client/voip-call/app/index.js b/examples/mobile-client/voip-call/app/index.js
deleted file mode 100644
index 5212d31d3..000000000
--- a/examples/mobile-client/voip-call/app/index.js
+++ /dev/null
@@ -1,6 +0,0 @@
-import { registerRootComponent } from 'expo';
-import 'react-native-get-random-values';
-
-import App from './App';
-
-registerRootComponent(App);
diff --git a/examples/mobile-client/voip-call/app/package.json b/examples/mobile-client/voip-call/app/package.json
deleted file mode 100644
index e6c3ecb9f..000000000
--- a/examples/mobile-client/voip-call/app/package.json
+++ /dev/null
@@ -1,30 +0,0 @@
-{
- "name": "voip-call",
- "main": "index.js",
- "version": "0.27.0",
- "private": true,
- "scripts": {
- "start": "expo start",
- "android": "expo run:android",
- "ios": "expo run:ios",
- "lint": "eslint ."
- },
- "dependencies": {
- "@fishjam-cloud/ios-expo-voip": "workspace:*",
- "@fishjam-cloud/react-native-client": "workspace:*",
- "@react-native-async-storage/async-storage": "^3.1.1",
- "expo": "~54.0.30",
- "expo-splash-screen": "~31.0.13",
- "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": {
- "@types/react": "~19.1.0",
- "eslint-config-expo": "~8.0.1",
- "typescript": "~5.9.2"
- }
-}
diff --git a/examples/mobile-client/voip-call/app/prettier.config.js b/examples/mobile-client/voip-call/app/prettier.config.js
deleted file mode 100644
index 1b0a5c68e..000000000
--- a/examples/mobile-client/voip-call/app/prettier.config.js
+++ /dev/null
@@ -1,8 +0,0 @@
-module.exports = {
- bracketSameLine: true,
- quoteProps: 'consistent',
- singleQuote: true,
- tabWidth: 2,
- trailingComma: 'all',
- useTabs: false,
-};
diff --git a/examples/mobile-client/voip-call/app/src/components/Avatar.tsx b/examples/mobile-client/voip-call/app/src/components/Avatar.tsx
deleted file mode 100644
index 0119e05fd..000000000
--- a/examples/mobile-client/voip-call/app/src/components/Avatar.tsx
+++ /dev/null
@@ -1,62 +0,0 @@
-import { useEffect, useState } from 'react';
-import { Image, StyleSheet, Text, View } from 'react-native';
-
-import { BrandColors, TextColors } from '../theme/colors';
-
-type AvatarProps = {
- name: string;
- /** Server-assigned avatar image; falls back to initials when null/absent or on error. */
- avatarUrl?: string | null;
- size?: number;
- speaking?: boolean;
-};
-
-export function Avatar({
- name,
- avatarUrl,
- size = 96,
- speaking = false,
-}: AvatarProps) {
- const initial = name.trim()[0]?.toUpperCase() ?? '?';
- const [failed, setFailed] = useState(false);
- // Reset the error state if the URL changes (e.g. list refresh).
- useEffect(() => setFailed(false), [avatarUrl]);
- const showImage = Boolean(avatarUrl) && !failed;
- return (
-
- {/* Initials sit underneath as the fallback while the image loads or if it fails. */}
- {initial}
- {showImage && (
- setFailed(true)}
- accessibilityIgnoresInvertColors
- />
- )}
-
- );
-}
-
-const styles = StyleSheet.create({
- avatar: {
- backgroundColor: BrandColors.darkBlue60,
- alignItems: 'center',
- justifyContent: 'center',
- borderColor: BrandColors.seaBlue80,
- overflow: 'hidden',
- },
- text: {
- color: TextColors.white,
- fontWeight: '700',
- },
-});
diff --git a/examples/mobile-client/voip-call/app/src/components/InCallButton.tsx b/examples/mobile-client/voip-call/app/src/components/InCallButton.tsx
deleted file mode 100644
index 6035a6143..000000000
--- a/examples/mobile-client/voip-call/app/src/components/InCallButton.tsx
+++ /dev/null
@@ -1,70 +0,0 @@
-import { MaterialCommunityIcons } from '@expo/vector-icons';
-import {
- type GestureResponderEvent,
- StyleSheet,
- TouchableOpacity,
-} from 'react-native';
-
-import { AdditionalColors, BrandColors } from '../theme/colors';
-
-type InCallButtonType = 'primary' | 'disconnect';
-
-type InCallButtonProps = {
- type?: InCallButtonType;
- active?: boolean;
- onPress: (event: GestureResponderEvent) => void;
- iconName: keyof typeof MaterialCommunityIcons.glyphMap;
- accessibilityLabel?: string;
- disabled?: boolean;
-};
-
-export function InCallButton({
- type = 'primary',
- active = false,
- onPress,
- iconName,
- accessibilityLabel,
- disabled = false,
-}: InCallButtonProps) {
- const isDisconnect = type === 'disconnect';
- const filled = isDisconnect || active;
-
- const backgroundColor = isDisconnect
- ? AdditionalColors.red80
- : active
- ? BrandColors.darkBlue100
- : AdditionalColors.white;
-
- const iconColor = filled ? AdditionalColors.white : BrandColors.darkBlue100;
-
- return (
-
-
-
- );
-}
-
-const styles = StyleSheet.create({
- button: {
- width: 52,
- height: 52,
- borderRadius: 26,
- justifyContent: 'center',
- alignItems: 'center',
- },
- outline: {
- borderWidth: 1,
- borderColor: BrandColors.darkBlue80,
- },
- disabled: { opacity: 0.45 },
-});
diff --git a/examples/mobile-client/voip-call/app/src/components/VideoCallView.tsx b/examples/mobile-client/voip-call/app/src/components/VideoCallView.tsx
deleted file mode 100644
index bb48d77d3..000000000
--- a/examples/mobile-client/voip-call/app/src/components/VideoCallView.tsx
+++ /dev/null
@@ -1,99 +0,0 @@
-import {
- RTCView,
- type Track,
- useCamera,
- usePeers,
-} from '@fishjam-cloud/react-native-client';
-import { StyleSheet, View } from 'react-native';
-import { useSafeAreaInsets } from 'react-native-safe-area-context';
-
-import { BrandColors } from '../theme/colors';
-import { Avatar } from './Avatar';
-
-function streamOf(track: Track | null | undefined) {
- return track?.stream && !track?.metadata?.paused ? track.stream : null;
-}
-
-type VideoCallViewProps = {
- remoteName: string;
- remoteAvatarUrl?: string | null;
- localName: string;
- localAvatarUrl?: string | null;
-};
-
-export function VideoCallView({
- remoteName,
- remoteAvatarUrl,
- localName,
- localAvatarUrl,
-}: VideoCallViewProps) {
- const insets = useSafeAreaInsets();
- const { remotePeers } = usePeers();
- const { cameraStream } = useCamera();
-
- const primaryRemote = remotePeers[0];
- const remoteStream = streamOf(primaryRemote?.cameraTrack);
- const localStream = cameraStream;
-
- return (
-
- {remoteStream ? (
-
- ) : (
-
-
-
- )}
-
-
- {localStream ? (
-
- ) : (
-
-
-
- )}
-
-
- );
-}
-
-const styles = StyleSheet.create({
- container: { flex: 1, backgroundColor: BrandColors.darkBlue100 },
- remoteVideo: { flex: 1 },
- remoteNoVideo: {
- flex: 1,
- alignItems: 'center',
- justifyContent: 'center',
- backgroundColor: BrandColors.darkBlue60,
- },
- pip: {
- position: 'absolute',
- right: 16,
- width: 108,
- height: 156,
- // Square corners on purpose: RTCView is a SurfaceView on Android and cannot
- // be clipped by borderRadius/overflow, so a rounded PiP overshoots there.
- backgroundColor: BrandColors.seaBlue60,
- borderWidth: 1,
- borderColor: 'rgba(255, 255, 255, 0.6)',
- shadowColor: '#000',
- shadowOpacity: 0.25,
- shadowRadius: 6,
- shadowOffset: { width: 0, height: 2 },
- elevation: 6,
- },
- pipVideo: { flex: 1 },
- pipNoVideo: { flex: 1, alignItems: 'center', justifyContent: 'center' },
-});
diff --git a/examples/mobile-client/voip-call/app/src/components/index.ts b/examples/mobile-client/voip-call/app/src/components/index.ts
deleted file mode 100644
index d7e1cba09..000000000
--- a/examples/mobile-client/voip-call/app/src/components/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export { Avatar } from './Avatar';
-export { InCallButton } from './InCallButton';
-export { VideoCallView } from './VideoCallView';
diff --git a/examples/mobile-client/voip-call/app/src/hooks/useCallRoom.ts b/examples/mobile-client/voip-call/app/src/hooks/useCallRoom.ts
deleted file mode 100644
index 47f869713..000000000
--- a/examples/mobile-client/voip-call/app/src/hooks/useCallRoom.ts
+++ /dev/null
@@ -1,89 +0,0 @@
-import {
- useCamera,
- useConnection,
- useMicrophone,
- useSandbox,
- useVoIP,
-} from '@fishjam-cloud/react-native-client';
-import { useEffect, useState } from 'react';
-
-import { useUser } from '../user/UserContext';
-import { IS_VIDEO_CALL } from './usePlaceCall';
-
-const SANDBOX_API_URL = process.env.EXPO_PUBLIC_SANDBOX_API_URL ?? '';
-
-// Serializes joins and leaves. React runs an effect's cleanup before the next effect
-// body, but `leaveRoom` and the media teardown are async — without this chain an
-// "End & Accept" swap when accepting waiting call while we are in an ongoing one
-// could start joining the new room while the old one is still being torn down, and
-// we would briefly be in two rooms. Module-level so the leave scheduled by an
-// unmounting `CallScreen` still runs before the next call's join.
-let roomOperations: Promise = Promise.resolve();
-
-/**
- * Ties room membership to the caller's mount lifetime: joins the current call's room
- * (starting media first) on mount, leaves and stops media on unmount, and swaps rooms
- * when `currentCall` moves to a different one.
- *
- * Call from the screen that is visible for exactly the duration of a call.
- * Returns the room we have actually joined, or `null` while still connecting.
- */
-export function useCallRoom(): string | null {
- const { currentCall, reportConnectFailed } = useVoIP();
- const { username } = useUser();
- const { joinRoom, leaveRoom } = useConnection();
- const { startCamera, stopCamera } = useCamera();
- const { startMicrophone, stopMicrophone } = useMicrophone();
- const { getSandboxPeerToken } = useSandbox({
- sandboxApiUrl: SANDBOX_API_URL,
- });
-
- const roomName = currentCall?.roomName ?? null;
- const [joinedRoom, setJoinedRoom] = useState(null);
-
- useEffect(() => {
- if (!roomName) return;
- let cancelled = false;
-
- roomOperations = roomOperations.then(async () => {
- if (cancelled) return;
- try {
- const peerToken = await getSandboxPeerToken(
- roomName,
- username ?? 'unknown',
- 'conference',
- );
- if (cancelled) return;
-
- if (IS_VIDEO_CALL) await startCamera();
- await startMicrophone();
- if (cancelled) return;
-
- await joinRoom({ peerToken });
- if (cancelled) return;
- setJoinedRoom(roomName);
- } catch (err) {
- console.error('[voip] failed to join call room:', err);
- if (!cancelled) await reportConnectFailed();
- }
- });
-
- return () => {
- cancelled = true;
- setJoinedRoom(null);
- roomOperations = roomOperations.then(async () => {
- try {
- await stopCamera();
- await stopMicrophone();
- await leaveRoom();
- } catch (err) {
- console.error('[voip] failed to leave call room:', err);
- }
- });
- };
- // Only the room decides when to join or leave
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [roomName]);
-
- return joinedRoom;
-}
diff --git a/examples/mobile-client/voip-call/app/src/hooks/useCallSignaling.ts b/examples/mobile-client/voip-call/app/src/hooks/useCallSignaling.ts
deleted file mode 100644
index b9ebf0a66..000000000
--- a/examples/mobile-client/voip-call/app/src/hooks/useCallSignaling.ts
+++ /dev/null
@@ -1,125 +0,0 @@
-import { useCallback, useEffect, useRef, type MutableRefObject } from 'react';
-
-import {
- useVoIP,
- type CurrentCall,
- type VoIPCallStatus,
-} from '@fishjam-cloud/react-native-client';
-
-import { useUser } from '../user/UserContext';
-
-const SERVER_URL =
- process.env.EXPO_PUBLIC_VOIP_SERVER_URL ?? 'http://localhost:4400';
-
-export type SendSignal = (msg: Record) => void;
-
-/** Filled by {@link useCallSignaling} so App can wire `VoIPProvider.onWaitingCallDeclined`. */
-export type SendSignalRef = MutableRefObject;
-
-export function useCallSignaling(sendSignalRef: SendSignalRef): void {
- const { endCall, currentCall, callStatus, lastEndedReason } = useVoIP();
- const { username } = useUser();
-
- const socketRef = useRef(null);
-
- const handlersRef = useRef({ endCall, currentCall });
- handlersRef.current = { endCall, currentCall };
-
- const sendSignal = useCallback((msg: Record) => {
- const ws = socketRef.current;
- if (ws?.readyState === WebSocket.OPEN) {
- ws.send(JSON.stringify(msg));
- } else {
- console.warn('[signaling] message not sent — socket not open', msg);
- }
- }, []);
-
- useEffect(() => {
- sendSignalRef.current = sendSignal;
- }, [sendSignal, sendSignalRef]);
-
- useEffect(() => {
- if (!username) return;
-
- const wsUrl =
- SERVER_URL.replace(/^http/, 'ws') +
- '/ws?username=' +
- encodeURIComponent(username);
-
- const ws = new WebSocket(wsUrl);
- socketRef.current = ws;
-
- ws.onmessage = (e) => {
- let msg: Record;
- try {
- msg = JSON.parse(e.data);
- } catch {
- return;
- }
-
- const { endCall, currentCall } = handlersRef.current;
- if (!currentCall || currentCall.startedAt !== null) return;
- if (currentCall.roomName !== msg.roomName) return;
-
- // The caller cancelled while we (the callee) are still ringing — from
- // our side this incoming call rang and was never answered.
- if (msg.type === 'call-cancelled' && !currentCall.isOutgoing) {
- void endCall('missed');
- }
- // The callee rejected while we (the caller) are still ringing out — the
- // other party declined, not just hung up.
- else if (msg.type === 'call-rejected' && currentCall.isOutgoing) {
- void endCall('rejected');
- }
- };
-
- return () => {
- ws.close();
- socketRef.current = null;
- };
- }, [username]);
-
- // Detect the local user ending a call before it connected, and notify the
- // other party so their ringing UI can be dismissed.
-
- const prevRef = useRef<{
- callStatus: VoIPCallStatus;
- call: CurrentCall | null;
- }>({
- callStatus,
- call: currentCall,
- });
-
- useEffect(() => {
- const { callStatus: prevStatus, call: prevCall } = prevRef.current;
-
- const userEndedCall =
- lastEndedReason === 'local' || lastEndedReason === 'rejected';
-
- if (
- prevCall &&
- prevCall.startedAt === null &&
- callStatus === 'available' &&
- userEndedCall
- ) {
- // Caller cancelled an outgoing call that was still ringing.
- if (prevStatus === 'connecting' && prevCall.isOutgoing) {
- sendSignal({
- type: 'call-cancelled',
- to: prevCall.handle,
- roomName: prevCall.roomName,
- });
- }
- // Callee rejected an incoming call before answering.
- else if (prevStatus === 'incoming' && !prevCall.isOutgoing) {
- sendSignal({
- type: 'call-rejected',
- to: prevCall.handle,
- roomName: prevCall.roomName,
- });
- }
- }
-
- prevRef.current = { callStatus, call: currentCall };
- }, [callStatus, currentCall, lastEndedReason, sendSignal]);
-}
diff --git a/examples/mobile-client/voip-call/app/src/hooks/useDeviceRegistration.ts b/examples/mobile-client/voip-call/app/src/hooks/useDeviceRegistration.ts
deleted file mode 100644
index 196d5c000..000000000
--- a/examples/mobile-client/voip-call/app/src/hooks/useDeviceRegistration.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import { useVoIP } from '@fishjam-cloud/react-native-client';
-import { useEffect } from 'react';
-import { Platform } from 'react-native';
-
-import { useUser } from '../user/UserContext';
-
-const SERVER_URL =
- process.env.EXPO_PUBLIC_VOIP_SERVER_URL ?? 'http://localhost:4400';
-
-/**
- * Registers this device's VoIP push token with the signaling server so other
- * users can ring it.
- */
-export function useDeviceRegistration(): void {
- const { username } = useUser();
- const { voipToken } = useVoIP();
-
- useEffect(() => {
- if (!username || !voipToken) return;
- if (Platform.OS !== 'ios' && Platform.OS !== 'android') return;
- fetch(`${SERVER_URL}/register`, {
- method: 'POST',
- headers: { 'content-type': 'application/json' },
- body: JSON.stringify({ username, voipToken, platform: Platform.OS }),
- }).catch(() => {});
- }, [username, voipToken]);
-}
diff --git a/examples/mobile-client/voip-call/app/src/hooks/usePlaceCall.ts b/examples/mobile-client/voip-call/app/src/hooks/usePlaceCall.ts
deleted file mode 100644
index f50afcad4..000000000
--- a/examples/mobile-client/voip-call/app/src/hooks/usePlaceCall.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import { useVoIP } from '@fishjam-cloud/react-native-client';
-import { useCallback } from 'react';
-
-import { useUser } from '../user/UserContext';
-
-const SERVER_URL =
- process.env.EXPO_PUBLIC_VOIP_SERVER_URL ?? 'http://localhost:4400';
-
-/** Must match the `isVideo` prop passed to `VoIPProvider` in App.tsx. */
-export const IS_VIDEO_CALL = true;
-
-/** Random room name for the call. */
-function makeRoomName() {
- const bytes = crypto.getRandomValues(new Uint8Array(6));
- const id = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
- return `voip-${id}`;
-}
-
-/**
- * Places an outgoing call: rings the callee through our own signaling server first,
- * then reports the call to CallKit/Telecom via `startCall`.
- *
- * The order matters and is ours to choose. `startCall` only touches the native call
- * UI, so if signaling fails we never show a call that cannot connect.
- */
-export function usePlaceCall(): (to: string) => Promise {
- const { startCall } = useVoIP();
- const { username } = useUser();
-
- return useCallback(
- async (to: string) => {
- const roomName = makeRoomName();
-
- const res = await fetch(`${SERVER_URL}/call`, {
- method: 'POST',
- headers: { 'content-type': 'application/json' },
- body: JSON.stringify({
- from: username,
- to,
- roomName,
- isVideo: IS_VIDEO_CALL,
- }),
- });
- if (!res.ok) throw new Error('Failed to initiate call');
-
- await startCall(to, roomName);
- },
- [startCall, username],
- );
-}
diff --git a/examples/mobile-client/voip-call/app/src/hooks/useRecentsRedial.ts b/examples/mobile-client/voip-call/app/src/hooks/useRecentsRedial.ts
deleted file mode 100644
index cad0c9cd3..000000000
--- a/examples/mobile-client/voip-call/app/src/hooks/useRecentsRedial.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import { useVoIP } from '@fishjam-cloud/react-native-client';
-import { useEffect } from 'react';
-
-import { useUser } from '../user/UserContext';
-import { usePlaceCall } from './usePlaceCall';
-
-/**
- * Places a call when the user redials from the iOS Recents list. The SDK holds the
- * intent until we are ready, so we can simply wait for the session to be restored.
- */
-export function useRecentsRedial(): void {
- const { pendingCallIntent, clearCallIntent } = useVoIP();
- const { username } = useUser();
- const placeCall = usePlaceCall();
-
- useEffect(() => {
- if (!pendingCallIntent || !username) return;
-
- const { handle } = pendingCallIntent;
- clearCallIntent();
- placeCall(handle).catch((err) =>
- console.error('[voip] failed to start call from a Recents intent:', err),
- );
- }, [pendingCallIntent, username, clearCallIntent, placeCall]);
-}
diff --git a/examples/mobile-client/voip-call/app/src/hooks/useRequestPermissions.ts b/examples/mobile-client/voip-call/app/src/hooks/useRequestPermissions.ts
deleted file mode 100644
index 02b0e7739..000000000
--- a/examples/mobile-client/voip-call/app/src/hooks/useRequestPermissions.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import {
- useCameraPermissions,
- useMicrophonePermissions,
-} from '@fishjam-cloud/react-native-client';
-import { useEffect } from 'react';
-import { PermissionsAndroid, Platform } from 'react-native';
-
-/** Requests the camera, microphone, and (Android 13+) notification permissions once. */
-export function useRequestPermissions(): void {
- const [, requestCamera] = useCameraPermissions();
- const [, requestMicrophone] = useMicrophonePermissions();
-
- useEffect(() => {
- (async () => {
- const microphoneStatus = await requestMicrophone();
- if (microphoneStatus !== 'granted') {
- console.warn('Microphone permission not granted — calls will be muted');
- }
- const cameraStatus = await requestCamera();
- if (cameraStatus !== 'granted') {
- console.warn('Camera permission not granted — video will be disabled');
- }
- if (Platform.OS === 'android' && Number(Platform.Version) >= 33) {
- await PermissionsAndroid.request(
- PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS,
- );
- }
- })().catch((err) => console.error('Failed to request permissions:', err));
- }, [requestCamera, requestMicrophone]);
-}
diff --git a/examples/mobile-client/voip-call/app/src/screens/CallScreen.tsx b/examples/mobile-client/voip-call/app/src/screens/CallScreen.tsx
deleted file mode 100644
index 8e9603892..000000000
--- a/examples/mobile-client/voip-call/app/src/screens/CallScreen.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import { usePeers, useVoIP } from '@fishjam-cloud/react-native-client';
-import { useEffect } from 'react';
-
-import { useCallRoom } from '../hooks/useCallRoom';
-import { InCallView } from './InCallView';
-import { OutgoingCallView } from './OutgoingCallView';
-
-/**
- * The screen shown for the whole lifetime of a call (`callStatus` is `connecting` or
- * `active`). It owns the Fishjam side of the call: mounting joins the current call's
- * room via `useCallRoom`, unmounting leaves it, and the remote peer's presence is
- * reported back to the call as connect / hang-up.
- */
-export function CallScreen() {
- const { callStatus, currentCall, reportConnected, endCall } = useVoIP();
- const { remotePeers } = usePeers();
-
- const joinedRoom = useCallRoom();
-
- // The remote peer showing up is what "connected" means for us, and it going away is
- // the remote hanging up. Gated on `joinedRoom` so a stale peer list from the room we
- // just left cannot connect (or end) the room we are moving into.
- useEffect(() => {
- if (!currentCall || joinedRoom !== currentCall.roomName) return;
-
- if (callStatus === 'connecting' && remotePeers.length > 0) {
- void reportConnected();
- } else if (callStatus === 'active' && remotePeers.length === 0) {
- void endCall('remote');
- }
- }, [
- callStatus,
- currentCall,
- joinedRoom,
- remotePeers.length,
- reportConnected,
- endCall,
- ]);
-
- if (callStatus === 'active') {
- return ;
- }
- return ;
-}
diff --git a/examples/mobile-client/voip-call/app/src/screens/InCallView.tsx b/examples/mobile-client/voip-call/app/src/screens/InCallView.tsx
deleted file mode 100644
index 46ea012ab..000000000
--- a/examples/mobile-client/voip-call/app/src/screens/InCallView.tsx
+++ /dev/null
@@ -1,343 +0,0 @@
-import {
- setCallMuted,
- useAudioOutput,
- useCamera,
- useMicrophone,
- usePeers,
- useVAD,
-} from '@fishjam-cloud/react-native-client';
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { StatusBar } from 'expo-status-bar';
-import { Platform, StyleSheet, Text, View } from 'react-native';
-import { SafeAreaView } from 'react-native-safe-area-context';
-
-import { Avatar, InCallButton, VideoCallView } from '../components';
-import { AdditionalColors, BrandColors, TextColors } from '../theme/colors';
-import { useUser } from '../user/UserContext';
-import { useVoIP } from '@fishjam-cloud/react-native-client';
-
-type PeerMeta = { displayName?: string };
-
-function formatDuration(totalSeconds: number): string {
- const m = Math.floor(totalSeconds / 60)
- .toString()
- .padStart(2, '0');
- const s = (totalSeconds % 60).toString().padStart(2, '0');
- return `${m}:${s}`;
-}
-
-function useElapsed(startedAt: number | null): number {
- const [elapsed, setElapsed] = useState(0);
- useEffect(() => {
- if (!startedAt) {
- setElapsed(0);
- return;
- }
- setElapsed(Math.floor((Date.now() - startedAt) / 1000));
- const id = setInterval(() => {
- setElapsed(Math.floor((Date.now() - startedAt) / 1000));
- }, 1000);
- return () => clearInterval(id);
- }, [startedAt]);
- return elapsed;
-}
-
-/**
- * Sets aside the live devices when the call goes on hold and restores exactly those
- * devices on resume. Runs only while mounted, i.e. only during an active call.
- */
-function useHoldMediaSync() {
- const { isOnHold } = useVoIP();
- const { isCameraOn, toggleCamera } = useCamera();
- const { isMicrophoneOn, toggleMicrophone } = useMicrophone();
-
- const heldMediaRef = useRef({
- microphoneEnabled: false,
- cameraEnabled: false,
- });
- const prevOnHoldRef = useRef(isOnHold);
-
- useEffect(() => {
- if (prevOnHoldRef.current === isOnHold) return;
- prevOnHoldRef.current = isOnHold;
-
- (async () => {
- if (isOnHold) {
- heldMediaRef.current = {
- microphoneEnabled: isMicrophoneOn,
- cameraEnabled: isCameraOn,
- };
- if (isMicrophoneOn) await toggleMicrophone();
- if (isCameraOn) await toggleCamera();
- } else {
- const { microphoneEnabled, cameraEnabled } = heldMediaRef.current;
- if (microphoneEnabled) await toggleMicrophone();
- if (cameraEnabled) await toggleCamera();
- }
- })().catch((err) =>
- console.error('[voip] failed to update media for held call:', err),
- );
- }, [isOnHold, isCameraOn, isMicrophoneOn, toggleCamera, toggleMicrophone]);
-}
-
-/**
- * Mirrors the system mute state (CallKit / Telecom) onto the microphone track, so
- * muting from the native call UI actually silences us.
- */
-function useMuteSync() {
- const { isMuted } = useVoIP();
- const { isMicrophoneOn, toggleMicrophone } = useMicrophone();
-
- const prevMutedRef = useRef(isMuted);
-
- useEffect(() => {
- if (prevMutedRef.current === isMuted) return;
- prevMutedRef.current = isMuted;
-
- if (isMicrophoneOn !== isMuted) return;
-
- toggleMicrophone().catch((err) =>
- console.error('[voip] failed to sync mute state:', err),
- );
- }, [isMuted, isMicrophoneOn, toggleMicrophone]);
-}
-
-export function InCallView() {
- const { currentCall, endCall, isOnHold, setCallHeld } = useVoIP();
- const { username, avatarUrlFor } = useUser();
-
- useHoldMediaSync();
- useMuteSync();
-
- const { isMicrophoneOn, toggleMicrophone } = useMicrophone();
- // Mute the mic AND drive the system mute indicator (CallKit on iOS). The
- // resulting onMuteChanged is idempotent, so this doesn't loop.
- const handleToggleMute = useCallback(async () => {
- const willBeMuted = isMicrophoneOn;
- await toggleMicrophone();
- await setCallMuted(willBeMuted);
- }, [isMicrophoneOn, toggleMicrophone]);
- const { isCameraOn, toggleCamera } = useCamera();
- const { currentAudioOutput, availableAudioOutputs, ios, android } =
- useAudioOutput();
- const { remotePeers } = usePeers();
-
- const peerIds = useMemo(() => remotePeers.map((p) => p.id), [remotePeers]);
- const speaking = useVAD({ peerIds });
-
- const elapsed = useElapsed(currentCall?.startedAt ?? null);
- const isSpeaker = currentAudioOutput?.type === 'speaker';
-
- if (!currentCall) return null;
-
- const isVideo = currentCall.isVideo;
- const displayName = currentCall.displayName;
-
- const toggleSpeaker = () => {
- if (Platform.OS === 'ios') {
- ios.overrideAudioOutput(isSpeaker ? 'none' : 'speaker');
- return;
- }
- const target = availableAudioOutputs.find(
- (device) => device.type === (isSpeaker ? 'earpiece' : 'speaker'),
- );
- if (target) {
- android
- .selectAudioOutput(target.id)
- .catch((err) => console.warn('Failed to switch audio output:', err));
- }
- };
-
- const bluetoothDevice = availableAudioOutputs.find(
- (device) => device.type === 'bluetooth',
- );
- const isBluetooth = currentAudioOutput?.type === 'bluetooth';
-
- const selectBluetooth = () => {
- if (Platform.OS === 'ios') {
- // iOS has no public API to force a specific Bluetooth route; restoring
- // the default route sends audio back to the connected Bluetooth device.
- ios
- .overrideAudioOutput('none')
- .catch((err) => console.warn('Failed to switch audio output:', err));
- return;
- }
- if (bluetoothDevice) {
- android
- .selectAudioOutput(bluetoothDevice.id)
- .catch((err) => console.warn('Failed to switch audio output:', err));
- }
- };
-
- const toggleHold = () => {
- setCallHeld(!isOnHold).catch((err) =>
- console.warn('Failed to change held state:', err),
- );
- };
-
- const controls = (
-
-
- {isVideo && (
-
- )}
-
- {bluetoothDevice && (
-
- )}
-
- endCall('local')}
- accessibilityLabel="End call"
- />
-
- );
-
- if (isVideo) {
- return (
-
- {/* Dark video background needs light status bar icons, unlike every other (light) screen. */}
-
-
-
-
- {displayName}
- {formatDuration(elapsed)}
-
- {controls}
-
-
- );
- }
-
- return (
-
-
- On call · {formatDuration(elapsed)}
- {remotePeers.length === 0 ? (
-
-
- {displayName}
-
- ) : (
-
- {remotePeers.map((peer) => {
- const name = peer.metadata?.peer?.displayName ?? displayName;
- const isTalking = speaking[peer.id] ?? false;
- return (
-
-
- {name}
-
- );
- })}
-
- )}
-
- {controls}
-
- );
-}
-
-const styles = StyleSheet.create({
- container: { flex: 1, backgroundColor: BrandColors.seaBlue20 },
-
- // audio layout
- audioContent: {
- flex: 1,
- alignItems: 'center',
- justifyContent: 'center',
- gap: 16,
- padding: 32,
- },
- label: {
- fontSize: 13,
- color: BrandColors.seaBlue100,
- letterSpacing: 1.5,
- textTransform: 'uppercase',
- fontWeight: '600',
- },
- callee: { alignItems: 'center', gap: 16, marginTop: 8 },
- name: { fontSize: 28, fontWeight: '700', color: TextColors.darkText },
- roster: {
- flexDirection: 'row',
- flexWrap: 'wrap',
- justifyContent: 'center',
- gap: 24,
- marginTop: 8,
- },
- rosterItem: { alignItems: 'center', gap: 8 },
- rosterName: { fontSize: 14, color: TextColors.darkText, fontWeight: '500' },
- audioControlsWrap: { paddingBottom: 24, alignItems: 'center' },
-
- // video layout (FaceTime style)
- videoRoot: { flex: 1, backgroundColor: BrandColors.darkBlue100 },
- overlay: { justifyContent: 'space-between' },
- videoHeader: { paddingTop: 8, alignItems: 'center' },
- videoName: { fontSize: 18, fontWeight: '700', color: AdditionalColors.white },
- videoTimer: {
- fontSize: 13,
- color: 'rgba(255, 255, 255, 0.85)',
- marginTop: 2,
- },
- floatingControlsWrap: { alignItems: 'center', paddingBottom: 12 },
-
- // shared control bar
- controls: {
- flexDirection: 'row',
- gap: 10,
- backgroundColor: 'rgba(255, 255, 255, 0.92)',
- paddingHorizontal: 12,
- paddingVertical: 14,
- borderRadius: 40,
- alignItems: 'center',
- },
-});
diff --git a/examples/mobile-client/voip-call/app/src/screens/LoginScreen.tsx b/examples/mobile-client/voip-call/app/src/screens/LoginScreen.tsx
deleted file mode 100644
index 93779fb8a..000000000
--- a/examples/mobile-client/voip-call/app/src/screens/LoginScreen.tsx
+++ /dev/null
@@ -1,150 +0,0 @@
-import { MaterialCommunityIcons } from '@expo/vector-icons';
-import { useState } from 'react';
-import {
- ActivityIndicator,
- KeyboardAvoidingView,
- Platform,
- StyleSheet,
- Text,
- TextInput,
- TouchableOpacity,
- View,
-} from 'react-native';
-import { SafeAreaView } from 'react-native-safe-area-context';
-
-import { AdditionalColors, BrandColors, TextColors } from '../theme/colors';
-import { useUser } from '../user/UserContext';
-
-export function LoginScreen() {
- const { register } = useUser();
- const [input, setInput] = useState('');
- const [isLoading, setIsLoading] = useState(false);
- const [isFocused, setIsFocused] = useState(false);
-
- const handleContinue = async () => {
- const name = input.trim();
- if (!name) return;
- setIsLoading(true);
- try {
- await register(name);
- } finally {
- setIsLoading(false);
- }
- };
-
- const disabled = !input.trim() || isLoading;
-
- return (
-
-
-
-
-
-
-
- Welcome
-
- Choose a display name so others can call you.
-
-
- setIsFocused(true)}
- onBlur={() => setIsFocused(false)}
- editable={!isLoading}
- autoFocus
- />
-
-
- {isLoading ? (
-
- ) : (
- Continue
- )}
-
-
-
-
- );
-}
-
-const styles = StyleSheet.create({
- container: { flex: 1, backgroundColor: BrandColors.seaBlue20 },
- flex: { flex: 1 },
- content: {
- flex: 1,
- justifyContent: 'center',
- alignItems: 'center',
- padding: 32,
- gap: 16,
- },
- iconWrap: {
- width: 88,
- height: 88,
- borderRadius: 44,
- backgroundColor: BrandColors.darkBlue100,
- alignItems: 'center',
- justifyContent: 'center',
- marginBottom: 8,
- },
- title: {
- fontSize: 28,
- fontWeight: '700',
- color: TextColors.darkText,
- textAlign: 'center',
- },
- subtitle: {
- fontSize: 15,
- color: AdditionalColors.grey80,
- textAlign: 'center',
- marginBottom: 8,
- },
- input: {
- width: '100%',
- height: 56,
- borderWidth: 2,
- borderColor: BrandColors.darkBlue100,
- borderRadius: 40,
- paddingHorizontal: 20,
- fontSize: 16,
- color: TextColors.darkText,
- backgroundColor: AdditionalColors.white,
- },
- inputFocused: {
- borderColor: BrandColors.seaBlue80,
- },
- button: {
- width: '100%',
- height: 56,
- backgroundColor: BrandColors.darkBlue100,
- borderRadius: 100,
- alignItems: 'center',
- justifyContent: 'center',
- },
- buttonDisabled: {
- backgroundColor: AdditionalColors.grey60,
- },
- buttonText: {
- color: AdditionalColors.white,
- fontSize: 18,
- fontWeight: '600',
- },
-});
diff --git a/examples/mobile-client/voip-call/app/src/screens/OutgoingCallView.tsx b/examples/mobile-client/voip-call/app/src/screens/OutgoingCallView.tsx
deleted file mode 100644
index 7629af387..000000000
--- a/examples/mobile-client/voip-call/app/src/screens/OutgoingCallView.tsx
+++ /dev/null
@@ -1,81 +0,0 @@
-import { useEffect } from 'react';
-import { StyleSheet, Text, View } from 'react-native';
-import Animated, {
- useAnimatedStyle,
- useSharedValue,
- withRepeat,
- withSequence,
- withTiming,
-} from 'react-native-reanimated';
-import { SafeAreaView } from 'react-native-safe-area-context';
-
-import { Avatar, InCallButton } from '../components';
-import { BrandColors, TextColors } from '../theme/colors';
-import { useUser } from '../user/UserContext';
-import { useVoIP } from '@fishjam-cloud/react-native-client';
-
-/** Ringing UI while an outgoing call waits for the other side. Rendered by `CallScreen`. */
-export function OutgoingCallView() {
- const { currentCall, endCall } = useVoIP();
- const { avatarUrlFor } = useUser();
- const scale = useSharedValue(1);
-
- useEffect(() => {
- scale.value = withRepeat(
- withSequence(
- withTiming(1.1, { duration: 800 }),
- withTiming(1, { duration: 800 }),
- ),
- -1,
- );
- return () => {
- scale.value = 1;
- };
- }, [scale]);
-
- const animatedStyle = useAnimatedStyle(() => ({
- transform: [{ scale: scale.value }],
- }));
-
- if (!currentCall) return null;
- const displayName = currentCall.displayName;
-
- return (
-
-
- Calling
-
-
-
- {displayName}
-
-
- endCall('local')}
- accessibilityLabel="Cancel call"
- />
-
-
- );
-}
-
-const styles = StyleSheet.create({
- container: { flex: 1, backgroundColor: BrandColors.seaBlue20 },
- content: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 16 },
- label: {
- fontSize: 13,
- color: BrandColors.seaBlue100,
- letterSpacing: 1.5,
- textTransform: 'uppercase',
- fontWeight: '600',
- },
- avatarWrap: { marginVertical: 24 },
- name: { fontSize: 28, fontWeight: '700', color: TextColors.darkText },
- controls: { paddingBottom: 32, alignItems: 'center' },
-});
diff --git a/examples/mobile-client/voip-call/app/src/screens/UsersScreen.tsx b/examples/mobile-client/voip-call/app/src/screens/UsersScreen.tsx
deleted file mode 100644
index 13ae2bd76..000000000
--- a/examples/mobile-client/voip-call/app/src/screens/UsersScreen.tsx
+++ /dev/null
@@ -1,149 +0,0 @@
-import { MaterialCommunityIcons } from '@expo/vector-icons';
-import { useEffect } from 'react';
-import {
- ActivityIndicator,
- FlatList,
- RefreshControl,
- StyleSheet,
- Text,
- TouchableOpacity,
- View,
-} from 'react-native';
-import { SafeAreaView } from 'react-native-safe-area-context';
-
-import { Avatar } from '../components';
-import { AdditionalColors, BrandColors, TextColors } from '../theme/colors';
-import { useUser } from '../user/UserContext';
-import { useVoIP } from '@fishjam-cloud/react-native-client';
-import { usePlaceCall } from '../hooks/usePlaceCall';
-
-export function UsersScreen() {
- const { username, users, refreshUsers, logout } = useUser();
- const { callStatus } = useVoIP();
- const placeCall = usePlaceCall();
- const isCalling = callStatus === 'connecting' || callStatus === 'active';
-
- useEffect(() => {
- refreshUsers();
- }, [refreshUsers]);
-
- const handleCall = async (to: string) => {
- try {
- await placeCall(to);
- } catch (err) {
- console.error('Failed to start call:', err);
- }
- };
-
- return (
-
-
-
- Users
- logout()}
- accessibilityLabel="Log out">
-
- Log out
-
-
- Signed in as {username}
-
-
- item.username}
- contentContainerStyle={styles.list}
- refreshControl={
-
- }
- ListEmptyComponent={
-
-
- No other users online yet.
-
- }
- renderItem={({ item }) => (
- handleCall(item.username)}
- disabled={isCalling}
- activeOpacity={0.7}>
-
- {item.username}
- {isCalling ? (
-
- ) : (
-
- )}
-
- )}
- />
-
- );
-}
-
-const styles = StyleSheet.create({
- container: { flex: 1, backgroundColor: BrandColors.seaBlue20 },
- header: {
- padding: 24,
- paddingBottom: 12,
- },
- headerRow: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
- },
- title: { fontSize: 28, fontWeight: '700', color: TextColors.darkText },
- me: { fontSize: 14, color: AdditionalColors.grey80, marginTop: 2 },
- logoutButton: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: 6,
- paddingVertical: 6,
- paddingHorizontal: 12,
- borderRadius: 100,
- backgroundColor: AdditionalColors.white,
- },
- logoutText: {
- fontSize: 14,
- fontWeight: '600',
- color: AdditionalColors.red80,
- },
- list: { padding: 16, gap: 10, flexGrow: 1 },
- row: {
- flexDirection: 'row',
- alignItems: 'center',
- padding: 12,
- borderRadius: 16,
- backgroundColor: AdditionalColors.white,
- gap: 12,
- },
- rowDisabled: { opacity: 0.5 },
- name: {
- flex: 1,
- fontSize: 16,
- fontWeight: '500',
- color: TextColors.darkText,
- },
- empty: { paddingTop: 64, alignItems: 'center', gap: 10 },
- emptyText: { fontSize: 16, fontWeight: '600', color: BrandColors.darkBlue80 },
- emptyHint: { fontSize: 14, color: AdditionalColors.grey80 },
-});
diff --git a/examples/mobile-client/voip-call/app/src/theme/colors.ts b/examples/mobile-client/voip-call/app/src/theme/colors.ts
deleted file mode 100644
index 1fd920692..000000000
--- a/examples/mobile-client/voip-call/app/src/theme/colors.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-export const BrandColors = {
- seaBlue100: '#1F7193',
- seaBlue80: '#46ADD8',
- seaBlue60: '#87CCE8',
- seaBlue40: '#BFE7F8',
- seaBlue20: '#F1FAFE',
-
- darkBlue100: '#001A72',
- darkBlue80: '#3F57A6',
- darkBlue60: '#7089DB',
- darkBlue40: '#BFCCF8',
- darkBlue20: '#F5F7FE',
-};
-
-export const AdditionalColors = {
- red100: '#981B1B',
- red80: '#C32222',
- grey80: '#70778F',
- grey60: '#B2B9CC',
- white: '#FFFFFF',
-};
-
-export const TextColors = {
- darkText: '#001A72',
- additionalLightText: '#ACB5D2',
- white: '#FFFFFF',
-};
diff --git a/examples/mobile-client/voip-call/app/src/user/UserContext.ts b/examples/mobile-client/voip-call/app/src/user/UserContext.ts
deleted file mode 100644
index 513dfcf73..000000000
--- a/examples/mobile-client/voip-call/app/src/user/UserContext.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { createContext, useContext } from 'react';
-
-export type UserSummary = {
- username: string;
- avatarUrl: string | null;
-};
-
-export type UserContextValue = {
- username: string | null;
- users: UserSummary[];
- isLoading: boolean;
- register: (name: string) => Promise;
- refreshUsers: () => Promise;
- logout: () => Promise;
- avatarUrlFor: (name: string) => string | null;
-};
-
-export const UserContext = createContext(null);
-
-export function useUser(): UserContextValue {
- const ctx = useContext(UserContext);
- if (!ctx) throw new Error('useUser must be used within UserProvider');
- return ctx;
-}
diff --git a/examples/mobile-client/voip-call/app/src/user/UserProvider.tsx b/examples/mobile-client/voip-call/app/src/user/UserProvider.tsx
deleted file mode 100644
index c2e834d4d..000000000
--- a/examples/mobile-client/voip-call/app/src/user/UserProvider.tsx
+++ /dev/null
@@ -1,93 +0,0 @@
-import AsyncStorage from '@react-native-async-storage/async-storage';
-import {
- type PropsWithChildren,
- useCallback,
- useEffect,
- useMemo,
- useState,
-} from 'react';
-
-import { UserContext, type UserSummary } from './UserContext';
-
-const SERVER_URL =
- process.env.EXPO_PUBLIC_VOIP_SERVER_URL ?? 'http://localhost:4400';
-const USERNAME_STORAGE_KEY = 'voip.username';
-
-export function UserProvider({ children }: PropsWithChildren) {
- const [username, setUsername] = useState(null);
- const [allUsers, setAllUsers] = useState([]);
- // `true` while we read the persisted session on startup, so the UI can avoid
- // flashing the login screen before a saved username is restored.
- const [isLoading, setIsLoading] = useState(true);
-
- const fetchUsers = useCallback(async () => {
- try {
- const res = await fetch(`${SERVER_URL}/users?exclude=`);
- if (!res.ok) return;
- const list: UserSummary[] = await res.json();
- setAllUsers(list);
- } catch {
- // network error — ignore
- }
- }, []);
-
- const register = useCallback(
- async (name: string) => {
- setUsername(name);
- await AsyncStorage.setItem(USERNAME_STORAGE_KEY, name);
- await fetchUsers();
- },
- [fetchUsers],
- );
-
- const refreshUsers = useCallback(async () => {
- if (username) await fetchUsers();
- }, [username, fetchUsers]);
-
- const logout = useCallback(async () => {
- setUsername(null);
- setAllUsers([]);
- await AsyncStorage.removeItem(USERNAME_STORAGE_KEY);
- }, []);
-
- // Restore the persisted session on startup so a reload keeps the user logged in.
- useEffect(() => {
- (async () => {
- try {
- const saved = await AsyncStorage.getItem(USERNAME_STORAGE_KEY);
- if (saved) {
- setUsername(saved);
- await fetchUsers();
- }
- } finally {
- setIsLoading(false);
- }
- })();
- }, [fetchUsers]);
-
- const users = useMemo(
- () => allUsers.filter((u) => u.username !== username),
- [allUsers, username],
- );
-
- const avatarUrlFor = useCallback(
- (name: string) =>
- allUsers.find((u) => u.username === name)?.avatarUrl ?? null,
- [allUsers],
- );
-
- const value = useMemo(
- () => ({
- username,
- users,
- isLoading,
- register,
- refreshUsers,
- logout,
- avatarUrlFor,
- }),
- [username, users, isLoading, register, refreshUsers, logout, avatarUrlFor],
- );
-
- return {children};
-}
diff --git a/examples/mobile-client/voip-call/app/tsconfig.json b/examples/mobile-client/voip-call/app/tsconfig.json
deleted file mode 100644
index 6d82505fc..000000000
--- a/examples/mobile-client/voip-call/app/tsconfig.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
- "extends": "expo/tsconfig.base",
- "compilerOptions": {
- "strict": true,
- "jsx": "react-jsx"
- },
- "include": ["**/*.ts", "**/*.tsx"],
- "exclude": ["server"]
-}
diff --git a/examples/mobile-client/voip-call/server/.env.example b/examples/mobile-client/voip-call/server/.env.example
deleted file mode 100644
index e69de29bb..000000000
diff --git a/examples/mobile-client/voip-call/server/.gitignore b/examples/mobile-client/voip-call/server/.gitignore
deleted file mode 100644
index af6a1f3b3..000000000
--- a/examples/mobile-client/voip-call/server/.gitignore
+++ /dev/null
@@ -1,3 +0,0 @@
-voip.db
-apns.pem
-fcm-credentials.json
\ No newline at end of file
diff --git a/examples/mobile-client/voip-call/server/README.md b/examples/mobile-client/voip-call/server/README.md
deleted file mode 100644
index 303f7d534..000000000
--- a/examples/mobile-client/voip-call/server/README.md
+++ /dev/null
@@ -1,146 +0,0 @@
-# voip-call-server
-
-## Run
-
-```bash
-deno task start # listens on :4400
-```
-
-## Credentials
-
-Both services are optional — configure APNs to call iOS devices, FCM to call
-Android devices, or both to call between them. On startup the server prints which
-services are enabled, and `/call` answers `503` when the callee's platform has no
-credentials.
-
-## APNs certificate
-
-APNs auth is certificate-based — you need a **VoIP Services certificate** from your
-Apple Developer account. See Apple's guide:
-[Establishing a certificate-based connection to APNs](https://developer.apple.com/documentation/usernotifications/establishing-a-certificate-based-connection-to-apns).
-
-Drop the VoIP push certificate **and its private key, combined into one PEM**, at
-`./apns.pem` — it's presented to APNs as a TLS client certificate. The bundle id and
-sandbox host are set at the top of `main.ts`.
-
-```bash
-# combine an exported cert + key into one PEM
-cat cert.pem key.pem > apns.pem
-```
-
-## FCM credentials
-
-FCM (Android) push uses the [FCM HTTP v1 API](https://firebase.google.com/docs/cloud-messaging/send-message),
-which authenticates with a **Firebase service account**. In the Firebase console
-go to **Project settings → Service accounts → Generate new private key** and
-download the JSON. See Google's guide:
-[Authorize send requests](https://firebase.google.com/docs/cloud-messaging/auth-server).
-
-Drop the downloaded file at `./fcm-credentials.json` — the server reads
-`client_email`, `private_key`, and `project_id` from it to mint an OAuth access
-token scoped to `firebase.messaging`.
-
-## Push routing
-
-A push token is only valid with the service that issued it, so each device records
-its `platform` (`ios` or `android`) when it registers. `/call` looks up the
-**callee's** platform and rings them through the matching service — an Android
-caller reaching an iOS callee goes out over APNs, and an iOS caller reaching an
-Android callee goes out over FCM.
-
-## API
-
-| Method | Path | Body / Query | Description |
-| --------- | --------------------- | ----------------------------------- | ---------------------------------------- |
-| POST | `/register` | `{ username, voipToken, platform }` | Register / update device VoIP push token |
-| GET | `/users?exclude=` | | List all registered users except `me` |
-| POST | `/call` | `{ from, to, roomName, isVideo }` | Send a VoIP push to the callee |
-| WebSocket | `/ws?username=` | | Bidirectional signaling socket |
-
-`platform` must be `"ios"` or `"android"`; anything else is rejected with `400`.
-
-## Signaling (WebSocket)
-
-Every connected app opens a persistent WebSocket at `/ws?username=`. The
-server maintains an in-memory `username -> WebSocket` map and acts as a simple
-relay: any JSON message that contains a `to` field is forwarded to that user's
-socket, with `from` stamped to the sender's username.
-
-### Message: `call-cancelled`
-
-Sent by the **caller** when they cancel before the callee has answered. The
-server relays it immediately to the callee, which calls `endCall()` to dismiss
-the ringing UI.
-
-**Caller → Server:**
-
-```json
-{ "type": "call-cancelled", "to": "", "roomName": "" }
-```
-
-**Server → Callee:**
-
-```json
-{
- "type": "call-cancelled",
- "to": "",
- "roomName": "",
- "from": ""
-}
-```
-
-The callee only acts on this message when the call is still ringing (i.e.
-`startedAt` is `null` and the call is not outgoing). If the call was already
-answered the message is silently ignored.
-
-## APNs VoIP push payload
-
-The push is sent with the headers `apns-push-type: voip` and
-`apns-topic: .voip`, to the VoIP token the app registered. The
-payload forwarded to the callee's device (field semantics are in the
-[VoIP calls guide](https://documentation.fishjam.io/docs/how-to/client/voip-calls#6-handle-an-incoming-call)):
-
-```json
-{
- "roomName": "",
- "displayName": "",
- "isVideo": false,
- "avatarUrl": "https://…/caller.jpg"
-}
-```
-
-`avatarUrl` is optional. On **Android** the caller photo is downloaded and shown
-in the incoming-call notification and full-screen UI (falling back to initials on
-failure). On **iOS** CallKit cannot render caller images, so it is delivered to JS
-(`onIncoming` payload) only for your own in-app UI. The server assigns each user one of
-its bundled avatar images (served from `./avatars`) at registration, and sends
-the caller's in the push.
-
-iOS 13+ requires that every received VoIP push immediately reports an incoming call to CallKit — the `@fishjam-cloud/react-native-webrtc` pod handles this automatically.
-
-## FCM push payload
-
-FCM values must be strings, so the same fields are sent as a high-priority
-**data** message (`isVideo` is stringified), plus the `fishjam` discriminator
-the SDK's messaging service keys on — a data message without it is never
-treated as a call:
-
-```json
-{
- "message": {
- "token": "",
- "data": {
- "fishjam": "voip-incoming",
- "roomName": "",
- "displayName": "",
- "isVideo": "false",
- "avatarUrl": "https://…/caller.jpg"
- },
- "android": { "priority": "high" }
- }
-}
-```
-
-The high-priority data message wakes `@fishjam-cloud/react-native-webrtc`'s
-messaging service even when the app is backgrounded or killed, which reports the
-incoming call to the native Telecom stack.
diff --git a/examples/mobile-client/voip-call/server/avatars/coral.png b/examples/mobile-client/voip-call/server/avatars/coral.png
deleted file mode 100644
index db8b92bde..000000000
Binary files a/examples/mobile-client/voip-call/server/avatars/coral.png and /dev/null differ
diff --git a/examples/mobile-client/voip-call/server/avatars/mint.png b/examples/mobile-client/voip-call/server/avatars/mint.png
deleted file mode 100644
index 5a8dfd429..000000000
Binary files a/examples/mobile-client/voip-call/server/avatars/mint.png and /dev/null differ
diff --git a/examples/mobile-client/voip-call/server/avatars/ocean.png b/examples/mobile-client/voip-call/server/avatars/ocean.png
deleted file mode 100644
index 67df19369..000000000
Binary files a/examples/mobile-client/voip-call/server/avatars/ocean.png and /dev/null differ
diff --git a/examples/mobile-client/voip-call/server/avatars/orchid.png b/examples/mobile-client/voip-call/server/avatars/orchid.png
deleted file mode 100644
index 0e0f011d8..000000000
Binary files a/examples/mobile-client/voip-call/server/avatars/orchid.png and /dev/null differ
diff --git a/examples/mobile-client/voip-call/server/avatars/sunny.png b/examples/mobile-client/voip-call/server/avatars/sunny.png
deleted file mode 100644
index 39fd08500..000000000
Binary files a/examples/mobile-client/voip-call/server/avatars/sunny.png and /dev/null differ
diff --git a/examples/mobile-client/voip-call/server/deno.json b/examples/mobile-client/voip-call/server/deno.json
deleted file mode 100644
index b95a4f7c2..000000000
--- a/examples/mobile-client/voip-call/server/deno.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
- "tasks": {
- "start": "deno run --allow-net --allow-read --allow-env --allow-ffi main.ts"
- },
- "imports": {
- "@db/sqlite": "jsr:@db/sqlite@^0.12",
- "google-auth-library": "npm:google-auth-library@^10.9.0"
- },
- "fmt": {
- "singleQuote": true
- }
-}
diff --git a/examples/mobile-client/voip-call/server/deno.lock b/examples/mobile-client/voip-call/server/deno.lock
deleted file mode 100644
index 694919a8a..000000000
--- a/examples/mobile-client/voip-call/server/deno.lock
+++ /dev/null
@@ -1,207 +0,0 @@
-{
- "version": "5",
- "specifiers": {
- "jsr:@db/sqlite@*": "0.13.0",
- "jsr:@db/sqlite@0.12": "0.12.0",
- "jsr:@denosaurs/plug@1": "1.1.0",
- "jsr:@std/assert@0.217": "0.217.0",
- "jsr:@std/encoding@1": "1.0.10",
- "jsr:@std/fmt@1": "1.0.10",
- "jsr:@std/fs@1": "1.0.24",
- "jsr:@std/internal@^1.0.14": "1.0.14",
- "jsr:@std/path@0.217": "0.217.0",
- "jsr:@std/path@1": "1.1.5",
- "jsr:@std/path@1.0": "1.0.9",
- "jsr:@std/path@^1.1.5": "1.1.5",
- "npm:google-auth-library@^10.9.0": "10.9.0"
- },
- "jsr": {
- "@db/sqlite@0.12.0": {
- "integrity": "dd1ef7f621ad50fc1e073a1c3609c4470bd51edc0994139c5bf9851de7a6d85f",
- "dependencies": [
- "jsr:@denosaurs/plug",
- "jsr:@std/path@0.217"
- ]
- },
- "@db/sqlite@0.13.0": {
- "integrity": "4545c635e0b3d4ddfdc0f2240f932f24b8ad0178e9c2e3a0f9403e7b18ae2fb5",
- "dependencies": [
- "jsr:@denosaurs/plug",
- "jsr:@std/path@1.0"
- ]
- },
- "@denosaurs/plug@1.1.0": {
- "integrity": "eb2f0b7546c7bca2000d8b0282c54d50d91cf6d75cb26a80df25a6de8c4bc044",
- "dependencies": [
- "jsr:@std/encoding",
- "jsr:@std/fmt",
- "jsr:@std/fs",
- "jsr:@std/path@1"
- ]
- },
- "@std/assert@0.217.0": {
- "integrity": "c98e279362ca6982d5285c3b89517b757c1e3477ee9f14eb2fdf80a45aaa9642"
- },
- "@std/encoding@1.0.10": {
- "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1"
- },
- "@std/fmt@1.0.10": {
- "integrity": "90dfba288802ac6de82fb31d0917eb9e4450b9925b954d5e51fc29ac07419db5"
- },
- "@std/fs@1.0.24": {
- "integrity": "f3061b45b81673a2bece689da041df32d174be064c89eb6397fb5718d3fb7877",
- "dependencies": [
- "jsr:@std/internal",
- "jsr:@std/path@^1.1.5"
- ]
- },
- "@std/internal@1.0.14": {
- "integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7"
- },
- "@std/path@0.217.0": {
- "integrity": "1217cc25534bca9a2f672d7fe7c6f356e4027df400c0e85c0ef3e4343bc67d11",
- "dependencies": [
- "jsr:@std/assert"
- ]
- },
- "@std/path@1.0.9": {
- "integrity": "260a49f11edd3db93dd38350bf9cd1b4d1366afa98e81b86167b4e3dd750129e"
- },
- "@std/path@1.1.5": {
- "integrity": "ccea00982ea28c36becaf6e62f855406c76a8c32d462f66f415bbb7d83a271bc",
- "dependencies": [
- "jsr:@std/internal"
- ]
- }
- },
- "npm": {
- "agent-base@7.1.4": {
- "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="
- },
- "base64-js@1.5.1": {
- "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="
- },
- "bignumber.js@9.3.1": {
- "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="
- },
- "buffer-equal-constant-time@1.0.1": {
- "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="
- },
- "data-uri-to-buffer@4.0.1": {
- "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="
- },
- "debug@4.4.3": {
- "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
- "dependencies": [
- "ms"
- ]
- },
- "ecdsa-sig-formatter@1.0.11": {
- "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
- "dependencies": [
- "safe-buffer"
- ]
- },
- "extend@3.0.2": {
- "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="
- },
- "fetch-blob@3.2.0": {
- "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
- "dependencies": [
- "node-domexception",
- "web-streams-polyfill"
- ]
- },
- "formdata-polyfill@4.0.10": {
- "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
- "dependencies": [
- "fetch-blob"
- ]
- },
- "gaxios@7.1.6": {
- "integrity": "sha512-aIQ0QL8Or8vsUhHyXGA6AohOFRrAAiHhrvsAG6myzcSlfhxSXtnwXA/pRuQTilFgjhLe30swK5rg1d7E1f8Izw==",
- "dependencies": [
- "extend",
- "https-proxy-agent",
- "node-fetch"
- ]
- },
- "gcp-metadata@8.1.2": {
- "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==",
- "dependencies": [
- "gaxios",
- "google-logging-utils",
- "json-bigint"
- ]
- },
- "google-auth-library@10.9.0": {
- "integrity": "sha512-xtvUqvINPhTaBm7nXqlYPcrMHJPm1lCNdSovxnKKhTm+4JsvQ+KGVYJViLoH9Yxu8w+T0Qv5HubzYT9BLrppJg==",
- "dependencies": [
- "base64-js",
- "ecdsa-sig-formatter",
- "gaxios",
- "gcp-metadata",
- "google-logging-utils",
- "jws"
- ]
- },
- "google-logging-utils@1.1.3": {
- "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="
- },
- "https-proxy-agent@7.0.6": {
- "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
- "dependencies": [
- "agent-base",
- "debug"
- ]
- },
- "json-bigint@1.0.0": {
- "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
- "dependencies": [
- "bignumber.js"
- ]
- },
- "jwa@2.0.1": {
- "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
- "dependencies": [
- "buffer-equal-constant-time",
- "ecdsa-sig-formatter",
- "safe-buffer"
- ]
- },
- "jws@4.0.1": {
- "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
- "dependencies": [
- "jwa",
- "safe-buffer"
- ]
- },
- "ms@2.1.3": {
- "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
- },
- "node-domexception@1.0.0": {
- "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
- "deprecated": true
- },
- "node-fetch@3.3.2": {
- "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
- "dependencies": [
- "data-uri-to-buffer",
- "fetch-blob",
- "formdata-polyfill"
- ]
- },
- "safe-buffer@5.2.1": {
- "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="
- },
- "web-streams-polyfill@3.3.3": {
- "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="
- }
- },
- "workspace": {
- "dependencies": [
- "jsr:@db/sqlite@0.12",
- "npm:google-auth-library@^10.9.0"
- ]
- }
-}
diff --git a/examples/mobile-client/voip-call/server/main.ts b/examples/mobile-client/voip-call/server/main.ts
deleted file mode 100644
index 4d120f6c6..000000000
--- a/examples/mobile-client/voip-call/server/main.ts
+++ /dev/null
@@ -1,323 +0,0 @@
-import { Database } from "@db/sqlite";
-import { JWT } from "google-auth-library";
-
-const db = new Database("voip.db");
-
-type DevicePlatform = "ios" | "android";
-
-const isDevicePlatform = (value: unknown): value is DevicePlatform =>
- value === "ios" || value === "android";
-
-db.exec(`
- CREATE TABLE IF NOT EXISTS users (
- username TEXT NOT NULL PRIMARY KEY,
- voip_token TEXT NOT NULL UNIQUE,
- platform TEXT NOT NULL,
- avatar TEXT,
- updated_at INTEGER NOT NULL
- )
-`);
-
-// --- Avatars (served from ./avatars) ---
-
-const AVATARS = ["orchid", "mint", "sunny", "coral", "ocean"] as const;
-type AvatarName = (typeof AVATARS)[number];
-
-const baseUrl = (req: Request) =>
- Deno.env.get("PUBLIC_BASE_URL") ?? new URL(req.url).origin;
-
-const avatarUrl = (req: Request, avatar: string) =>
- `${baseUrl(req)}/avatars/${avatar}.png`;
-
-/**
- * Picks the avatar currently assigned to the fewest users (round-robin balance),
- * breaking ties at random. Called once per user at registration.
- */
-function assignLeastUsedAvatar(): AvatarName {
- const counts = new Map(AVATARS.map((a) => [a, 0]));
- const rows = db.sql<{ avatar: string | null }>`
- SELECT avatar FROM users WHERE avatar IS NOT NULL
- `;
- for (const { avatar } of rows) {
- if (avatar && counts.has(avatar as AvatarName)) {
- counts.set(avatar as AvatarName, counts.get(avatar as AvatarName)! + 1);
- }
- }
- const min = Math.min(...counts.values());
- const leastUsed = AVATARS.filter((a) => counts.get(a) === min);
- return leastUsed[Math.floor(Math.random() * leastUsed.length)];
-}
-
-type PushParams = {
- token: string;
- roomName: string;
- displayName: string;
- isVideo: boolean;
- avatarUrl?: string;
-};
-
-async function readIfPresent(path: string): Promise {
- try {
- return await Deno.readTextFile(path);
- } catch (err) {
- if (err instanceof Deno.errors.NotFound) return null;
- throw err;
- }
-}
-
-// --- FCM push (Android) ---
-
-type ServiceAccount = {
- client_email: string;
- private_key: string;
- project_id: string;
-};
-
-const fcmCredentials = await readIfPresent("./fcm-credentials.json");
-const serviceAccount: ServiceAccount | null = fcmCredentials
- ? JSON.parse(fcmCredentials)
- : null;
-
-const authClient = serviceAccount
- ? new JWT({
- email: serviceAccount.client_email,
- key: serviceAccount.private_key,
- scopes: ["https://www.googleapis.com/auth/firebase.messaging"],
- })
- : null;
-
-async function getAccessToken(client: JWT): Promise {
- const { token } = await client.getAccessToken();
- if (!token) throw new Error("Failed to get access token");
- return token;
-}
-
-async function sendFcmPush(params: PushParams): Promise {
- if (!serviceAccount || !authClient) {
- throw new Error("Android push requires ./fcm-credentials.json");
- }
- const accessToken = await getAccessToken(authClient);
-
- const res = await fetch(
- `https://fcm.googleapis.com/v1/projects/${serviceAccount.project_id}/messages:send`,
- {
- method: "POST",
- headers: {
- Authorization: `Bearer ${accessToken}`,
- "Content-Type": "application/json",
- },
- body: JSON.stringify({
- message: {
- token: params.token,
- data: {
- // Discriminator the SDK's PushNotificationService keys on; a data
- // message without it is never treated as a call.
- fishjam: "voip-incoming",
- roomName: params.roomName,
- displayName: params.displayName,
- isVideo: String(params.isVideo),
- ...(params.avatarUrl ? { avatarUrl: params.avatarUrl } : {}),
- },
- android: { priority: "high" },
- },
- }),
- },
- );
-
- if (!res.ok) {
- const text = await res.text();
- throw new Error(`FCM push failed ${res.status}: ${text}`);
- }
-}
-
-// --- APNs VoIP push (certificate-based) ---
-
-const BUNDLE_ID = "io.fishjam.example.voipcall";
-const APNS_HOST = "api.development.push.apple.com";
-
-const apnsPem = await readIfPresent("./apns.pem");
-const apnsClient = apnsPem
- ? Deno.createHttpClient({ cert: apnsPem, key: apnsPem })
- : null;
-
-async function sendApnsPush(params: PushParams): Promise {
- if (!apnsClient) {
- throw new Error("iOS push requires ./apns.pem");
- }
- const res = await fetch(`https://${APNS_HOST}/3/device/${params.token}`, {
- client: apnsClient,
- method: "POST",
- headers: {
- "apns-push-type": "voip",
- "apns-topic": `${BUNDLE_ID}.voip`,
- "content-type": "application/json",
- },
- body: JSON.stringify({
- roomName: params.roomName,
- displayName: params.displayName,
- isVideo: params.isVideo,
- ...(params.avatarUrl ? { avatarUrl: params.avatarUrl } : {}),
- }),
- });
-
- if (!res.ok) {
- const text = await res.text();
- throw new Error(`APNs push failed ${res.status}: ${text}`);
- }
-}
-
-// --- Push routing ---
-
-const sendPush: Record Promise> =
- {
- ios: sendApnsPush,
- android: sendFcmPush,
- };
-
-// --- Helpers ---
-
-const json = (data: unknown, status = 200) => Response.json(data, { status });
-
-// --- WebSocket signaling registry ---
-
-const sockets = new Map();
-
-// --- Route handler ---
-
-Deno.serve({ port: 4400 }, async (req) => {
- const url = new URL(req.url);
- console.log(`${req.method} ${url.pathname}`);
-
- // POST /register { username, voipToken, platform }
- if (req.method === "POST" && url.pathname === "/register") {
- const { username, voipToken, platform } = (await req.json()) as {
- username: string;
- voipToken: string;
- platform: string;
- };
- if (!username || !voipToken) {
- return json({ error: "username and voipToken are required" }, 400);
- }
- if (!isDevicePlatform(platform)) {
- return json({ error: 'platform must be "ios" or "android"' }, 400);
- }
- const existing = db.sql<{ avatar: string | null }>`
- SELECT avatar FROM users WHERE username = ${username}
- `;
- const avatar = existing[0]?.avatar ?? assignLeastUsedAvatar();
- db.exec(
- `INSERT OR REPLACE INTO users (username, voip_token, platform, avatar, updated_at) VALUES (?, ?, ?, ?, ?)`,
- [username, voipToken, platform, avatar, Date.now()],
- );
- return json({ ok: true, avatarUrl: avatarUrl(req, avatar) });
- }
-
- // GET /users?exclude=
- if (req.method === "GET" && url.pathname === "/users") {
- const exclude = url.searchParams.get("exclude") ?? "";
- const rows = db.sql<{ username: string; avatar: string | null }>`
- SELECT username, avatar FROM users WHERE username != ${exclude} ORDER BY username
- `;
- return json(
- rows.map((r) => ({
- username: r.username,
- avatarUrl: r.avatar ? avatarUrl(req, r.avatar) : null,
- })),
- );
- }
-
- // POST /call { from, to, roomName }
- if (req.method === "POST" && url.pathname === "/call") {
- const { from, to, roomName, isVideo } = (await req.json()) as {
- from: string;
- to: string;
- roomName: string;
- isVideo: boolean;
- };
- if (!from || !to || !roomName) {
- return json({ error: "from, to and roomName are required" }, 400);
- }
-
- const calleeRows = db.sql<{ voip_token: string; platform: string | null }>`
- SELECT voip_token, platform FROM users WHERE username = ${to}
- `;
- if (calleeRows.length === 0) {
- return json({ error: "callee not found" }, 404);
- }
- const { voip_token: voipToken, platform } = calleeRows[0];
- if (!isDevicePlatform(platform)) {
- return json({ error: "callee registered without a known platform" }, 409);
- }
-
- const callerRows = db.sql<{ avatar: string | null }>`
- SELECT avatar FROM users WHERE username = ${from}
- `;
- const callerAvatar = callerRows[0]?.avatar;
-
- try {
- await sendPush[platform]({
- token: voipToken,
- roomName: roomName,
- displayName: from,
- isVideo: isVideo,
- avatarUrl: callerAvatar ? avatarUrl(req, callerAvatar) : undefined,
- });
- } catch (err) {
- console.error(`Failed to send ${platform} VoIP push:`, err);
- return json({ error: "failed to send VoIP push" }, 502);
- }
-
- return json({ ok: true });
- }
-
- // GET /ws?username= — bidirectional signaling socket
- if (req.method === "GET" && url.pathname === "/ws") {
- const username = url.searchParams.get("username");
- if (!username) return json({ error: "username required" }, 400);
-
- const { socket, response } = Deno.upgradeWebSocket(req);
- socket.onopen = () => {
- sockets.set(username, socket);
- console.log(`${username} connected`);
- };
- socket.onclose = () => {
- if (sockets.get(username) === socket) sockets.delete(username);
- console.log(`${username} disconnected`);
- };
- socket.onmessage = (e) => {
- let msg: { type?: string; to?: string; [key: string]: unknown };
- try {
- msg = JSON.parse(e.data);
- } catch {
- return;
- }
- if (!msg.to) return;
- const target = sockets.get(msg.to);
- if (target?.readyState === WebSocket.OPEN) {
- target.send(JSON.stringify({ ...msg, from: username }));
- }
- };
- return response;
- }
-
- // GET /avatars/.png — serve the bundled avatar images
- if (req.method === "GET" && url.pathname.startsWith("/avatars/")) {
- const name = url.pathname.slice("/avatars/".length);
- if (!/^[a-z0-9_-]+\.png$/.test(name)) {
- return new Response("Not found", { status: 404 });
- }
- try {
- const file = await Deno.readFile(`./avatars/${name}`);
- return new Response(file, {
- headers: {
- "content-type": "image/png",
- "cache-control": "public, max-age=86400",
- },
- });
- } catch {
- return new Response("Not found", { status: 404 });
- }
- }
-
- return new Response("Not found", { status: 404 });
-});
diff --git a/package.json b/package.json
index fb9805c5a..642158667 100644
--- a/package.json
+++ b/package.json
@@ -16,7 +16,6 @@
"packages/react-native-webrtc",
"examples/react-client/*",
"examples/mobile-client/*",
- "examples/mobile-client/voip-call/app",
"e2e-tests/webrtc-client",
"e2e-tests/react-client",
"e2e-tests/livestream-client"
diff --git a/yarn.lock b/yarn.lock
index 164c9eec6..c00c8015c 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5287,7 +5287,7 @@ __metadata:
languageName: node
linkType: hard
-"@fishjam-cloud/ios-expo-voip@workspace:*, @fishjam-cloud/ios-expo-voip@workspace:packages/ios-expo-voip":
+"@fishjam-cloud/ios-expo-voip@workspace:packages/ios-expo-voip":
version: 0.0.0-use.local
resolution: "@fishjam-cloud/ios-expo-voip@workspace:packages/ios-expo-voip"
peerDependencies:
@@ -6603,18 +6603,6 @@ __metadata:
languageName: node
linkType: hard
-"@react-native-async-storage/async-storage@npm:^3.1.1":
- version: 3.1.1
- resolution: "@react-native-async-storage/async-storage@npm:3.1.1"
- dependencies:
- idb: "npm:8.0.3"
- peerDependencies:
- react: "*"
- react-native: "*"
- checksum: 10c0/20d34973b0a4d1acc133556c52daa59b9826f676abdc351f12c70feb4d557b66e279d1c880662bbfe037b7d79bf64fdee759a5217781c47cd20fd8ba0e0e1b24
- languageName: node
- linkType: hard
-
"@react-native/assets-registry@npm:0.81.5":
version: 0.81.5
resolution: "@react-native/assets-registry@npm:0.81.5"
@@ -14137,13 +14125,6 @@ __metadata:
languageName: node
linkType: hard
-"idb@npm:8.0.3":
- version: 8.0.3
- resolution: "idb@npm:8.0.3"
- checksum: 10c0/421cd9a3281b7564528857031cc33fd9e95753f8191e483054cb25d1ceea7303a0d1462f4f69f5b41606f0f066156999e067478abf2460dfcf9cab80dae2a2b2
- languageName: node
- linkType: hard
-
"ieee754@npm:^1.1.13, ieee754@npm:^1.2.1":
version: 1.2.1
resolution: "ieee754@npm:1.2.1"
@@ -18052,17 +18033,6 @@ __metadata:
languageName: node
linkType: hard
-"react-native-get-random-values@npm:^2.0.0":
- version: 2.0.0
- resolution: "react-native-get-random-values@npm:2.0.0"
- dependencies:
- fast-base64-decode: "npm:^1.0.0"
- peerDependencies:
- react-native: ">=0.81"
- checksum: 10c0/fd2e10ab3bca54bff8f5844e3314b797e02822e86663bc2dafe00a511b8a8e1c05d96aa0b8dc1ffad428387d44bbf458ce770a429858433d36f58a16e6e52986
- languageName: node
- linkType: hard
-
"react-native-is-edge-to-edge@npm:^1.1.6, react-native-is-edge-to-edge@npm:^1.2.1":
version: 1.3.1
resolution: "react-native-is-edge-to-edge@npm:1.3.1"
@@ -21484,27 +21454,6 @@ __metadata:
languageName: node
linkType: hard
-"voip-call@workspace:examples/mobile-client/voip-call/app":
- version: 0.0.0-use.local
- resolution: "voip-call@workspace:examples/mobile-client/voip-call/app"
- dependencies:
- "@fishjam-cloud/ios-expo-voip": "workspace:*"
- "@fishjam-cloud/react-native-client": "workspace:*"
- "@react-native-async-storage/async-storage": "npm:^3.1.1"
- "@types/react": "npm:~19.1.0"
- eslint-config-expo: "npm:~8.0.1"
- expo: "npm:~54.0.30"
- expo-splash-screen: "npm:~31.0.13"
- expo-status-bar: "npm:~3.0.9"
- react: "npm:19.1.0"
- react-native: "npm:0.81.5"
- react-native-get-random-values: "npm:^2.0.0"
- react-native-reanimated: "npm:~4.1.1"
- react-native-safe-area-context: "npm:~5.6.0"
- typescript: "npm:~5.9.2"
- languageName: unknown
- linkType: soft
-
"vscode-languageserver-textdocument@npm:^1.0.12":
version: 1.0.12
resolution: "vscode-languageserver-textdocument@npm:1.0.12"