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"