diff --git a/api/fishjam-server b/api/fishjam-server index 93b3d30c..339220ff 160000 --- a/api/fishjam-server +++ b/api/fishjam-server @@ -1 +1 @@ -Subproject commit 93b3d30cd8dd465d5e6876935772d95b723db06a +Subproject commit 339220ff517c5be2e1e54fce8d1b415b59a5f67c diff --git a/api/protos b/api/protos index 50aacf98..85ccd2bb 160000 --- a/api/protos +++ b/api/protos @@ -1 +1 @@ -Subproject commit 50aacf9839c7e1f67e983f775f04206050b99cee +Subproject commit 85ccd2bbb82ed5a5528c2dba836b05e64f62424f diff --git a/api/room-manager b/api/room-manager index 1875d21d..669f4aa1 160000 --- a/api/room-manager +++ b/api/room-manager @@ -1 +1 @@ -Subproject commit 1875d21dfab7861ef6fb2709468dad00f1731b84 +Subproject commit 669f4aa1b223bca7e257533bd0233751319d8c23 diff --git a/docs/how-to/client/background-streaming.mdx b/docs/how-to/client/background-streaming.mdx index 4845d7c7..440c2cca 100644 --- a/docs/how-to/client/background-streaming.mdx +++ b/docs/how-to/client/background-streaming.mdx @@ -227,3 +227,5 @@ useCallKitEvent("ended", () => { ## See Also For an enhanced user experience when your app is in the background, consider enabling [Picture in Picture](./picture-in-picture), which allows users to see video content in a floating window while using other apps. + +To surface an incoming call as a native CallKit (iOS) or Telecom (Android) call driven by a push notification, see [VoIP calls](./voip-calls). diff --git a/docs/how-to/client/ios-simulator-camera.mdx b/docs/how-to/client/ios-simulator-camera.mdx index 8c803bb8..9f2a1ffe 100644 --- a/docs/how-to/client/ios-simulator-camera.mdx +++ b/docs/how-to/client/ios-simulator-camera.mdx @@ -1,5 +1,5 @@ --- -sidebar_position: 17 +sidebar_position: 18 sidebar_label: "iOS Simulator Camera 📱" description: Use SimCam to test camera features in the iOS Simulator without a physical device. --- diff --git a/docs/how-to/client/migration-guide.mdx b/docs/how-to/client/migration-guide.mdx index 57840793..fccd3d58 100644 --- a/docs/how-to/client/migration-guide.mdx +++ b/docs/how-to/client/migration-guide.mdx @@ -1,6 +1,6 @@ --- title: "0.25.x Migration Guide" -sidebar_position: 14 +sidebar_position: 15 sidebar_label: "0.25.x Migration Guide 📱" description: Upgrade your React Native app from @fishjam-cloud/react-native-client 0.24.x to 0.25.x. --- diff --git a/docs/how-to/client/simulcast.mdx b/docs/how-to/client/simulcast.mdx index 6d253553..5be9961b 100644 --- a/docs/how-to/client/simulcast.mdx +++ b/docs/how-to/client/simulcast.mdx @@ -1,6 +1,6 @@ --- title: Simulcast -sidebar_position: 15 +sidebar_position: 16 description: Enable multi-quality video streaming and let receivers choose their preferred quality variant. --- diff --git a/docs/how-to/client/text-chat.mdx b/docs/how-to/client/text-chat.mdx index c629f865..ac69acd3 100644 --- a/docs/how-to/client/text-chat.mdx +++ b/docs/how-to/client/text-chat.mdx @@ -1,6 +1,6 @@ --- title: Text Chat -sidebar_position: 16 +sidebar_position: 17 description: Implement peer-to-peer text chat in your application using Fishjam data channels. --- diff --git a/docs/how-to/client/voip-calls.mdx b/docs/how-to/client/voip-calls.mdx new file mode 100644 index 00000000..8275ef1d --- /dev/null +++ b/docs/how-to/client/voip-calls.mdx @@ -0,0 +1,789 @@ +--- +title: "VoIP calls" +sidebar_position: 14 +sidebar_label: "VoIP calls 📱" +description: Receive and place native VoIP calls with CallKit on iOS and Telecom on Android, driven by push notifications, in a React Native app. +--- + +import TabItem from "@theme/TabItem"; +import Tabs from "@theme/Tabs"; + +# VoIP calls Mobile + +:::note +This guide is exclusively for **Mobile** (React Native) applications. +::: + +Your app can ring like the phone app: an incoming call shows up as a **native call**, +full-screen on a locked phone or as a call notification while the phone is in use, +even when the app is backgrounded or killed. A push notification wakes the app, and +answering the call has it join a Fishjam room, where the call takes place. + +Each platform uses its own transport: + +- **iOS**: a **VoIP push** over **APNs** wakes the app through **PushKit**, and the + SDK reports the call to **CallKit**. +- **Android**: a **high-priority data message** over **FCM** wakes the app, and the + SDK surfaces the call through **Telecom**. + +**PushKit**, **CallKit**, and **Telecom** are the operating systems' own native VoIP +frameworks. The SDK drives them for you, so a single JS API works on both platforms. +Registering calls with the OS also gets you **remote surface control** for free: the +user can handle a call from a Bluetooth headset or Android Auto, and those actions +reach your app as the same state changes described in this guide. + +:::tip[Working example] +Everything in this guide is wired together in the +[voip-call example](https://github.com/fishjam-cloud/web-client-sdk/tree/main/examples/mobile-client/voip-call), +whose README covers running it, including the APNs and FCM push credentials you must +generate yourself. +::: + +:::info[You need a push sender] +Delivering the push is your backend's job: it sends a **VoIP push via APNs** (iOS) +or a **high-priority data message via FCM** (Android) to the device token your app +reports. This guide covers only the **app** side: wiring, native config, and the JS +call flow. For a working sender, see our +[voip-call example server](https://github.com/fishjam-cloud/web-client-sdk/tree/main/examples/mobile-client/voip-call/server). + +Wiring up the sending credentials is backend work too. For iOS, connect to APNs with a +[certificate-based](https://developer.apple.com/documentation/usernotifications/establishing-a-certificate-based-connection-to-apns) +or [token-based](https://developer.apple.com/documentation/usernotifications/establishing-a-token-based-connection-to-apns) +connection; for Android, +[authorize your server to send via FCM](https://firebase.google.com/docs/cloud-messaging/auth-server). +::: + +## How a call flows + +1. Your backend sends a VoIP push to the callee's device. +2. The OS wakes the app, even if it was killed, and the SDK reports the call to + CallKit / Telecom. `status` becomes `incoming`. +3. The user answers on the **system** call UI. `status` becomes `connecting`. +4. **Your app** joins the Fishjam room and waits for the remote media. +5. Once media is live, **your app** calls `reportConnected()`. The SDK confirms the + connection to the OS, the call timer starts, and `status` becomes `active`. + +The SDK tracks the call's state and drives the native call UI; **joining the room and +reporting back is your app's job**. Step 5 has a **deadline**: an answered call that +isn't reported connected within `voip.fulfillAnswerCallTimeout` (10s by default) is +ended by the OS. + +## 1. Install the SDK + +Install the React Native SDK as described in the +[Installation guide](./installation#step-1-install-the-package). The exact packages +differ between Expo and bare React Native. + +On **Expo**, also install [`@fishjam-cloud/ios-expo-voip`](https://www.npmjs.com/package/@fishjam-cloud/ios-expo-voip) +(**iOS only**). It registers PushKit at launch; without that registration the app never +receives VoIP pushes at all: + +```bash +npx expo install @fishjam-cloud/ios-expo-voip +``` + +## 2. Configure the native project + + + + +Configure the Fishjam config plugin in your app's `app.json`: turn on iOS background +mode and Android VoIP, and (optionally) tune the timeouts in the `voip` block: + +```json title="app.json" +{ + "expo": { + "plugins": [ + [ + "@fishjam-cloud/react-native-client", + { + "android": { + "enableVoip": true + }, + "ios": { + "enableVoIPBackgroundMode": true + }, + "voip": { + "incomingCallTimeout": 15, + "outgoingCallTimeout": 20, + "fulfillAnswerCallTimeout": 5 + } + } + ] + ] + } +} +``` + +What matters is that the `voip` block is **present**. That's the single switch for +Recents and the intent wiring. It wires up `@fishjam-cloud/ios-expo-voip` and puts calls +in the Phone app's **Recents** with [redial](#10-redial-from-recents-ios); there's no +separate option to turn on. Every value inside it is optional, so `"voip": {}` is enough +to take the defaults: **45s** incoming ring, **60s** unconnected outgoing, and **10s** +for the answer handshake. Timeouts are positive seconds; a fractional value is floored, +and a zero or negative one fails `expo prebuild`. + +Because the block is the switch, setting it without +`@fishjam-cloud/ios-expo-voip` installed fails `expo prebuild` outright, with +`Fishjam VoIP options are enabled but @fishjam-cloud/ios-expo-voip is not installed`. + +**iOS push entitlement.** iOS only issues a VoIP token to an app that declares +the [`aps-environment`](https://developer.apple.com/documentation/bundleresources/entitlements/aps-environment) +push entitlement, so add it in `app.json`: + +```json title="app.json" +{ + "expo": { + "ios": { + "entitlements": { "aps-environment": "development" } + } + } +} +``` + +Use `"production"` for TestFlight / App Store builds. For the underlying iOS +frameworks, see Apple's [PushKit](https://developer.apple.com/documentation/pushkit) +(VoIP pushes) and [CallKit](https://developer.apple.com/documentation/callkit). + +**Android Firebase setup.** VoIP on Android is delivered over +[Firebase Cloud Messaging](https://firebase.google.com/docs/cloud-messaging). Point +Expo at your `google-services.json` so Firebase is wired into the build: + +```json title="app.json" +{ + "expo": { + "android": { + "package": "io.example.myapp", + "googleServicesFile": "./google-services.json" + } + } +} +``` + +Download `google-services.json` from the [Firebase console](https://console.firebase.google.com/) +and save it at your project +root. Its `package_name` must match `android.package` exactly. See +[Add Firebase to your Android project](https://firebase.google.com/docs/android/setup) +for the full Firebase setup, and Android's +[Telecom framework](https://developer.android.com/develop/connectivity/telecom) for +the call APIs. + +:::warning[Both Android fields are required together] +`enableVoip: true` **and** `googleServicesFile` must both be set. If `enableVoip` +is on but `googleServicesFile` is missing, `expo prebuild` **succeeds** yet +Firebase is never wired up and pushes silently never arrive. If Android calls +don't ring, check this first. +::: + + + + +Config plugins only run during `expo prebuild`. In a bare project you own the +native directories, so apply the changes by hand. + +**iOS** + +Register [PushKit](https://developer.apple.com/documentation/pushkit) at launch in +`AppDelegate.swift`. A push can wake the app before any JS runs, so this must happen +at launch: + +```swift title="AppDelegate.swift" +// in application(_:didFinishLaunchingWithOptions:) +VoipManager.registerForVoIPPushes() +``` + +Expose the pod's Objective-C class to Swift through the target's +[bridging header](https://developer.apple.com/documentation/swift/importing-objective-c-into-swift). +An app with a Swift `AppDelegate` already has one at `ios//-Bridging-Header.h`; +add the import to it: + +```objc title="myapp-Bridging-Header.h" +#import "VoipManager.h" +``` + +If `"VoipManager.h"` doesn't resolve, use the pod-qualified form +`#import `. + +Add the push entitlement ([`aps-environment`](https://developer.apple.com/documentation/bundleresources/entitlements/aps-environment)) +and the VoIP background mode. In Xcode → _Signing & Capabilities_ these are +**Push Notifications** and **Background Modes → Voice over IP**: + +```xml title="Info.plist" +UIBackgroundModes + + voip + + +NSMicrophoneUsageDescription +Allow $(PRODUCT_NAME) to access your microphone. +``` + +Adding the **Push Notifications** capability writes the entitlement for you; if you +edit the file directly it must contain: + +```xml title="myapp.entitlements" +aps-environment +development +``` + +Use `production` for TestFlight / App Store builds. + +**Android** + +Apply the [`google-services`](https://firebase.google.com/docs/android/setup#add-config-file) +Gradle plugin to the **application** module and place `google-services.json` at +`android/app/google-services.json`: + +```groovy title="android/build.gradle" +buildscript { + dependencies { + classpath 'com.google.gms:google-services:4.4.1' + } +} +``` + +```groovy title="android/app/build.gradle" +apply plugin: 'com.google.gms.google-services' +``` + +Then declare the SDK's VoIP components in `AndroidManifest.xml`: + +```xml title="android/app/src/main/AndroidManifest.xml" + + + + + + + + + + + + + + + + + + + + + + + + +``` + +The `firebase_messaging_installation_id_enabled` flag is what makes FCM register the app +instance and hand back the [push token](#4-report-the-device-push-token). + +**Timeouts (bare only).** The `voip` timeouts the Expo tab configures are plain native +values, so set them directly. On iOS add integers to `Info.plist`; on Android add the +same names as ``, both in seconds: + +```xml title="Info.plist" +VoipIncomingCallTimeout15 +VoipOutgoingCallTimeout20 +VoipFulfillAnswerTimeout5 +``` + + + + +:::warning[Already using another FCM push library on Android?] +Android delivers every FCM message to a **single** messaging service. The SDK's +service claims that slot, so an app that also uses `expo-notifications` or +`@react-native-firebase/messaging` would otherwise stop receiving its other pushes. +Name that library so the SDK relays non-VoIP messages to it: + +```json title="app.json" +[ + "@fishjam-cloud/react-native-client", + { + "android": { + "enableVoip": true, + "voipFallbackMessagingService": "expo-notifications" + } + } +] +``` + +Accepted values are `"expo-notifications"`, `"@react-native-firebase/messaging"`, +or a fully-qualified `FirebaseMessagingService` class name. iOS is unaffected: VoIP +pushes ride PushKit's separate channel. + +If you'd rather own the messaging service yourself, set +`"voipMessagingService": false` instead. The SDK then declares no service at all, and +you forward calls to it from your own dispatcher with +`PushNotificationService.handleVoipMessage(context, message)` and +`PushNotificationService.handleNewToken(token)`. +::: + +:::note[Permissions] +A call still needs the camera and microphone. Configure those as in the +[Installation guide](./installation#step-2-configure-app-permissions), and request +them at runtime with [`useMicrophonePermissions`](../../api/mobile/functions/useMicrophonePermissions) +/ [`useCameraPermissions`](../../api/mobile/functions/useCameraPermissions) (see +[Managing devices](./managing-devices#managing-permissions-mobile-only)). On +**Android 13+** you must additionally request `POST_NOTIFICATIONS`, or the +incoming-call notification can't be posted: + +```ts +import { PermissionsAndroid, Platform } from "react-native"; +// ---cut--- +async function requestNotificationPermission() { + if (Platform.OS === "android" && Number(Platform.Version) >= 33) { + await PermissionsAndroid.request( + PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS, + ); + } +} +``` + +::: + +## 3. Mount the VoIP provider + +[`VoipProvider`](../../api/mobile/functions/VoipProvider) tracks the call's state from +the CallKit / Telecom events and exposes it through +[`useVoip`](../../api/mobile/functions/useVoip). It doesn't touch the connection: joining +rooms, peer tokens, and media stay in your app (that's [step 5](#5-connect-calls-to-a-room)). + +Mount it alongside [`FishjamProvider`](../../api/mobile/functions/FishjamProvider). The +order doesn't matter to `VoipProvider`, but the hook that bridges the two (step 5) calls +both `useVoip` and the Fishjam connection hooks, so it must sit **inside both**: + +```tsx +import React, { type ReactElement } from "react"; +import { + FishjamProvider, + VoipProvider, +} from "@fishjam-cloud/react-native-client"; + +declare const fishjamId: string; +declare function App(): ReactElement; +// ---cut--- +function Root() { + return ( + + + + + + ); +} +``` + +[`VoipProviderProps`](../../api/mobile/type-aliases/VoipProviderProps) accepts: + +| Prop | Type | Required | Purpose | +| ----------------------- | ---------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `isVideo` | `boolean` | — | Marks outgoing calls as video, which registers the CallKit / Telecom session as video and labels the Android call notification. Incoming calls take their label from the push payload's [`isVideo`](#6-handle-an-incoming-call) instead. Make sure the room type matches. Default `false`. | +| `onWaitingCallDeclined` | `(payload: VoipIncomingPayload) => void` | — | A second, waiting call was declined from the native UI. Signal the caller; local state is unchanged. | + +## 4. Report the device push token + +[`useVoip`](../../api/mobile/functions/useVoip) exposes the device's push token as +`voipToken`: the VoIP token from APNs on iOS, the FCM token on Android. Send it to your +backend; it's the push destination: + +```tsx +import { useVoip } from "@fishjam-cloud/react-native-client"; +import { useEffect } from "react"; +import { Platform } from "react-native"; + +declare function sendVoipTokenToBackend(token: string, platform: string): void; +// ---cut--- +function DeviceRegistration() { + const { voipToken } = useVoip(); // [!code highlight] + + useEffect(() => { + if (!voipToken) return; + // Persist this token on your backend, keyed by the signed-in user. + sendVoipTokenToBackend(voipToken, Platform.OS); + }, [voipToken]); + + return null; +} +``` + +A token is only valid with the service that issued it, so record each device's platform +at registration time and route through APNs or FCM accordingly. Both platforms reissue +tokens over time, so store the newest value per device rather than the first one you +saw. + +## 5. Connect calls to a room + +This is the bridge between the two halves. `VoipProvider` tells you a call needs a room; +your app joins it and reports back. Split the work like this: + +**The provider tells you** (through `useVoip`): + +- `status` is `connecting`: join `currentCall.roomName` (see + [`CurrentCall`](../../api/mobile/type-aliases/CurrentCall)). +- `status` left `connecting` / `active`: leave that room. +- `isOnHold` or `isMuted` changed: apply it to your tracks ([step 9](#9-hold-and-mute)). + +**You tell the provider:** + +- `reportConnected()` once media is live, or `reportConnectFailed()` if the join failed. +- `endCall('remote')` when the other side disappears from the room. + +A remote peer appearing in the room is what "media is live" means, so watch +[`usePeers`](../../api/mobile/functions/usePeers) and report from there. Start your +microphone (and camera, for a video call) **before** joining, so your tracks are +published with the join; skip it and the call connects silent. Mint the peer token +however your backend does; the SDK never sees it: + +```tsx +import { + useConnection, + useMicrophone, + usePeers, + useVoip, +} from "@fishjam-cloud/react-native-client"; +import { useEffect } from "react"; + +declare function getPeerToken(roomName: string): Promise; +// ---cut--- +function useVoipRoomConnection() { + const { status, currentCall, reportConnected, reportConnectFailed, endCall } = + useVoip(); + const { joinRoom, leaveRoom } = useConnection(); + const { startMicrophone, stopMicrophone } = useMicrophone(); + const { remotePeers } = usePeers(); + + // Join the room the SDK is asking for; leave it when the call ends. + const targetRoom = + status === "connecting" || status === "active" + ? currentCall?.roomName + : undefined; + + useEffect(() => { + if (!targetRoom) return; + let cancelled = false; + + (async () => { + try { + const peerToken = await getPeerToken(targetRoom); + if (cancelled) return; + await startMicrophone(); // publish media with the join // [!code highlight] + await joinRoom({ peerToken }); // [!code highlight] + } catch { + if (!cancelled) await reportConnectFailed(); // [!code highlight] + } + })(); + + return () => { + cancelled = true; + void stopMicrophone(); + void leaveRoom(); + }; + }, [targetRoom]); + + // A remote peer showing up means the call connected; it leaving means they hung up. + useEffect(() => { + if (status === "connecting" && remotePeers.length > 0) { + void reportConnected(); // [!code highlight] + } else if (status === "active" && remotePeers.length === 0) { + void endCall("remote"); + } + }, [status, remotePeers.length]); +} +``` + +Mount it once, anywhere inside both providers. + +:::warning[Report connected before the deadline] +`reportConnected()` isn't optional; it fulfills CallKit's answer action and starts the +call timer. The native fulfil deadline (`voip.fulfillAnswerCallTimeout`, 10s by default) +covers your token fetch **and** room join, so be quick. Miss it and the OS ends the +call. +::: + +## 6. Handle an incoming call + +A push wakes the app, the system UI starts ringing, and `useVoip` moves `status` to +`'incoming'` with [`currentCall`](../../api/mobile/type-aliases/CurrentCall) populated. +**Answering happens on the system call UI** (the CallKit screen or Android's full-screen +activity), not from a button you build. On answer, `status` moves to `'connecting'` and +the hook from [step 5](#5-connect-calls-to-a-room) joins the room and reports back; +you don't wire the accept path yourself. + +You can still render your own screen off `status` while a call rings (to match your app's +look), and decline from it with `endCall('rejected')`: + +```tsx +import React, { type ReactElement } from "react"; +import { useVoip } from "@fishjam-cloud/react-native-client"; + +declare function IncomingCallScreen(props: { + name?: string; + onReject: () => void; +}): ReactElement; +// ---cut--- +function CallUI() { + const { status, currentCall, endCall } = useVoip(); + // status: 'available' | 'incoming' | 'connecting' | 'active' // [!code highlight] + + if (status === "incoming") { + return ( + endCall("rejected")} // [!code highlight] + /> + ); + } + return null; +} +``` + +:::note[What your backend must put in the push] +The push carries the call fields the SDK reads, as a +[`VoipIncomingPayload`](../../api/mobile/type-aliases/VoipIncomingPayload): + +| Field | Required | Purpose | +| ------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `roomName` | ✅ | The Fishjam room both parties join. Android drops a payload without it; iOS still rings, then throws in JS, so always send it. | +| `displayName` | — | Label shown in the system call UI. Both platforms fall back to **"Incoming call"**. | +| `handle` | — | Stable id of the caller; falls back to `displayName`. This is what iOS Recents hands back for redial, so use a real user id, since a display name isn't unique. | +| `isVideo` | — | Labels the incoming call as video; Android shows "Incoming video call" instead of "Incoming call". Defaults to `false`. | +| `avatarUrl` | — | Android renders it in the incoming-call UI. iOS delivers it to JS only, since CallKit can't show caller images. | + +::: + +:::warning[Android: send it as a high-priority data message] +On Android the push must be a **data message** carrying the discriminator +`"fishjam": "voip-incoming"`, or the SDK won't treat it as a call. Send it with +[high priority](https://firebase.google.com/docs/cloud-messaging/android/message-priority). +For the exact message our example server sends, see +[`main.ts`](https://github.com/fishjam-cloud/web-client-sdk/blob/main/examples/mobile-client/voip-call/server/main.ts). +::: + +## 7. Place an outgoing call + +You mint the room name and ring the callee through **your own** signaling backend, then +call `startCall(to, roomName)`. `startCall` only registers the call with CallKit / +Telecom and moves `status` to `'connecting'`. It doesn't ring anyone and doesn't join; +the hook from [step 5](#5-connect-calls-to-a-room) joins once `status` is `connecting`, +exactly as it does for an incoming call. The order is yours to choose, and doing signaling +first means a failed ring never shows a call that can't connect: + +```tsx +import { useVoip } from "@fishjam-cloud/react-native-client"; +import { useCallback } from "react"; + +declare function ringCallee(to: string, roomName: string): Promise; +declare function makeRoomName(): string; +// ---cut--- +function usePlaceCall() { + const { startCall } = useVoip(); + + return useCallback( + async (to: string) => { + const roomName = makeRoomName(); // you own the room name + await ringCallee(to, roomName); // your signaling rings the callee + await startCall(to, roomName); // [!code highlight] registers the native call, status → 'connecting' + }, + [startCall], + ); +} +``` + +`to` is the callee's stable id, the value your backend looks up to decide who to ring. It +only affects the **caller's** own device, where the SDK uses it as both the handle and +the display name, so a raw user id shows up verbatim on their call screen. It never +reaches the callee: the name **they** see is the +[`displayName`](#6-handle-an-incoming-call) your backend puts in the push. + +If `startCall`'s native session won't start, the provider ends the call as `failed` and +`status` returns to `'available'`. A failed room join is reported by your hook's +`reportConnectFailed()` (step 5), which ends it the same way. + +## 8. End a call + +A call ends from several places: the system call UI, your room-connection hook +([step 5](#5-connect-calls-to-a-room)) when the remote peer disappears, or your own +in-call button. To hang up yourself: + +```tsx +import { useVoip } from "@fishjam-cloud/react-native-client"; +// ---cut--- +const { endCall } = useVoip(); +await endCall(); // defaults to 'local' +``` + +`endCall` takes a [`CallEndedReason`](../../api/mobile/type-aliases/CallEndedReason) +saying how the call ended. Whatever ends the call (your `endCall`, the system UI, or your +hook passing `'remote'` when the peer drops) sets `lastEndedReason`, always **from your +device's point of view**: whether you ended it (`'local'`) or the other side did +(`'remote'`), and why. Read it to react to a missed or rejected call, for example with a +"missed call" notification: + +| Reason | Meaning | +| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `local` | This device hung up. Also covers a decline on iOS, which CallKit can't distinguish. | +| `rejected` | The callee actively declined while ringing. Safe to pass on both, but CallKit has no case for it, so iOS ends the call exactly as `local`, and only Android reports it back distinctly. | +| `missed` | Rang and was never answered, including a native ring timeout. | +| `remote` | The other party hung up. | +| `answeredElsewhere` | Picked up on another of the user's devices. | +| `failed` | Setup failed: token fetch, room join, or the answer deadline. | + +A push can't express a caller canceling or a callee rejecting, so map those across +devices over your own signaling channel: when the caller cancels while ringing, the +callee should end as `missed`; when the callee rejects, the caller should end as +`rejected`. Reserve `remote` for the other party hanging up a call that was already +connected. + +## 9. Hold and mute + +The OS holds your call when a cellular call arrives, and mirrors the system call UI's +mute button. `useVoip` **reports** both as `isOnHold` and `isMuted`, but doesn't touch +your tracks. Applying them is your job, alongside the room connection from +[step 5](#5-connect-calls-to-a-room). Watch the flags and drive your own media hooks: + +```tsx +import { useMicrophone, useVoip } from "@fishjam-cloud/react-native-client"; +import { useEffect } from "react"; +// ---cut--- +function useSyncMute() { + const { status, isMuted } = useVoip(); + const { isMicrophoneOn, toggleMicrophone } = useMicrophone(); + + useEffect(() => { + if (status !== "active") return; // only while a call is live // [!code highlight] + // Mirror the system mute onto your published track. + if (isMicrophoneOn === isMuted) void toggleMicrophone(); + }, [isMuted, status]); +} +``` + +Hold works the same way: when `isOnHold` flips on, stop the mic and camera and remember +what was live; when it flips off, restore exactly that. To request a hold from your **own** +button (rather than react to the OS), call `setCallHeld`, then act on `isOnHold` flipping: + +```tsx +import { useVoip } from "@fishjam-cloud/react-native-client"; +// ---cut--- +const { isOnHold, setCallHeld } = useVoip(); + +await setCallHeld(true); // request a hold; isOnHold flips when the OS applies it // [!code highlight] +``` + +In-call A/V beyond this is the normal Fishjam device API ([`useMicrophone`](../../api/mobile/functions/useMicrophone), +[`useCamera`](../../api/mobile/functions/useCamera)), exactly like any other Fishjam app. + +See [Managing devices](./managing-devices) for the full device API. + +## 10. Redial from Recents (iOS) + +Every call is recorded in the iOS Phone app's **Recents**, and tapping an entry reopens +your app. The tap arrives as `pendingCallIntent`, carrying only the `handle` to redial, never a +room. Mint a room and place the call exactly as in [step 7](#7-place-an-outgoing-call), +then `clearCallIntent()` so it isn't replayed: + +```tsx +import { useVoip } from "@fishjam-cloud/react-native-client"; +import { useEffect } from "react"; + +declare function usePlaceCall(): (to: string) => Promise; +declare function useSessionReady(): boolean; +// ---cut--- +function useRedialFromRecents() { + const { pendingCallIntent, clearCallIntent } = useVoip(); + const placeCall = usePlaceCall(); + const isReady = useSessionReady(); + + useEffect(() => { + if (!pendingCallIntent || !isReady) return; // [!code highlight] + + const { handle } = pendingCallIntent; + clearCallIntent(); + void placeCall(handle); + }, [pendingCallIntent, isReady, clearCallIntent, placeCall]); +} +``` + +The SDK **holds** `pendingCallIntent` until you clear it, so an intent that arrives +before your app has restored its session isn't lost. Gate on your own readiness (as +`isReady` does here) and act on it once you can. + +Enabling Recents itself is native setup: + + + + +The `voip` block from [step 2](#2-configure-the-native-project) is all this takes; there's +no option to enable. It sets the `FishjamVoipEnabled` flag and the CallKit intent activity +types for you. + + + + +You own the `Info.plist`, so wire up the two halves the Expo plugin sets for you. First, +the plist: `FishjamVoipEnabled` puts calls in Recents, and `NSUserActivityTypes` declares +that your app handles the "start call" [intents](https://developer.apple.com/documentation/sirikit/instartcallintent) +iOS hands back when someone taps a Recents entry. Without those types the tap opens your +app but no intent is delivered: + +```xml title="Info.plist" +FishjamVoipEnabled + + +NSUserActivityTypes + + INStartCallIntent + INStartAudioCallIntent + INStartVideoCallIntent + +``` + +Second, forward that intent to the SDK from your `AppDelegate`. The tap arrives as an +[`NSUserActivity`](https://developer.apple.com/documentation/foundation/nsuseractivity); +handing it to `VoipManager` is what turns it into the `pendingCallIntent` your JS reads +above. Do this **before** React Native's own Linking handler so it isn't consumed as a +deep link: + +```swift title="AppDelegate.swift" +// in application(_:continue:restorationHandler:), before React Native Linking +if VoipManager.handleContinueUserActivity(userActivity) { + return true +} +``` + + + + +## 11. Test it + +Test on a **real device**: VoIP pushes never reach the iOS Simulator, and Android needs +**Google Play services** for FCM. Then check the key paths: + +- Send a push and confirm the native call screen appears, even when the app is killed. +- Answer, and the call connects with the OS timer starting once media is live. +- Let a call ring out and confirm it ends as `missed`. +- On iOS, the call shows in **Recents** and tapping it redials. +- Take a cellular call with **Hold & Accept**: your mic and camera pause, then resume. + +## Next steps + +- [Managing devices](./managing-devices): control the camera and microphone during + a call. diff --git a/packages/python-server-sdk b/packages/python-server-sdk index 38afe6d8..fdd55700 160000 --- a/packages/python-server-sdk +++ b/packages/python-server-sdk @@ -1 +1 @@ -Subproject commit 38afe6d834788d9f5cde5ac7c38bd9d51019bd0b +Subproject commit fdd5570033f84a4e8f49bf2ac7fedc019b5b99df diff --git a/packages/web-client-sdk b/packages/web-client-sdk index 90ab3e9c..3340d396 160000 --- a/packages/web-client-sdk +++ b/packages/web-client-sdk @@ -1 +1 @@ -Subproject commit 90ab3e9c91fcc1e76750599611c08dadb447fd27 +Subproject commit 3340d39624efd38eb3e2430fdc0e671d4f2c266c diff --git a/spelling.txt b/spelling.txt index c063804a..27a58018 100644 --- a/spelling.txt +++ b/spelling.txt @@ -119,3 +119,17 @@ unplugin unorm bgra BGRA +VoIP +CallKit +PushKit +Telecom +APNs +FCM +prebuild +Firebase +Recents +redial +backgrounded +Gradle +entitlement +entitlements