diff --git a/examples/mobile-client/voip-call/README.md b/examples/mobile-client/voip-call/README.md new file mode 100644 index 000000000..e87b72794 --- /dev/null +++ b/examples/mobile-client/voip-call/README.md @@ -0,0 +1,186 @@ +# iOS VoIP Push Notifications — setup + +This document lists everything the **app** has to change to receive VoIP push +notifications and surface them as CallKit calls. Most of the heavy lifting lives +in the `@fishjam-cloud/react-native-webrtc` pod; the app only has to (1) register +for VoIP pushes and (2) declare the right capabilities. + +## How the flow works + +``` +APNs VoIP push + → iOS wakes the app (even if killed) UIBackgroundModes: voip + → PKPushRegistry delegate VoipManager (pod) + → reports the call to CallKit CallKitManager (pod) → system call UI + → fires onIncomingPush block WebRTCModule+PushKit (pod) + → sendEventWithName("callKitActionPerformed", {incoming}) → JS + → user answers / ends on the CallKit UI + → CallKitManager callbacks WebRTCModule+CallKit (pod) + → JS events: answer / ended / muted / held +``` + +What the **pod already does for you** (no app code needed): + +- `PKPushRegistry` + `PKPushRegistryDelegate` — `VoipManager.m`. +- Reporting the incoming call to CallKit on push receipt — + `VoipManager.m` → `[[CallKitManager shared] reportIncomingCallWithDisplayName:isVideo:]`. +- Bridging native → JS events. `WebRTCModule` subclasses `RCTEventEmitter`; when + JS adds its first listener, `startObserving` runs and calls + `startObservingPushKit` (`WebRTCModule+PushKit.m`), which wires the + `registered` (VoIP token) and `incoming` (push payload) events. See + [`RCTEventEmitter`](https://reactnative.dev/docs/legacy/native-modules-ios#sending-events-to-javascript). + +What the **app must do** is below. + +--- + +## 1. AppDelegate — register for VoIP pushes + +The pod never registers `PKPushRegistry` on its own (a push can wake the app +before any JS is running, so registration must happen at launch). Add the call +in `application(_:didFinishLaunchingWithOptions:)`. + +`ios/voipcall/AppDelegate.swift`: + +```swift +public override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil +) -> Bool { + let delegate = ReactNativeDelegate() + let factory = ExpoReactNativeFactory(delegate: delegate) + delegate.dependencyProvider = RCTAppDependencyProvider() + + reactNativeDelegate = delegate + reactNativeFactory = factory + bindReactNativeFactory(factory) + + // 👇 Register for VoIP pushes as early as possible. + VoipManager.registerForVoIPPushes() + +#if os(iOS) || os(tvOS) + window = UIWindow(frame: UIScreen.main.bounds) + factory.startReactNative( + withModuleName: "main", + in: window, + launchOptions: launchOptions) +#endif + + return super.application(application, didFinishLaunchingWithOptions: launchOptions) +} +``` + +> Registration triggers the APNs handshake. Once the VoIP token is issued, the +> pod forwards it to JS as the `registered` event. Because the token may arrive +> before your component mounts, prefer reading it on demand rather than relying +> only on catching the event. + +## 2. Bridging header — expose the pod's class to Swift + +`VoipManager` is Objective-C; Swift needs it imported through the bridging +header. `ios/voipcall/voipcall-Bridging-Header.h`: + +~~~objc +// +// Use this file to import your target's public headers that you would like to expose to Swift. +// +#import "VoipManager.h" +~~~ + +> If `"VoipManager.h"` doesn't resolve, use the pod-qualified form instead: +> `#import `. + +## 3. Entitlements — Push Notifications + +`ios/voipcall/voipcall.entitlements` must contain the `aps-environment` key +(add the **Push Notifications** capability in Xcode → Signing & Capabilities, +which writes this for you). Already present in this app: + +~~~xml +aps-environment +development +~~~ + +## 4. Info.plist — background modes & permissions + +`ios/voipcall/Info.plist` needs (all already present in this app): + +```xml +UIBackgroundModes + + voip + processing + + + +NSMicrophoneUsageDescription +Allow $(PRODUCT_NAME) to access your microphone. +``` + +In Xcode → Signing & Capabilities this corresponds to **Background Modes → +Voice over IP** and **Push Notifications**. + +--- + +## 5. Server / APNs side + +- Create a **VoIP Services certificate** (or use an APNs Auth Key) in the Apple + Developer portal. VoIP pushes use a dedicated token, not the regular APNs one. + See Apple's [PushKit](https://developer.apple.com/documentation/pushkit) and + [Reporting incoming calls](https://developer.apple.com/documentation/callkit/cxprovider) + docs. +- Send the push with header `apns-push-type: voip` and `apns-topic: +.voip`, to the VoIP token your app reported via the `registered` + event. +- Payload fields the pod reads (`VoipManager.m`): + + ```json + { "displayName": "Alice", "isVideo": false } + ``` + + `username` is accepted as a fallback for `displayName`. The entire payload is + also forwarded to JS as the `incoming` event, so you can include routing data + (e.g. `roomId`). + +> iOS 13+ requires that **every** received VoIP push reports an incoming call to +> CallKit, immediately, inside the push handler — otherwise the system +> terminates the app. The pod already does this for you. + +--- + +## 6. JS side — subscribe to events + +```ts +import { useCallKitEvent } from "./src/callkit"; + +useCallKitEvent("registered", (token) => console.log("VoIP token:", token)); +useCallKitEvent("incoming", (payload) => + console.log("incoming push:", payload), +); +useCallKitEvent("answer", (payload) => console.log("answer:", payload)); +useCallKitEvent("ended", (payload) => console.log("ended:", payload)); +``` + +--- + +## 7. Expo caveat + +This `ios/` directory is generated by `expo prebuild`. Direct edits to +`AppDelegate.swift`, `Info.plist`, the entitlements, and the bridging header +**will be overwritten by `expo prebuild --clean`**. For a durable setup, move +these changes into an [Expo config plugin](https://docs.expo.dev/config-plugins/introduction/) +(`withAppDelegate`, `withInfoPlist`, `withEntitlementsPlist`) so they are +re-applied on every prebuild. + +--- + +## 8. Testing + +1. Run on a **real device** (VoIP pushes don't work on the Simulator). +2. Launch the app and confirm `VoIP token:` is logged — that token is your push + destination. +3. Send a VoIP push to that token (e.g. via a script using your VoIP key, or a + tool like Pusher/Knuff). The CallKit incoming-call UI should appear even if + the app is backgrounded or killed. +4. Tap **Answer** / **End** on the system UI and confirm the `answer` / `ended` + events log in Metro. diff --git a/examples/mobile-client/voip-call/app/.env.example b/examples/mobile-client/voip-call/app/.env.example index 78d7522d4..6b4e7bee2 100644 --- a/examples/mobile-client/voip-call/app/.env.example +++ b/examples/mobile-client/voip-call/app/.env.example @@ -1,2 +1,3 @@ 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/App.tsx b/examples/mobile-client/voip-call/app/App.tsx index 20c278f99..1799b002a 100644 --- a/examples/mobile-client/voip-call/app/App.tsx +++ b/examples/mobile-client/voip-call/app/App.tsx @@ -1,42 +1,122 @@ -import { FishjamProvider } from '@fishjam-cloud/react-native-client'; +import { + FishjamProvider, + useSandbox, +} from '@fishjam-cloud/react-native-client'; import { StatusBar } from 'expo-status-bar'; -import { useCallback } from 'react'; -import { StyleSheet, Text } from 'react-native'; -import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context'; +import { useCallback, useEffect } from 'react'; +import { ActivityIndicator, StyleSheet, View } from 'react-native'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; -import { useCallKitEvent } from './src/callkit'; +import type { PropsWithChildren } from 'react'; +import { DirectoryScreen } from './src/screens/DirectoryScreen'; +import { InCallScreen } from './src/screens/InCallScreen'; +import { LoginScreen } from './src/screens/LoginScreen'; +import { OutgoingCallScreen } from './src/screens/OutgoingCallScreen'; +import { BrandColors } from './src/theme/colors'; +import { UserProvider, useUser } from './src/user'; +import { VoipProvider, useVoip } from './src/voip'; -const EventLog = () => { - // TODO: Implement on the native side. - useCallKitEvent( - 'incoming', - useCallback((payload) => {}, []), - ); - useCallKitEvent( - 'answer', - useCallback((payload) => {}, []), - ); - useCallKitEvent( - 'end', - useCallback((payload) => {}, []), +const SERVER_URL = + process.env.EXPO_PUBLIC_VOIP_SERVER_URL ?? 'http://localhost:4400'; +const SANDBOX_API_URL = process.env.EXPO_PUBLIC_SANDBOX_API_URL ?? ''; + +function VoipWrapper({ children }: PropsWithChildren) { + const { username } = useUser(); + const { getSandboxPeerToken } = useSandbox({ + sandboxApiUrl: SANDBOX_API_URL, + }); + + const getPeerToken = useCallback( + (roomName: string) => + getSandboxPeerToken(roomName, username ?? 'unknown', 'conference'), + [getSandboxPeerToken, username], ); - useCallKitEvent( - 'registered', - useCallback((payload) => {}, []), + + const requestCall = useCallback( + async ({ + to, + roomName, + isVideo, + }: { + to: string; + roomName: string; + isVideo: boolean; + }) => { + const res = await fetch(`${SERVER_URL}/call`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ from: username, to, roomName, isVideo }), + }); + if (!res.ok) throw new Error('Failed to initiate call'); + }, + [username], ); return ( - - - CallKit events - + + + {children} + ); -}; +} + +function DeviceRegistration() { + const { username } = useUser(); + const { voipToken } = useVoip(); + + useEffect(() => { + if (!username || !voipToken) return; + fetch(`${SERVER_URL}/register`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ username, voipToken }), + }).catch(() => {}); + }, [username, voipToken]); + + return null; +} + +function AppScreens() { + const { username, isLoading } = useUser(); + const { status } = useVoip(); + + if (isLoading) { + return ( + + + + ); + } + + if (!username) { + return ; + } + + if (status === 'connecting') { + return ; + } + + if (status === 'active') { + return ; + } + + return ; +} const App = () => ( - + + + + + + + + ); @@ -44,10 +124,11 @@ const App = () => ( export default App; const styles = StyleSheet.create({ - container: { flex: 1, padding: 24, gap: 16 }, - title: { fontSize: 24, fontWeight: '600' }, - log: { flex: 1, borderTopColor: '#EAECF0', borderTopWidth: 1 }, - logContent: { paddingTop: 12, gap: 6 }, - muted: { color: 'gray' }, - line: { fontFamily: 'Courier', fontSize: 14 }, + root: { flex: 1, backgroundColor: BrandColors.seaBlue20 }, + center: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: BrandColors.seaBlue20, + }, }); diff --git a/examples/mobile-client/voip-call/app/app.json b/examples/mobile-client/voip-call/app/app.json index 5c6622ed0..a0331d0ff 100644 --- a/examples/mobile-client/voip-call/app/app.json +++ b/examples/mobile-client/voip-call/app/app.json @@ -11,8 +11,10 @@ "bundleIdentifier": "io.fishjam.example.voipcall", "infoPlist": { "NSMicrophoneUsageDescription": "Allow $(PRODUCT_NAME) to access your microphone.", + "NSCameraUsageDescription": "Allow $(PRODUCT_NAME) to access your camera for video calls.", "ITSAppUsesNonExemptEncryption": false - } + }, + "appleTeamId": "J5FM626PE2" }, "android": { "package": "io.fishjam.example.voipcall", diff --git a/examples/mobile-client/voip-call/app/index.js b/examples/mobile-client/voip-call/app/index.js index ce8f2073f..5212d31d3 100644 --- a/examples/mobile-client/voip-call/app/index.js +++ b/examples/mobile-client/voip-call/app/index.js @@ -1,4 +1,5 @@ import { registerRootComponent } from 'expo'; +import 'react-native-get-random-values'; import App from './App'; diff --git a/examples/mobile-client/voip-call/app/package.json b/examples/mobile-client/voip-call/app/package.json index e8686ac3e..beb18f242 100644 --- a/examples/mobile-client/voip-call/app/package.json +++ b/examples/mobile-client/voip-call/app/package.json @@ -11,10 +11,13 @@ }, "dependencies": { "@fishjam-cloud/react-native-client": "workspace:*", + "@react-native-async-storage/async-storage": "^3.1.1", "expo": "~54.0.30", "expo-status-bar": "~3.0.9", "react": "19.1.0", "react-native": "0.81.5", + "react-native-get-random-values": "^2.0.0", + "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0" }, "devDependencies": { diff --git a/examples/mobile-client/voip-call/app/src/callkit/index.ts b/examples/mobile-client/voip-call/app/src/callkit/index.ts deleted file mode 100644 index 7540075e6..000000000 --- a/examples/mobile-client/voip-call/app/src/callkit/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { useCallKitEvent } from './useCallKit'; -export type { ExtendedCallKitAction, VoipIncomingPayload } from './useCallKit'; diff --git a/examples/mobile-client/voip-call/app/src/callkit/useCallKit.ts b/examples/mobile-client/voip-call/app/src/callkit/useCallKit.ts deleted file mode 100644 index bfe161b87..000000000 --- a/examples/mobile-client/voip-call/app/src/callkit/useCallKit.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { - type CallKitAction, - useCallKitEvent as useCallKitEventSdk, -} from '@fishjam-cloud/react-native-client'; - -export type VoipIncomingPayload = { roomId: string; username: string }; - -/** - * Just the temporary example to demostrate how the new api should look like - */ -export type ExtendedCallKitAction = CallKitAction & { - incoming?: VoipIncomingPayload; - answer?: undefined; - end?: undefined; - registered?: string; // device id / VoIP push token -}; - -export const useCallKitEvent = useCallKitEventSdk as < - T extends keyof ExtendedCallKitAction, ->( - action: T, - callback: (event: ExtendedCallKitAction[T]) => void, -) => void; diff --git a/examples/mobile-client/voip-call/app/src/components/Avatar.tsx b/examples/mobile-client/voip-call/app/src/components/Avatar.tsx new file mode 100644 index 000000000..ccdcbadb5 --- /dev/null +++ b/examples/mobile-client/voip-call/app/src/components/Avatar.tsx @@ -0,0 +1,40 @@ +import { StyleSheet, Text, View } from 'react-native'; + +import { BrandColors, TextColors } from '../theme/colors'; + +type AvatarProps = { + name: string; + size?: number; + speaking?: boolean; +}; + +export function Avatar({ name, size = 96, speaking = false }: AvatarProps) { + const initial = name.trim()[0]?.toUpperCase() ?? '?'; + return ( + + {initial} + + ); +} + +const styles = StyleSheet.create({ + avatar: { + backgroundColor: BrandColors.darkBlue60, + alignItems: 'center', + justifyContent: 'center', + borderColor: BrandColors.seaBlue80, + }, + 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 new file mode 100644 index 000000000..5082c8885 --- /dev/null +++ b/examples/mobile-client/voip-call/app/src/components/InCallButton.tsx @@ -0,0 +1,61 @@ +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; +}; + +export function InCallButton({ + type = 'primary', + active = false, + onPress, + iconName, + accessibilityLabel, +}: 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: 60, + height: 60, + borderRadius: 30, + justifyContent: 'center', + alignItems: 'center', + }, + outline: { + borderWidth: 1, + borderColor: BrandColors.darkBlue80, + }, +}); diff --git a/examples/mobile-client/voip-call/app/src/components/VideoCallView.tsx b/examples/mobile-client/voip-call/app/src/components/VideoCallView.tsx new file mode 100644 index 000000000..83ecaf1b6 --- /dev/null +++ b/examples/mobile-client/voip-call/app/src/components/VideoCallView.tsx @@ -0,0 +1,85 @@ +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; + localName: string; +}; + +export function VideoCallView({ remoteName, localName }: 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, + borderRadius: 16, + overflow: 'hidden', + 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 new file mode 100644 index 000000000..d7e1cba09 --- /dev/null +++ b/examples/mobile-client/voip-call/app/src/components/index.ts @@ -0,0 +1,3 @@ +export { Avatar } from './Avatar'; +export { InCallButton } from './InCallButton'; +export { VideoCallView } from './VideoCallView'; diff --git a/examples/mobile-client/voip-call/app/src/screens/DirectoryScreen.tsx b/examples/mobile-client/voip-call/app/src/screens/DirectoryScreen.tsx new file mode 100644 index 000000000..e180ab153 --- /dev/null +++ b/examples/mobile-client/voip-call/app/src/screens/DirectoryScreen.tsx @@ -0,0 +1,154 @@ +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'; +import { useVoip } from '../voip'; + +// 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}`; +} + +export function DirectoryScreen() { + const { username, users, refreshUsers, logout } = useUser(); + const { status, startCall } = useVoip(); + const isCalling = status === 'connecting' || status === 'active'; + + useEffect(() => { + refreshUsers(); + }, [refreshUsers]); + + const handleCall = async (to: string) => { + try { + await startCall(to, makeRoomName()); + } catch (err) { + console.error('Failed to start call:', err); + } + }; + + return ( + + + + Directory + logout()} + accessibilityLabel="Log out"> + + Log out + + + Signed in as {username} + + + item} + contentContainerStyle={styles.list} + refreshControl={ + + } + ListEmptyComponent={ + + + No other users online yet. + + } + renderItem={({ item }) => ( + handleCall(item)} + disabled={isCalling} + activeOpacity={0.7}> + + {item} + {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/screens/InCallScreen.tsx b/examples/mobile-client/voip-call/app/src/screens/InCallScreen.tsx new file mode 100644 index 000000000..a85963199 --- /dev/null +++ b/examples/mobile-client/voip-call/app/src/screens/InCallScreen.tsx @@ -0,0 +1,199 @@ +import { + useAudioOutput, + useCamera, + useMicrophone, + usePeers, + useVAD, +} from '@fishjam-cloud/react-native-client'; +import { useEffect, useMemo, useState } from 'react'; +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 { useVoip } from '../voip'; + +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; +} + +export function InCallScreen() { + const { currentCall, endCall } = useVoip(); + + const { isMicrophoneOn, toggleMicrophone } = useMicrophone(); + const { isCameraOn, toggleCamera } = useCamera(); + const { currentAudioOutput, 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'); + } + // add android later + }; + + const controls = ( + + + {isVideo && ( + + )} + + + + ); + + if (isVideo) { + return ( + + + + + {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: 14, + backgroundColor: 'rgba(255, 255, 255, 0.92)', + paddingHorizontal: 18, + 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 new file mode 100644 index 000000000..d0ac02706 --- /dev/null +++ b/examples/mobile-client/voip-call/app/src/screens/LoginScreen.tsx @@ -0,0 +1,150 @@ +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'; + +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/OutgoingCallScreen.tsx b/examples/mobile-client/voip-call/app/src/screens/OutgoingCallScreen.tsx new file mode 100644 index 000000000..e607d5d89 --- /dev/null +++ b/examples/mobile-client/voip-call/app/src/screens/OutgoingCallScreen.tsx @@ -0,0 +1,74 @@ +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 { useVoip } from '../voip'; + +export function OutgoingCallScreen() { + const { currentCall, endCall } = useVoip(); + 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} + + + + + + ); +} + +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/theme/colors.ts b/examples/mobile-client/voip-call/app/src/theme/colors.ts new file mode 100644 index 000000000..1fd920692 --- /dev/null +++ b/examples/mobile-client/voip-call/app/src/theme/colors.ts @@ -0,0 +1,27 @@ +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 new file mode 100644 index 000000000..ed5f06a38 --- /dev/null +++ b/examples/mobile-client/voip-call/app/src/user/UserContext.ts @@ -0,0 +1,18 @@ +import { createContext, useContext } from 'react'; + +export type UserContextValue = { + username: string | null; + users: string[]; + isLoading: boolean; + register: (name: string) => Promise; + refreshUsers: () => Promise; + logout: () => Promise; +}; + +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 new file mode 100644 index 000000000..b0cac9966 --- /dev/null +++ b/examples/mobile-client/voip-call/app/src/user/UserProvider.tsx @@ -0,0 +1,73 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import React, { + type PropsWithChildren, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; + +import { UserContext } 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 [users, setUsers] = 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 (exclude: string) => { + try { + const res = await fetch(`${SERVER_URL}/users?exclude=${encodeURIComponent(exclude)}`); + if (!res.ok) return; + const list: string[] = await res.json(); + setUsers(list); + } catch { + // network error — ignore + } + }, []); + + const register = useCallback( + async (name: string) => { + setUsername(name); + await AsyncStorage.setItem(USERNAME_STORAGE_KEY, name); + await fetchUsers(name); + }, + [fetchUsers], + ); + + const refreshUsers = useCallback(async () => { + if (username) await fetchUsers(username); + }, [username, fetchUsers]); + + const logout = useCallback(async () => { + setUsername(null); + setUsers([]); + 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(saved); + } + } finally { + setIsLoading(false); + } + })(); + }, [fetchUsers]); + + const value = useMemo( + () => ({ username, users, isLoading, register, refreshUsers, logout }), + [username, users, isLoading, register, refreshUsers, logout], + ); + + return {children}; +} diff --git a/examples/mobile-client/voip-call/app/src/user/index.ts b/examples/mobile-client/voip-call/app/src/user/index.ts new file mode 100644 index 000000000..dec7164e8 --- /dev/null +++ b/examples/mobile-client/voip-call/app/src/user/index.ts @@ -0,0 +1,3 @@ +export { UserProvider } from './UserProvider'; +export { useUser } from './UserContext'; +export type { UserContextValue } from './UserContext'; diff --git a/examples/mobile-client/voip-call/app/src/voip/VoipContext.ts b/examples/mobile-client/voip-call/app/src/voip/VoipContext.ts new file mode 100644 index 000000000..23af187c2 --- /dev/null +++ b/examples/mobile-client/voip-call/app/src/voip/VoipContext.ts @@ -0,0 +1,61 @@ +import { createContext, useContext } from 'react'; + +/** + * Lifecycle state of the current VoIP call. + * + * - `available` — no call in progress + * - `incoming` — a call is ringing, awaiting the user's answer + * - `connecting` — the call was started/answered and we're joining the room + * - `active` — media is connected and the call is in progress + */ +export type VoipCallStatus = 'available' | 'incoming' | 'connecting' | 'active'; + +/** + * Details of the call currently being handled. + */ +export type CurrentCall = { + /** Fishjam room the call takes place in. */ + roomName: string; + /** Name shown in the CallKit UI (the remote party). */ + displayName: string; + /** Whether the call is a video call. */ + isVideo: boolean; + /** Timestamp (ms) when the call became `active`, or `null` if not yet connected. */ + startedAt: number | null; +}; + +/** + * Value held by {@link VoipContext} and returned from {@link useVoip}. + */ +export type VoipContextValue = { + /** Current call lifecycle status. */ + status: VoipCallStatus; + /** This device's VoIP push token, or `null` until APNs has issued one. */ + voipToken: string | null; + /** The call currently being handled, or `null` when `status` is `available`. */ + currentCall: CurrentCall | null; + /** + * Starts an outgoing call to `to` in the given `roomName` — reports it to + * CallKit and joins the room. + */ + startCall: (to: string, roomName: string) => Promise; + /** Answers the current incoming call and joins its room. */ + answerCall: () => Promise; + /** + * Ends or rejects the current call — dismisses CallKit, leaves the room, and + * resets state back to `available`. + */ + endCall: () => Promise; +}; + +export const VoipContext = createContext(null); + +/** + * Returns the current {@link VoipContextValue}. + * Must be used within a {@link VoipProvider}. + */ +export function useVoip(): VoipContextValue { + const ctx = useContext(VoipContext); + if (!ctx) throw new Error('useVoip must be used within VoipProvider'); + return ctx; +} diff --git a/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx b/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx new file mode 100644 index 000000000..1ecb51795 --- /dev/null +++ b/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx @@ -0,0 +1,178 @@ +import { + useCallKit, + useCamera, + useConnection, + useMicrophone, + usePeers, + useVoIPEvents, + type VoipIncomingPayload, +} from '@fishjam-cloud/react-native-client'; +import { + type PropsWithChildren, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; + +import { + type CurrentCall, + type VoipCallStatus, + VoipContext, +} from './VoipContext'; + +type VoipProviderProps = PropsWithChildren & { + /** + * Returns a Fishjam peer token for the given room. Invoked when joining a room + * on call start/answer. It should wrap a method that calls your backend to get the peer token for a given room. + * Make sure to pass the correct params when obtaining the peer token, such as the room name, the peer name, and the room type. + */ + getPeerToken: (roomName: string) => Promise; + /** + * Asks your signaling backend to ring `to` in `roomName`. Invoked when starting + * an outgoing call, before joining the room. + */ + requestCall: (params: { + to: string; + roomName: string; + isVideo: boolean; + }) => Promise; + /** + * Whether outgoing calls are video calls — reflected in the CallKit session. + * Make sure the underlying room type is set accordingly. Defaults to `false` (audio-only). + */ + isVideo?: boolean; +}; + +/** + * Tracks the current VoIP call state (driven by {@link useVoIPEvents}) and drives + * the Fishjam connection — joining the room on answer, leaving it on end. Exposes + * the call state and controls through {@link useVoip}. + * + * Render it inside a `FishjamProvider` so it can reach the Fishjam connection. + */ +export function VoipProvider({ + getPeerToken, + requestCall, + isVideo = false, + children, +}: VoipProviderProps) { + const [voipToken, setVoipToken] = useState(null); + const [status, setStatus] = useState('available'); + const [currentCall, setCurrentCall] = useState(null); + const { startCamera, stopCamera } = useCamera(); + const { startMicrophone, stopMicrophone } = useMicrophone(); + const { joinRoom, leaveRoom } = useConnection(); + const { startCallKitSession, endCallKitSession } = useCallKit(); + const { remotePeers } = usePeers(); + + const handleJoinRoom = useCallback( + async (roomName: string) => { + const token = await getPeerToken(roomName); + if (isVideo) { + await startCamera(); + } + await startMicrophone(); + await joinRoom({ peerToken: token }); + }, + [getPeerToken, joinRoom, startMicrophone, startCamera, isVideo], + ); + + const handleLeaveRoom = useCallback(async () => { + await stopCamera(); + await stopMicrophone(); + await leaveRoom(); + }, [leaveRoom, stopCamera, stopMicrophone]); + + const endCall = useCallback(async () => { + await endCallKitSession(); + await handleLeaveRoom(); + setCurrentCall(null); + setStatus('available'); + }, [endCallKitSession, handleLeaveRoom]); + + const startCall = useCallback( + async (to: string, roomName: string) => { + setCurrentCall({ + roomName, + displayName: to, + isVideo, + startedAt: null, + }); + setStatus('connecting'); + + try { + await requestCall({ to, roomName, isVideo }); + await startCallKitSession({ displayName: to, isVideo }); + await handleJoinRoom(roomName); + } catch (err) { + console.error('Failed to start call:', err); + await endCall(); + } + }, + [requestCall, handleJoinRoom, startCallKitSession, isVideo], + ); + + const answerCall = useCallback(async () => { + if (!currentCall) return; + + setStatus('connecting'); + try { + await handleJoinRoom(currentCall.roomName); + } catch (err) { + console.error('Failed to join room on answer:', err); + await endCall(); + } + }, [handleJoinRoom, endCall, currentCall]); + + useVoIPEvents({ + onRegistered: useCallback((token: string) => { + setVoipToken(token); + }, []), + + onIncoming: useCallback((payload: VoipIncomingPayload) => { + setCurrentCall({ + roomName: payload.roomName, + displayName: payload.displayName, + isVideo: payload.isVideo, + startedAt: null, + }); + setStatus('incoming'); + }, []), + + onAnswered: useCallback(async () => { + await answerCall(); + }, [answerCall]), + + onEnded: useCallback(async () => { + await endCall(); + }, [endCall]), + }); + + useEffect(() => { + if (status === 'connecting' && remotePeers.length > 0) { + setCurrentCall((prev) => + prev ? { ...prev, startedAt: Date.now() } : prev, + ); + setStatus('active'); + } else if (status === 'active' && remotePeers.length === 0) { + endCall().catch((err) => console.error('Failed to end call:', err)); + } + }, [remotePeers.length, status, endCall]); + + const voipValue = useMemo( + () => ({ + voipToken, + status, + currentCall, + startCall, + answerCall, + endCall, + }), + [voipToken, status, currentCall, startCall, answerCall, endCall], + ); + + return ( + {children} + ); +} diff --git a/examples/mobile-client/voip-call/app/src/voip/index.ts b/examples/mobile-client/voip-call/app/src/voip/index.ts new file mode 100644 index 000000000..3848ba68a --- /dev/null +++ b/examples/mobile-client/voip-call/app/src/voip/index.ts @@ -0,0 +1,7 @@ +export { useVoip } from './VoipContext'; +export type { + CurrentCall, + VoipCallStatus, + VoipContextValue, +} from './VoipContext'; +export { VoipProvider } from './VoipProvider'; diff --git a/examples/mobile-client/voip-call/server/.env.example b/examples/mobile-client/voip-call/server/.env.example new file mode 100644 index 000000000..e69de29bb diff --git a/examples/mobile-client/voip-call/server/.gitignore b/examples/mobile-client/voip-call/server/.gitignore new file mode 100644 index 000000000..d04f59082 --- /dev/null +++ b/examples/mobile-client/voip-call/server/.gitignore @@ -0,0 +1,2 @@ +voip.db +apns.pem \ 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 index d770b4ee5..f735facea 100644 --- a/examples/mobile-client/voip-call/server/README.md +++ b/examples/mobile-client/voip-call/server/README.md @@ -5,3 +5,40 @@ ```bash deno task start # listens on :4400 ``` + +## 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 +``` + +## API + +| Method | Path | Body / Query | Description | +|--------|-----------------------|-----------------------------------|------------------------------------------| +| POST | `/register` | `{ username, voipToken }` | 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 | + +## APNs VoIP push payload + +The push payload forwarded to the callee's device: + +```json +{ + "roomName": "", + "displayName": "", + "isVideo": false +} +``` + +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. diff --git a/examples/mobile-client/voip-call/server/deno.json b/examples/mobile-client/voip-call/server/deno.json index a5352894c..3e96824b3 100644 --- a/examples/mobile-client/voip-call/server/deno.json +++ b/examples/mobile-client/voip-call/server/deno.json @@ -1,6 +1,9 @@ { "tasks": { - "start": "deno run --allow-net main.ts" + "start": "deno run --allow-net --allow-read --allow-env --allow-ffi main.ts" + }, + "imports": { + "@db/sqlite": "jsr:@db/sqlite@^0.12" }, "fmt": { "singleQuote": true diff --git a/examples/mobile-client/voip-call/server/deno.lock b/examples/mobile-client/voip-call/server/deno.lock new file mode 100644 index 000000000..cf1f95c51 --- /dev/null +++ b/examples/mobile-client/voip-call/server/deno.lock @@ -0,0 +1,81 @@ +{ + "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" + }, + "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" + ] + } + }, + "workspace": { + "dependencies": [ + "jsr:@db/sqlite@0.12" + ] + } +} diff --git a/examples/mobile-client/voip-call/server/main.ts b/examples/mobile-client/voip-call/server/main.ts index 22397445a..82e14f8f9 100644 --- a/examples/mobile-client/voip-call/server/main.ts +++ b/examples/mobile-client/voip-call/server/main.ts @@ -1,23 +1,120 @@ -const json = (data: unknown) => Response.json(data); +import { Database } from "@db/sqlite"; -Deno.serve({ port: 4400 }, (req) => { - const url = new URL(req.url); +const db = new Database("voip.db"); - if (req.method === 'POST' && url.pathname === '/register') { - return json({ ok: true }); - } - if (req.method === 'GET' && url.pathname === '/users') { - return json([]); +// TODO: voip_token should be primary key but for testing it's easier if i do username :D +db.exec(` + CREATE TABLE IF NOT EXISTS users ( + username TEXT NOT NULL PRIMARY KEY, + voip_token TEXT NOT NULL, + updated_at INTEGER NOT NULL + ) +`); + +// --- APNs VoIP push (certificate-based) --- + +const BUNDLE_ID = "io.fishjam.example.voipcall"; +const APNS_HOST = "api.development.push.apple.com"; + +const apnsPem = await Deno.readTextFile("./apns.pem"); +const apnsClient = Deno.createHttpClient({ cert: apnsPem, key: apnsPem }); + +async function sendVoipPush(params: { + voipToken: string; + roomName: string; + displayName: string; + isVideo: boolean; +}): Promise { + const res = await fetch(`https://${APNS_HOST}/3/device/${params.voipToken}`, { + 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, + }), + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`APNs push failed ${res.status}: ${text}`); } - if (req.method === 'POST' && url.pathname === '/call') { +} + +// --- Helpers --- + +const json = (data: unknown, status = 200) => Response.json(data, { status }); + +// --- Route handler --- + +Deno.serve({ port: 4400 }, async (req) => { + const url = new URL(req.url); + console.log(`${req.method} ${url.pathname}`); + + // POST /register { username, voipToken } + if (req.method === "POST" && url.pathname === "/register") { + const { username, voipToken } = (await req.json()) as { + username: string; + voipToken: string; + }; + if (!username || !voipToken) { + return json({ error: "username and voipToken are required" }, 400); + } + db.exec( + `INSERT INTO users (username, voip_token, updated_at) VALUES (?, ?, ?) + ON CONFLICT(username) DO UPDATE SET voip_token=excluded.voip_token, updated_at=excluded.updated_at`, + [username, voipToken, Date.now()], + ); return json({ ok: true }); } - if (req.method === 'GET' && url.pathname === '/incoming') { - return json(null); + + // GET /users?exclude= + // TODO: we'll have to omit by token, not username ! + if (req.method === "GET" && url.pathname === "/users") { + const exclude = url.searchParams.get("exclude") ?? ""; + const rows = db.sql<{ username: string }>` + SELECT username FROM users WHERE username != ${exclude} ORDER BY username + `; + return json(rows.map((r) => r.username)); } - if (req.method === 'POST' && url.pathname === '/cancel') { + + // 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 }>` + SELECT voip_token FROM users WHERE username = ${to} + `; + if (calleeRows.length === 0) + return json({ error: "callee not found" }, 404); + const voipToken = calleeRows[0].voip_token; + + try { + await sendVoipPush({ + voipToken, + roomName: roomName, + displayName: from, + isVideo: isVideo, + }); + } catch (err) { + console.error("Failed to send VoIP push:", err); + return json({ error: "failed to send VoIP push" }, 502); + } + return json({ ok: true }); } - return new Response('Not found', { status: 404 }); + return new Response("Not found", { status: 404 }); }); diff --git a/packages/mobile-client/src/index.ts b/packages/mobile-client/src/index.ts index 14654e76d..37b97b2b0 100644 --- a/packages/mobile-client/src/index.ts +++ b/packages/mobile-client/src/index.ts @@ -23,8 +23,11 @@ export { stopPIP, AudioDeviceType, useAudioOutput, + useVoIPEvents, } from '@fishjam-cloud/react-native-webrtc'; +export type { VoIPEventHandlers, VoipIncomingPayload } from '@fishjam-cloud/react-native-webrtc'; + export type { CallKitAction, CallKitConfig, diff --git a/packages/react-native-webrtc b/packages/react-native-webrtc index 6b994c06a..84c9dbbe4 160000 --- a/packages/react-native-webrtc +++ b/packages/react-native-webrtc @@ -1 +1 @@ -Subproject commit 6b994c06a0065ef7e4c8f0169fb95a15303b3266 +Subproject commit 84c9dbbe45936ac86ff08fc4ba69f4c0dfaed44e diff --git a/yarn.lock b/yarn.lock index 1301d3567..5b7f19074 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5188,6 +5188,18 @@ __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" @@ -12222,6 +12234,13 @@ __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" @@ -15787,6 +15806,17 @@ __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.2.1 resolution: "react-native-is-edge-to-edge@npm:1.2.1" @@ -18914,12 +18944,15 @@ __metadata: resolution: "voip-call@workspace:examples/mobile-client/voip-call/app" dependencies: "@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-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