From 0817ecd7a6cb02801756774a65069d1ad16991ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gadomski?= Date: Mon, 6 Jul 2026 11:01:01 +0200 Subject: [PATCH 01/52] Add telecom test screen --- examples/mobile-client/voip-call/app/App.tsx | 3 + .../app/src/screens/TelecomTestScreen.tsx | 209 ++++++++++++++++++ packages/mobile-client/src/index.ts | 9 + 3 files changed, 221 insertions(+) create mode 100644 examples/mobile-client/voip-call/app/src/screens/TelecomTestScreen.tsx diff --git a/examples/mobile-client/voip-call/app/App.tsx b/examples/mobile-client/voip-call/app/App.tsx index 1799b002a..3c5e19374 100644 --- a/examples/mobile-client/voip-call/app/App.tsx +++ b/examples/mobile-client/voip-call/app/App.tsx @@ -12,6 +12,7 @@ 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 { TelecomTestScreen } from './src/screens/TelecomTestScreen'; import { BrandColors } from './src/theme/colors'; import { UserProvider, useUser } from './src/user'; import { VoipProvider, useVoip } from './src/voip'; @@ -114,6 +115,8 @@ const App = () => ( + {/* Dev-only: exercises the Android Telecom native path directly. */} + diff --git a/examples/mobile-client/voip-call/app/src/screens/TelecomTestScreen.tsx b/examples/mobile-client/voip-call/app/src/screens/TelecomTestScreen.tsx new file mode 100644 index 000000000..81336594e --- /dev/null +++ b/examples/mobile-client/voip-call/app/src/screens/TelecomTestScreen.tsx @@ -0,0 +1,209 @@ +import { + useTelecom, + useTelecomEvent, + type TelecomEvent, +} from '@fishjam-cloud/react-native-webrtc'; +import { useCallback, useEffect, useState } from 'react'; +import { + Modal, + PermissionsAndroid, + Platform, + Pressable, + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; + +/** + * Throwaway dev screen to exercise the Android Telecom native path directly, + * before it's wired into VoipProvider. Renders a floating button that opens a + * panel with the raw Telecom controls + a live event log. + */ +async function ensureNotificationPermission() { + if (Platform.OS !== 'android' || Platform.Version < 33) return; + try { + await PermissionsAndroid.request( + PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS, + ); + } catch { + // ignore — the user can grant it manually via adb / settings + } +} + +export function TelecomTestScreen() { + const [open, setOpen] = useState(false); + const [log, setLog] = useState([]); + const [state, setState] = useState({ hasActiveCall: false, isAnswered: false }); + + const { + startCall, + reportIncomingCall, + answerCall, + setCallActive, + endCall, + hasActiveCall, + isAnswered, + } = useTelecom(); + + const append = useCallback((line: string) => { + const stamp = new Date().toLocaleTimeString(); + setLog((prev) => [`${stamp} ${line}`, ...prev].slice(0, 40)); + }, []); + + const refreshState = useCallback(() => { + setState({ hasActiveCall: hasActiveCall(), isAnswered: isAnswered() }); + }, [hasActiveCall, isAnswered]); + + useTelecomEvent( + useCallback( + (e: TelecomEvent) => { + append(`event → ${JSON.stringify(e)}`); + refreshState(); + }, + [append, refreshState], + ), + ); + + useEffect(() => { + if (open) { + ensureNotificationPermission(); + refreshState(); + } + }, [open, refreshState]); + + const run = (label: string, fn: () => Promise) => async () => { + try { + await fn(); + append(`${label} → ok`); + } catch (err) { + append(`${label} FAILED: ${(err as Error)?.message ?? err}`); + } + refreshState(); + }; + + return ( + <> + setOpen(true)} + accessibilityLabel="Open Telecom test panel"> + ☎︎ + + + setOpen(false)}> + + + Telecom native test + setOpen(false)}> + Close + + + + + + hasActiveCall: {String(state.hasActiveCall)} + + + isAnswered: {String(state.isAnswered)} + + + + + + + + + reportIncomingCall({ displayName: 'Alice', isVideo: false }), + )} + /> + + + startCall({ displayName: 'Bob', isVideo: false }), + )} + /> + + + + + Event log + + {log.map((line, i) => ( + + {line} + + ))} + + + + + ); +} + +function Btn({ label, onPress }: { label: string; onPress: () => void }) { + return ( + + {label} + + ); +} + +const styles = StyleSheet.create({ + fab: { + position: 'absolute', + right: 16, + bottom: 32, + width: 56, + height: 56, + borderRadius: 28, + backgroundColor: '#2563EB', + alignItems: 'center', + justifyContent: 'center', + elevation: 6, + zIndex: 999, + }, + fabText: { color: '#fff', fontSize: 24 }, + container: { flex: 1, backgroundColor: '#0B1220', padding: 16 }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginTop: 32, + marginBottom: 12, + }, + title: { color: '#fff', fontSize: 20, fontWeight: '600' }, + close: { color: '#93C5FD', fontSize: 16 }, + stateRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 16, + marginBottom: 12, + }, + stateText: { color: '#E5E7EB', fontFamily: 'monospace', fontSize: 12 }, + refresh: { color: '#93C5FD', fontSize: 18 }, + buttons: { gap: 8 }, + btn: { + backgroundColor: '#1F2937', + paddingVertical: 12, + paddingHorizontal: 16, + borderRadius: 8, + }, + btnText: { color: '#fff', fontSize: 15, textAlign: 'center' }, + logTitle: { color: '#9CA3AF', marginTop: 16, marginBottom: 6, fontSize: 13 }, + logBox: { flex: 1, backgroundColor: '#111827', borderRadius: 6 }, + logContent: { padding: 8 }, + logLine: { + color: '#93C5FD', + fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', + fontSize: 11, + marginBottom: 2, + }, +}); diff --git a/packages/mobile-client/src/index.ts b/packages/mobile-client/src/index.ts index 37b97b2b0..9fc68bc58 100644 --- a/packages/mobile-client/src/index.ts +++ b/packages/mobile-client/src/index.ts @@ -24,10 +24,19 @@ export { AudioDeviceType, useAudioOutput, useVoIPEvents, + useTelecom, + useTelecomEvent, } from '@fishjam-cloud/react-native-webrtc'; export type { VoIPEventHandlers, VoipIncomingPayload } from '@fishjam-cloud/react-native-webrtc'; +export type { + TelecomConfig, + TelecomEvent, + TelecomEventType, + UseTelecomResult, +} from '@fishjam-cloud/react-native-webrtc'; + export type { CallKitAction, CallKitConfig, From e25fa95e5dcf91f3f9f6fb6c65b204e077fa776e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gadomski?= Date: Mon, 6 Jul 2026 11:03:03 +0200 Subject: [PATCH 02/52] Add simple plugin for VoIP functionality --- examples/mobile-client/voip-call/app/app.json | 3 + packages/mobile-client/plugin/src/types.ts | 7 ++ .../plugin/src/withFishjamAndroid.ts | 12 ++- .../plugin/src/withFishjamVoip.ts | 92 +++++++++++++++++++ 4 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 packages/mobile-client/plugin/src/withFishjamVoip.ts diff --git a/examples/mobile-client/voip-call/app/app.json b/examples/mobile-client/voip-call/app/app.json index a0331d0ff..3b0061bf3 100644 --- a/examples/mobile-client/voip-call/app/app.json +++ b/examples/mobile-client/voip-call/app/app.json @@ -30,6 +30,9 @@ [ "@fishjam-cloud/react-native-client", { + "android": { + "enableVoip": true + }, "ios": { "enableVoIPBackgroundMode": true } diff --git a/packages/mobile-client/plugin/src/types.ts b/packages/mobile-client/plugin/src/types.ts index 9a7bbe264..a44212ddf 100644 --- a/packages/mobile-client/plugin/src/types.ts +++ b/packages/mobile-client/plugin/src/types.ts @@ -4,6 +4,13 @@ export type FishjamPluginOptions = enableForegroundService?: boolean; enableScreensharing?: boolean; supportsPictureInPicture?: boolean; + /** + * Adds the Telecom (VoIP calling) manifest entries: call permissions, + * the incoming-call ring activity, the notification-action receiver, + * and the foreground service that hosts the ongoing-call notification. + * Leave off if your app doesn't implement calling. + */ + enableVoip?: boolean; }; ios?: { enableScreensharing?: boolean; diff --git a/packages/mobile-client/plugin/src/withFishjamAndroid.ts b/packages/mobile-client/plugin/src/withFishjamAndroid.ts index 1d887bf27..76d989c22 100644 --- a/packages/mobile-client/plugin/src/withFishjamAndroid.ts +++ b/packages/mobile-client/plugin/src/withFishjamAndroid.ts @@ -3,6 +3,13 @@ import { AndroidConfig, withAndroidManifest } from '@expo/config-plugins'; import { getMainApplicationOrThrow } from '@expo/config-plugins/build/android/Manifest'; import type { FishjamPluginOptions } from './types'; +import { withFishjamVoipAndroid } from './withFishjamVoip'; + +// The VoIP integration posts its ongoing-call notification through +// WebRTCForegroundService, so enabling VoIP implies the service (and its +// permissions) even when the app never enables the room foreground service. +const needsForegroundService = (props: FishjamPluginOptions) => + Boolean(props?.android?.enableForegroundService || props?.android?.enableVoip); const withFishjamPictureInPicture: ConfigPlugin = (config, props) => withAndroidManifest(config, (configuration) => { @@ -18,7 +25,7 @@ const withFishjamPictureInPicture: ConfigPlugin = (config, const withFishjamForegroundService: ConfigPlugin = (config, props) => withAndroidManifest(config, async (configuration) => { - if (!props?.android?.enableForegroundService) { + if (!needsForegroundService(props)) { return configuration; } @@ -52,7 +59,7 @@ const withFishjamForegroundService: ConfigPlugin = (config const withFishjamForegroundServicePermission: ConfigPlugin = (config, props) => withAndroidManifest(config, (configuration) => { - if (!props?.android?.enableForegroundService) { + if (!needsForegroundService(props)) { return configuration; } @@ -92,6 +99,7 @@ const withFishjamForegroundServicePermission: ConfigPlugin export const withFishjamAndroid: ConfigPlugin = (config, props) => { config = withFishjamForegroundServicePermission(config, props); config = withFishjamForegroundService(config, props); + config = withFishjamVoipAndroid(config, props); config = withFishjamPictureInPicture(config, props); return config; }; diff --git a/packages/mobile-client/plugin/src/withFishjamVoip.ts b/packages/mobile-client/plugin/src/withFishjamVoip.ts new file mode 100644 index 000000000..590df4b6f --- /dev/null +++ b/packages/mobile-client/plugin/src/withFishjamVoip.ts @@ -0,0 +1,92 @@ +import type { ConfigPlugin } from '@expo/config-plugins'; +import { withAndroidManifest } from '@expo/config-plugins'; +import { getMainApplicationOrThrow } from '@expo/config-plugins/build/android/Manifest'; + +import type { FishjamPluginOptions } from './types'; + +/** + * Manifest entries required by the Android Telecom (VoIP calling) integration. + * + * These deliberately live in the app's manifest (injected here) instead of the + * react-native-webrtc library manifest, so SDK users who don't build a calling + * app never declare call-related permissions or components. Opt in with + * `android.enableVoip`. + * + * - MANAGE_OWN_CALLS: required to register calls with Telecom via + * androidx.core.telecom CallsManager. + * - POST_NOTIFICATIONS: the incoming/ongoing CallStyle notifications. + * - USE_FULL_SCREEN_INTENT: the incoming-call ring screen over the lock screen. + * (The ongoing-call notification is posted by the foreground service and + * does not use a full-screen intent.) + */ +const VOIP_PERMISSIONS = [ + 'android.permission.MANAGE_OWN_CALLS', + 'android.permission.POST_NOTIFICATIONS', + 'android.permission.USE_FULL_SCREEN_INTENT', +]; + +const INCOMING_CALL_ACTIVITY = { + $: { + 'android:name': 'com.oney.WebRTCModule.voip.IncomingCallActivity', + 'android:exported': 'false' as const, + 'android:showWhenLocked': 'true', + 'android:turnScreenOn': 'true', + 'android:launchMode': 'singleInstance', + 'android:excludeFromRecents': 'true', + 'android:taskAffinity': '', + 'android:theme': '@android:style/Theme.Black.NoTitleBar.Fullscreen', + }, +}; + +const END_CALL_RECEIVER = { + $: { + 'android:name': 'com.oney.WebRTCModule.voip.EndCallNotificationReceiver', + 'android:exported': 'false' as const, + }, +}; + +export const withFishjamVoipAndroid: ConfigPlugin = (config, props) => + withAndroidManifest(config, (configuration) => { + if (!props?.android?.enableVoip) { + return configuration; + } + + const manifest = configuration.modResults.manifest; + if (!manifest['uses-permission']) { + manifest['uses-permission'] = []; + } + const permissions = manifest['uses-permission']; + + VOIP_PERMISSIONS.forEach((permissionName) => { + const hasPermission = permissions.some((perm) => perm.$?.['android:name'] === permissionName); + if (!hasPermission) { + permissions.push({ $: { 'android:name': permissionName } }); + } + }); + + const mainApplication = getMainApplicationOrThrow(configuration.modResults); + + mainApplication.activity = mainApplication.activity || []; + const activityName = INCOMING_CALL_ACTIVITY.$['android:name']; + const existingActivityIndex = mainApplication.activity.findIndex( + (activity) => activity.$['android:name'] === activityName, + ); + if (existingActivityIndex !== -1) { + mainApplication.activity[existingActivityIndex] = INCOMING_CALL_ACTIVITY; + } else { + mainApplication.activity.push(INCOMING_CALL_ACTIVITY); + } + + mainApplication.receiver = mainApplication.receiver || []; + const receiverName = END_CALL_RECEIVER.$['android:name']; + const existingReceiverIndex = mainApplication.receiver.findIndex( + (receiver) => receiver.$['android:name'] === receiverName, + ); + if (existingReceiverIndex !== -1) { + mainApplication.receiver[existingReceiverIndex] = END_CALL_RECEIVER; + } else { + mainApplication.receiver.push(END_CALL_RECEIVER); + } + + return configuration; + }); From d235a4252f36aaef26617f7107be3e0651c8407f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gadomski?= Date: Mon, 6 Jul 2026 13:39:46 +0200 Subject: [PATCH 03/52] Integrate with FCM --- .../mobile-client/voip-call/app/.gitignore | 2 + .../plugin/src/withFishjamVoip.ts | 45 +++++++++++++++++++ packages/react-native-webrtc | 2 +- 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/examples/mobile-client/voip-call/app/.gitignore b/examples/mobile-client/voip-call/app/.gitignore index 8c6c2de46..935c0172d 100644 --- a/examples/mobile-client/voip-call/app/.gitignore +++ b/examples/mobile-client/voip-call/app/.gitignore @@ -20,3 +20,5 @@ yarn-error.* # generated native folders /ios /android + +google-services.json \ No newline at end of file diff --git a/packages/mobile-client/plugin/src/withFishjamVoip.ts b/packages/mobile-client/plugin/src/withFishjamVoip.ts index 590df4b6f..1e71442d9 100644 --- a/packages/mobile-client/plugin/src/withFishjamVoip.ts +++ b/packages/mobile-client/plugin/src/withFishjamVoip.ts @@ -45,6 +45,31 @@ const END_CALL_RECEIVER = { }, }; +// FCM service that receives VoIP wake-up pushes and reports the call to Telecom. +// Declared here (app manifest) so non-calling apps pull in neither it nor Firebase. +const MESSAGING_SERVICE = { + '$': { + 'android:name': 'com.oney.WebRTCModule.voip.PushNotificationService', + 'android:exported': 'false' as const, + }, + 'intent-filter': [ + { + action: [{ $: { 'android:name': 'com.google.firebase.MESSAGING_EVENT' } }], + }, + ], +}; + +// Enables the Firebase Installations path that backs FirebaseMessagingService.onRegistered +// (the non-deprecated token callback PushNotificationService uses). Without it, FCM only +// fires the deprecated onNewToken and onRegistered never fires. Note: this turns on the +// Firebase installation ID, a stable per-install identifier. +const INSTALLATION_ID_META = { + $: { + 'android:name': 'firebase_messaging_installation_id_enabled', + 'android:value': 'true', + }, +}; + export const withFishjamVoipAndroid: ConfigPlugin = (config, props) => withAndroidManifest(config, (configuration) => { if (!props?.android?.enableVoip) { @@ -88,5 +113,25 @@ export const withFishjamVoipAndroid: ConfigPlugin = (confi mainApplication.receiver.push(END_CALL_RECEIVER); } + mainApplication.service = mainApplication.service || []; + const serviceName = MESSAGING_SERVICE.$['android:name']; + const existingServiceIndex = mainApplication.service.findIndex( + (service) => service.$['android:name'] === serviceName, + ); + if (existingServiceIndex !== -1) { + mainApplication.service[existingServiceIndex] = MESSAGING_SERVICE; + } else { + mainApplication.service.push(MESSAGING_SERVICE); + } + + mainApplication['meta-data'] = mainApplication['meta-data'] || []; + const metaName = INSTALLATION_ID_META.$['android:name']; + const existingMetaIndex = mainApplication['meta-data'].findIndex((meta) => meta.$['android:name'] === metaName); + if (existingMetaIndex !== -1) { + mainApplication['meta-data'][existingMetaIndex] = INSTALLATION_ID_META; + } else { + mainApplication['meta-data'].push(INSTALLATION_ID_META); + } + return configuration; }); diff --git a/packages/react-native-webrtc b/packages/react-native-webrtc index 84c9dbbe4..97223d162 160000 --- a/packages/react-native-webrtc +++ b/packages/react-native-webrtc @@ -1 +1 @@ -Subproject commit 84c9dbbe45936ac86ff08fc4ba69f4c0dfaed44e +Subproject commit 97223d162a4ae7b175a3eb30a321fea4f4ca8da1 From 1c9433296d1cf13db3f4c890c55e58e3d06d40e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gadomski?= Date: Tue, 7 Jul 2026 12:11:18 +0200 Subject: [PATCH 04/52] Add FCM push to server --- .../mobile-client/voip-call/server/.gitignore | 3 +- .../mobile-client/voip-call/server/deno.json | 3 +- .../mobile-client/voip-call/server/deno.lock | 130 +++++++++++++++++- .../mobile-client/voip-call/server/main.ts | 84 ++++++++++- 4 files changed, 210 insertions(+), 10 deletions(-) diff --git a/examples/mobile-client/voip-call/server/.gitignore b/examples/mobile-client/voip-call/server/.gitignore index d04f59082..af6a1f3b3 100644 --- a/examples/mobile-client/voip-call/server/.gitignore +++ b/examples/mobile-client/voip-call/server/.gitignore @@ -1,2 +1,3 @@ voip.db -apns.pem \ No newline at end of file +apns.pem +fcm-credentials.json \ No newline at end of file diff --git a/examples/mobile-client/voip-call/server/deno.json b/examples/mobile-client/voip-call/server/deno.json index 3e96824b3..b95a4f7c2 100644 --- a/examples/mobile-client/voip-call/server/deno.json +++ b/examples/mobile-client/voip-call/server/deno.json @@ -3,7 +3,8 @@ "start": "deno run --allow-net --allow-read --allow-env --allow-ffi main.ts" }, "imports": { - "@db/sqlite": "jsr:@db/sqlite@^0.12" + "@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 index cf1f95c51..694919a8a 100644 --- a/examples/mobile-client/voip-call/server/deno.lock +++ b/examples/mobile-client/voip-call/server/deno.lock @@ -12,7 +12,8 @@ "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:@std/path@^1.1.5": "1.1.5", + "npm:google-auth-library@^10.9.0": "10.9.0" }, "jsr": { "@db/sqlite@0.12.0": { @@ -73,9 +74,134 @@ ] } }, + "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" + "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 index 82e14f8f9..b8f7068e1 100644 --- a/examples/mobile-client/voip-call/server/main.ts +++ b/examples/mobile-client/voip-call/server/main.ts @@ -1,4 +1,5 @@ import { Database } from "@db/sqlite"; +import { JWT } from "google-auth-library"; const db = new Database("voip.db"); @@ -11,6 +12,68 @@ db.exec(` ) `); +// --- FCM push (Android) --- + +const USE_FCM = true; + +type ServiceAccount = { + client_email: string; + private_key: string; + project_id: string; +}; + +const serviceAccount: ServiceAccount = JSON.parse( + await Deno.readTextFile("./fcm-credentials.json"), +); + +const authClient = new JWT({ + email: serviceAccount.client_email, + key: serviceAccount.private_key, + scopes: ["https://www.googleapis.com/auth/firebase.messaging"], +}); + +async function getAccessToken(): Promise { + const { token } = await authClient.getAccessToken(); + if (!token) throw new Error("Failed to get access token"); + return token; +} + +async function sendFcmPush(params: { + fcmToken: string; + roomName: string; + displayName: string; + isVideo: boolean; +}): Promise { + const accessToken = await getAccessToken(); + + 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.fcmToken, + data: { + roomName: params.roomName, + displayName: params.displayName, + isVideo: String(params.isVideo), + }, + 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"; @@ -102,12 +165,21 @@ Deno.serve({ port: 4400 }, async (req) => { const voipToken = calleeRows[0].voip_token; try { - await sendVoipPush({ - voipToken, - roomName: roomName, - displayName: from, - isVideo: isVideo, - }); + if (USE_FCM) { + await sendFcmPush({ + fcmToken: voipToken, + roomName: roomName, + displayName: from, + isVideo: isVideo, + }); + } else { + 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); From e55f0215c1c2e767b09a9271ec96c944237c5bba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gadomski?= Date: Tue, 7 Jul 2026 14:44:52 +0200 Subject: [PATCH 05/52] Register firebase to react-native --- examples/mobile-client/voip-call/app/app.json | 2 + .../mobile-client/voip-call/app/package.json | 1 + .../app/src/screens/TelecomTestScreen.tsx | 7 + yarn.lock | 680 +++++++++++++++++- 4 files changed, 688 insertions(+), 2 deletions(-) diff --git a/examples/mobile-client/voip-call/app/app.json b/examples/mobile-client/voip-call/app/app.json index 3b0061bf3..4470186e1 100644 --- a/examples/mobile-client/voip-call/app/app.json +++ b/examples/mobile-client/voip-call/app/app.json @@ -18,6 +18,7 @@ }, "android": { "package": "io.fishjam.example.voipcall", + "googleServicesFile": "./google-services.json", "edgeToEdgeEnabled": true, "permissions": [ "android.permission.RECORD_AUDIO", @@ -27,6 +28,7 @@ ] }, "plugins": [ + "@react-native-firebase/app", [ "@fishjam-cloud/react-native-client", { diff --git a/examples/mobile-client/voip-call/app/package.json b/examples/mobile-client/voip-call/app/package.json index beb18f242..dfaaf8146 100644 --- a/examples/mobile-client/voip-call/app/package.json +++ b/examples/mobile-client/voip-call/app/package.json @@ -12,6 +12,7 @@ "dependencies": { "@fishjam-cloud/react-native-client": "workspace:*", "@react-native-async-storage/async-storage": "^3.1.1", + "@react-native-firebase/app": "^25.1.0", "expo": "~54.0.30", "expo-status-bar": "~3.0.9", "react": "19.1.0", diff --git a/examples/mobile-client/voip-call/app/src/screens/TelecomTestScreen.tsx b/examples/mobile-client/voip-call/app/src/screens/TelecomTestScreen.tsx index 81336594e..e61a84737 100644 --- a/examples/mobile-client/voip-call/app/src/screens/TelecomTestScreen.tsx +++ b/examples/mobile-client/voip-call/app/src/screens/TelecomTestScreen.tsx @@ -1,4 +1,5 @@ import { + getVoipToken, useTelecom, useTelecomEvent, type TelecomEvent, @@ -66,6 +67,12 @@ export function TelecomTestScreen() { ), ); + useEffect(() => { + getVoipToken().then((token) => + console.log('[voip] FCM installation id =', token), + ); + }, []); + useEffect(() => { if (open) { ensureNotificationPermission(); diff --git a/yarn.lock b/yarn.lock index 5b7f19074..c0c0e1683 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3997,6 +3997,548 @@ __metadata: languageName: node linkType: hard +"@firebase/ai@npm:2.13.1": + version: 2.13.1 + resolution: "@firebase/ai@npm:2.13.1" + dependencies: + "@firebase/app-check-interop-types": "npm:0.3.4" + "@firebase/component": "npm:0.7.3" + "@firebase/logger": "npm:0.5.1" + "@firebase/util": "npm:1.15.1" + tslib: "npm:^2.1.0" + peerDependencies: + "@firebase/app": 0.x + "@firebase/app-types": 0.x + checksum: 10c0/826876c4e4fc9102e33d5afba1d00727efeef9c5a5db2d81c562342c458f0020243e47b5db1eb7ef03aeafc785581a47f42ac5da98f423efecf156a97861b3ad + languageName: node + linkType: hard + +"@firebase/analytics-compat@npm:0.2.28": + version: 0.2.28 + resolution: "@firebase/analytics-compat@npm:0.2.28" + dependencies: + "@firebase/analytics": "npm:0.10.22" + "@firebase/analytics-types": "npm:0.8.4" + "@firebase/component": "npm:0.7.3" + "@firebase/util": "npm:1.15.1" + tslib: "npm:^2.1.0" + peerDependencies: + "@firebase/app-compat": 0.x + checksum: 10c0/e8508b26bc75f47cd90f7cd31b36084439179fe85eac8ebe02f3ac6ffb0176d8f77f1e91de07fedf3307ea3ef0b5c547b89ba175c0f6277829d3df1d48a713e3 + languageName: node + linkType: hard + +"@firebase/analytics-types@npm:0.8.4": + version: 0.8.4 + resolution: "@firebase/analytics-types@npm:0.8.4" + checksum: 10c0/5dd5929eab551855c8338ed97c1a4ef18713501611ea9226f391410800f9736490500d6decdda777c8dc37ac615b59fd50127559e486dfac91690a07394a0fb6 + languageName: node + linkType: hard + +"@firebase/analytics@npm:0.10.22": + version: 0.10.22 + resolution: "@firebase/analytics@npm:0.10.22" + dependencies: + "@firebase/component": "npm:0.7.3" + "@firebase/installations": "npm:0.6.22" + "@firebase/logger": "npm:0.5.1" + "@firebase/util": "npm:1.15.1" + tslib: "npm:^2.1.0" + peerDependencies: + "@firebase/app": 0.x + checksum: 10c0/2f6398e1fe197ff2f3eab5fac7f2296d666799eb1598a35d71e996d365478b35dd00001e57e5e0b4e18d9ea6996a9aded36847dbc88091022c3d1df095df9ad8 + languageName: node + linkType: hard + +"@firebase/app-check-compat@npm:0.4.5": + version: 0.4.5 + resolution: "@firebase/app-check-compat@npm:0.4.5" + dependencies: + "@firebase/app-check": "npm:0.12.0" + "@firebase/app-check-types": "npm:0.5.4" + "@firebase/component": "npm:0.7.3" + "@firebase/logger": "npm:0.5.1" + "@firebase/util": "npm:1.15.1" + tslib: "npm:^2.1.0" + peerDependencies: + "@firebase/app-compat": 0.x + checksum: 10c0/8ab0ddd04a20f8908cb53b728967571e035816dfb43b1ea9753a5f0ceb08c58aa5b3f682244e0e66e87f7a63998d50b1c276da8a833209ce6237124ee79bb458 + languageName: node + linkType: hard + +"@firebase/app-check-interop-types@npm:0.3.4": + version: 0.3.4 + resolution: "@firebase/app-check-interop-types@npm:0.3.4" + checksum: 10c0/1861470d0e3f4fcff5e46bd9e9cc9c2408988eb01d661bac791e0dd511dde20e148503c923c8808f6921a70c523740f102c55615e0393158277d643fcec21c1c + languageName: node + linkType: hard + +"@firebase/app-check-types@npm:0.5.4": + version: 0.5.4 + resolution: "@firebase/app-check-types@npm:0.5.4" + checksum: 10c0/945edf0f14a8e432893a36594028b78c728e56ebbf41543019b0b5319b987e38b7b374268589905b8827bb16af06cbb2682218d9b0bb50efd7d2cab13e40e825 + languageName: node + linkType: hard + +"@firebase/app-check@npm:0.12.0": + version: 0.12.0 + resolution: "@firebase/app-check@npm:0.12.0" + dependencies: + "@firebase/component": "npm:0.7.3" + "@firebase/logger": "npm:0.5.1" + "@firebase/util": "npm:1.15.1" + tslib: "npm:^2.1.0" + peerDependencies: + "@firebase/app": 0.x + checksum: 10c0/ccca7fad754ccfb9862ab8346de61c981759cb6e33851f57410ef375f9e992937883238e7f3c163f4e5bff044e93d1368c949bc2d026a1504f35d5f39459623d + languageName: node + linkType: hard + +"@firebase/app-compat@npm:0.5.14": + version: 0.5.14 + resolution: "@firebase/app-compat@npm:0.5.14" + dependencies: + "@firebase/app": "npm:0.15.0" + "@firebase/component": "npm:0.7.3" + "@firebase/logger": "npm:0.5.1" + "@firebase/util": "npm:1.15.1" + tslib: "npm:^2.1.0" + checksum: 10c0/7cfb3169883850fd23635422fcc9d267a92b10c09d3bc52c2e163c87c885c5da7db4fd0d420fa8ed15153585803328fcfa970010ce5cb15769f32ce4ed77bec3 + languageName: node + linkType: hard + +"@firebase/app-types@npm:0.9.5": + version: 0.9.5 + resolution: "@firebase/app-types@npm:0.9.5" + dependencies: + "@firebase/logger": "npm:0.5.1" + checksum: 10c0/997c2deab31ac1666b10dd9fbcf6198266581ac9d1228c90969331edf1c94554312f3c828748322a444d00dcbf88e06071926c2ba565228f128d7af3e70811bd + languageName: node + linkType: hard + +"@firebase/app@npm:0.15.0": + version: 0.15.0 + resolution: "@firebase/app@npm:0.15.0" + dependencies: + "@firebase/component": "npm:0.7.3" + "@firebase/logger": "npm:0.5.1" + "@firebase/util": "npm:1.15.1" + idb: "npm:7.1.1" + tslib: "npm:^2.1.0" + checksum: 10c0/18c90bb85839172ffe722561cb23869a75329747ecaab9bbe294d0d871986faa9f78139a70ce63939ea41b5c8d7e5b5f9603d5c0a315a0832ab2a1cd5dcfa956 + languageName: node + linkType: hard + +"@firebase/auth-compat@npm:0.6.8": + version: 0.6.8 + resolution: "@firebase/auth-compat@npm:0.6.8" + dependencies: + "@firebase/auth": "npm:1.13.3" + "@firebase/auth-types": "npm:0.13.1" + "@firebase/component": "npm:0.7.3" + "@firebase/util": "npm:1.15.1" + tslib: "npm:^2.1.0" + peerDependencies: + "@firebase/app-compat": 0.x + checksum: 10c0/5af65a62b3b5ad571f3c7db9fc8e75ef7f926b8fa4376052166a6c00d7122e298dcf48fd890a787fa16481b5756a5c27eb8819e3b6b6aec3d0c770c615739be6 + languageName: node + linkType: hard + +"@firebase/auth-interop-types@npm:0.2.5": + version: 0.2.5 + resolution: "@firebase/auth-interop-types@npm:0.2.5" + checksum: 10c0/1a0a72935b2809f1b9c57e1fda2eb53b741cf4f2e78b295d45bf4d14829d51ce3b6f86b6d4ce6b867b5878c223c10032d11fdf82d0ab4a10b30dd4803fa287ae + languageName: node + linkType: hard + +"@firebase/auth-types@npm:0.13.1": + version: 0.13.1 + resolution: "@firebase/auth-types@npm:0.13.1" + peerDependencies: + "@firebase/app-types": 0.x + "@firebase/util": 1.x + checksum: 10c0/598273dfccb4d112fd9e6026433ac8ce4716b7ac466e80c0f9b1f6d892c44a575f52f7e43341fe5bdae863f59f5532816f2acd13479e88db6650d421c17839e2 + languageName: node + linkType: hard + +"@firebase/auth@npm:1.13.3": + version: 1.13.3 + resolution: "@firebase/auth@npm:1.13.3" + dependencies: + "@firebase/component": "npm:0.7.3" + "@firebase/logger": "npm:0.5.1" + "@firebase/util": "npm:1.15.1" + tslib: "npm:^2.1.0" + peerDependencies: + "@firebase/app": 0.x + "@react-native-async-storage/async-storage": ^2.2.0 || ^3.0.0 + peerDependenciesMeta: + "@react-native-async-storage/async-storage": + optional: true + checksum: 10c0/aca4ec9ff0ac4f5a72de994fdb243d02770f64a363f0f7b082c52877f40b4dc208cbc2fadd9b3e6f23c865bcd74cf0dea564c5025fae4e2f8e529c1dca5b8030 + languageName: node + linkType: hard + +"@firebase/component@npm:0.7.3": + version: 0.7.3 + resolution: "@firebase/component@npm:0.7.3" + dependencies: + "@firebase/util": "npm:1.15.1" + tslib: "npm:^2.1.0" + checksum: 10c0/a2c3400afecfa90cf6ec3526579ed3557828aafa8d4c3b34af7221a92ec2d9261c4a85b01c31b89774efcb6194c63f23ff219efe6a9cd842de2a6d41728bbd35 + languageName: node + linkType: hard + +"@firebase/data-connect@npm:0.7.1": + version: 0.7.1 + resolution: "@firebase/data-connect@npm:0.7.1" + dependencies: + "@firebase/auth-interop-types": "npm:0.2.5" + "@firebase/component": "npm:0.7.3" + "@firebase/logger": "npm:0.5.1" + "@firebase/util": "npm:1.15.1" + tslib: "npm:^2.1.0" + peerDependencies: + "@firebase/app": 0.x + checksum: 10c0/e4df223abdaae906ef3c226aadebbfb00ef9eae56bf8c4226ce4cb7a708e4de6ae381db4f7d188f82d2868da3ed64847a09b39c6dec34248528c522d9bb74baa + languageName: node + linkType: hard + +"@firebase/database-compat@npm:2.1.4": + version: 2.1.4 + resolution: "@firebase/database-compat@npm:2.1.4" + dependencies: + "@firebase/component": "npm:0.7.3" + "@firebase/database": "npm:1.1.3" + "@firebase/database-types": "npm:1.0.20" + "@firebase/logger": "npm:0.5.1" + "@firebase/util": "npm:1.15.1" + tslib: "npm:^2.1.0" + checksum: 10c0/e0d889acca69ee55030bc2fdd223d5b692e8a15656aa4403291f57b36401be89e915354260817a7be8f4eaa988d744d36d07933a0e8c9f79a46a971218f02dd6 + languageName: node + linkType: hard + +"@firebase/database-types@npm:1.0.20": + version: 1.0.20 + resolution: "@firebase/database-types@npm:1.0.20" + dependencies: + "@firebase/app-types": "npm:0.9.5" + "@firebase/util": "npm:1.15.1" + checksum: 10c0/bc2848ded44b3bbf0352cd9f5580190b4e82b83c99618e0cf76a84a68850a4cf87954b1d9fff134ae4d930be79188eb5e7e34e3007e25576d630f1c83a7ace0f + languageName: node + linkType: hard + +"@firebase/database@npm:1.1.3": + version: 1.1.3 + resolution: "@firebase/database@npm:1.1.3" + dependencies: + "@firebase/app-check-interop-types": "npm:0.3.4" + "@firebase/auth-interop-types": "npm:0.2.5" + "@firebase/component": "npm:0.7.3" + "@firebase/logger": "npm:0.5.1" + "@firebase/util": "npm:1.15.1" + faye-websocket: "npm:0.11.4" + tslib: "npm:^2.1.0" + checksum: 10c0/90aceab86ffff9bbccc9f1d6bfc83d450e74a6817c3605f8b61b3f8ea028b89f7d81017d12b61ff805506684e12435476da4cb0ce37dfb773c95807709e75e25 + languageName: node + linkType: hard + +"@firebase/firestore-compat@npm:0.4.11": + version: 0.4.11 + resolution: "@firebase/firestore-compat@npm:0.4.11" + dependencies: + "@firebase/component": "npm:0.7.3" + "@firebase/firestore": "npm:4.16.0" + "@firebase/firestore-types": "npm:3.0.4" + "@firebase/util": "npm:1.15.1" + tslib: "npm:^2.1.0" + peerDependencies: + "@firebase/app-compat": 0.x + checksum: 10c0/d0ea00b7bf37f172d64b2dd786f7e919f3314bf745b8b842b0c4029bd3a0b7db787450e274c6582b982d5afe7d7ff32d8d7244f32c166a5760df7c1a66bfd20c + languageName: node + linkType: hard + +"@firebase/firestore-types@npm:3.0.4": + version: 3.0.4 + resolution: "@firebase/firestore-types@npm:3.0.4" + peerDependencies: + "@firebase/app-types": 0.x + "@firebase/util": 1.x + checksum: 10c0/8370f76b9ed06e8a6982af70d077b4b369ebcb322e334180c74d5c581d6487353f1bdf64a5ba0d62cac64dfc5973f4697d81257cf2927e305fa4e54fad3d9e6d + languageName: node + linkType: hard + +"@firebase/firestore@npm:4.16.0": + version: 4.16.0 + resolution: "@firebase/firestore@npm:4.16.0" + dependencies: + "@firebase/component": "npm:0.7.3" + "@firebase/logger": "npm:0.5.1" + "@firebase/util": "npm:1.15.1" + "@firebase/webchannel-wrapper": "npm:1.0.6" + "@grpc/grpc-js": "npm:~1.9.0" + "@grpc/proto-loader": "npm:^0.7.8" + re2js: "npm:^0.4.2" + tslib: "npm:^2.1.0" + peerDependencies: + "@firebase/app": 0.x + checksum: 10c0/f89796fbab2e1305fd17871f1d870d281feae77b0acd2aee6cdb61854269ce235ea9934229ed5da319e3bd5f8e4cd54339d2b88a9814cdc3e68fde9aae267d4d + languageName: node + linkType: hard + +"@firebase/functions-compat@npm:0.4.5": + version: 0.4.5 + resolution: "@firebase/functions-compat@npm:0.4.5" + dependencies: + "@firebase/component": "npm:0.7.3" + "@firebase/functions": "npm:0.13.5" + "@firebase/functions-types": "npm:0.6.4" + "@firebase/util": "npm:1.15.1" + tslib: "npm:^2.1.0" + peerDependencies: + "@firebase/app-compat": 0.x + checksum: 10c0/38c614ec794ad6d702e366670df21f14a5057093264bc871d0101a224921663092b373c3ac08917f299f09b9b867984d934ef123376d75160fcc48a8d480dd6a + languageName: node + linkType: hard + +"@firebase/functions-types@npm:0.6.4": + version: 0.6.4 + resolution: "@firebase/functions-types@npm:0.6.4" + checksum: 10c0/50f1e50fc58f9a297e05c888a555b7bf085be5f5c4284e035cf3ebfc63016fa2b6dbf8de5e4a343b04f98e357525968e6254eabd271eb3b2548fa297770c7f68 + languageName: node + linkType: hard + +"@firebase/functions@npm:0.13.5": + version: 0.13.5 + resolution: "@firebase/functions@npm:0.13.5" + dependencies: + "@firebase/app-check-interop-types": "npm:0.3.4" + "@firebase/auth-interop-types": "npm:0.2.5" + "@firebase/component": "npm:0.7.3" + "@firebase/messaging-interop-types": "npm:0.2.5" + "@firebase/util": "npm:1.15.1" + tslib: "npm:^2.1.0" + peerDependencies: + "@firebase/app": 0.x + checksum: 10c0/92a5d6261feee27aa5ad7f0c1c55e3abf44c3ddd66f65f2bbf1001b06bb1d5bb2fdad21e6539299ae41079a8293da583c11bde0de949d736221426ab0bc663d1 + languageName: node + linkType: hard + +"@firebase/installations-compat@npm:0.2.22": + version: 0.2.22 + resolution: "@firebase/installations-compat@npm:0.2.22" + dependencies: + "@firebase/component": "npm:0.7.3" + "@firebase/installations": "npm:0.6.22" + "@firebase/installations-types": "npm:0.5.4" + "@firebase/util": "npm:1.15.1" + tslib: "npm:^2.1.0" + peerDependencies: + "@firebase/app-compat": 0.x + checksum: 10c0/0ba91d39f68507bdfa1f5cc494bbefc2af7acb885ba226e2ff0005e63233b623242bfef083cb6123a839a4dce28f0a54745932c06d821d04ad0dcdbb6a11b7cc + languageName: node + linkType: hard + +"@firebase/installations-types@npm:0.5.4": + version: 0.5.4 + resolution: "@firebase/installations-types@npm:0.5.4" + peerDependencies: + "@firebase/app-types": 0.x + checksum: 10c0/56148089894be055fa3b98b666bc2e75c6f6c679f79a6fc9b1d65e6f58e2d545df283149c990ae56910e39487a5cd65b4d753dd3b4eeea30b61e1691e7c5f134 + languageName: node + linkType: hard + +"@firebase/installations@npm:0.6.22": + version: 0.6.22 + resolution: "@firebase/installations@npm:0.6.22" + dependencies: + "@firebase/component": "npm:0.7.3" + "@firebase/util": "npm:1.15.1" + idb: "npm:7.1.1" + tslib: "npm:^2.1.0" + peerDependencies: + "@firebase/app": 0.x + checksum: 10c0/c8c0f5135b81254f85d68cee80561abad916238220802a5c5734dbd76e52742b91a61767aa1a14e0363e5aa6fd21c67aad47a7117f9c99a2902ef01af8c86739 + languageName: node + linkType: hard + +"@firebase/logger@npm:0.5.1": + version: 0.5.1 + resolution: "@firebase/logger@npm:0.5.1" + dependencies: + tslib: "npm:^2.1.0" + checksum: 10c0/ce8644943452e2e1cc00fe4eab2d63c8a0ba6767beb0e57d2ea11f609b5ffa6956c2ec8d6a01efae07a4932b70180c8a07540c53e1ddd876b2baa5858185c247 + languageName: node + linkType: hard + +"@firebase/messaging-compat@npm:0.2.27": + version: 0.2.27 + resolution: "@firebase/messaging-compat@npm:0.2.27" + dependencies: + "@firebase/component": "npm:0.7.3" + "@firebase/messaging": "npm:0.13.0" + "@firebase/util": "npm:1.15.1" + tslib: "npm:^2.1.0" + peerDependencies: + "@firebase/app-compat": 0.x + checksum: 10c0/f2831bdce11e51d041766823312425a75d94a71c9b5d9787412f4b45678c6600c32d571763ea3699d7e28a8b422a6376d37c83d21875d602a2ba90c789609cbb + languageName: node + linkType: hard + +"@firebase/messaging-interop-types@npm:0.2.5": + version: 0.2.5 + resolution: "@firebase/messaging-interop-types@npm:0.2.5" + checksum: 10c0/edffb0e4e5b630ee05f65ab3bbcf71657f05f67da645ec4c1f2d3e7a4abf5d541ef38348599ae5fb584ae9b341a74abab9a162be01695f4181e02b234c549159 + languageName: node + linkType: hard + +"@firebase/messaging@npm:0.13.0": + version: 0.13.0 + resolution: "@firebase/messaging@npm:0.13.0" + dependencies: + "@firebase/component": "npm:0.7.3" + "@firebase/installations": "npm:0.6.22" + "@firebase/messaging-interop-types": "npm:0.2.5" + "@firebase/util": "npm:1.15.1" + idb: "npm:7.1.1" + tslib: "npm:^2.1.0" + peerDependencies: + "@firebase/app": 0.x + checksum: 10c0/e194de2655aedf458cba5b5e3884635da8519f196f2dd3bc04224966c4503448f6937209eea7a93a16685c7d37c04ee3a71793e4463ef6ae7db64e635b67ba56 + languageName: node + linkType: hard + +"@firebase/performance-compat@npm:0.2.25": + version: 0.2.25 + resolution: "@firebase/performance-compat@npm:0.2.25" + dependencies: + "@firebase/component": "npm:0.7.3" + "@firebase/logger": "npm:0.5.1" + "@firebase/performance": "npm:0.7.12" + "@firebase/performance-types": "npm:0.2.4" + "@firebase/util": "npm:1.15.1" + tslib: "npm:^2.1.0" + peerDependencies: + "@firebase/app-compat": 0.x + checksum: 10c0/2fce37ed67f8921eadff7efaedefd892281cf1ccd6277413f23598335e95d6a0e93d5b2b673c93fada18f0c244669abd6a066f8249782a394931a8777c8df998 + languageName: node + linkType: hard + +"@firebase/performance-types@npm:0.2.4": + version: 0.2.4 + resolution: "@firebase/performance-types@npm:0.2.4" + checksum: 10c0/5a823641e637d2b51cda6865804a5f05dba1d0f14b8664f396b5a0c029f5bb74c421240d3bd3fadf307dd9b71571ffcb32d04f5058c65eadbde1bfe4b85ab6c8 + languageName: node + linkType: hard + +"@firebase/performance@npm:0.7.12": + version: 0.7.12 + resolution: "@firebase/performance@npm:0.7.12" + dependencies: + "@firebase/component": "npm:0.7.3" + "@firebase/installations": "npm:0.6.22" + "@firebase/logger": "npm:0.5.1" + "@firebase/util": "npm:1.15.1" + tslib: "npm:^2.1.0" + web-vitals: "npm:^4.2.4" + peerDependencies: + "@firebase/app": 0.x + checksum: 10c0/58211e8bd59b1d4c40c2ebb66504ea0745301308a8c3911da29014176bedf3953521f8dc4d9f0d320d955934a1bef18b17bf4a5dca0546dba702e05e18ed28f9 + languageName: node + linkType: hard + +"@firebase/remote-config-compat@npm:0.2.26": + version: 0.2.26 + resolution: "@firebase/remote-config-compat@npm:0.2.26" + dependencies: + "@firebase/component": "npm:0.7.3" + "@firebase/logger": "npm:0.5.1" + "@firebase/remote-config": "npm:0.8.5" + "@firebase/remote-config-types": "npm:0.5.1" + "@firebase/util": "npm:1.15.1" + tslib: "npm:^2.1.0" + peerDependencies: + "@firebase/app-compat": 0.x + checksum: 10c0/6f799383eef4758a245bf2f1f8ee591dc3e680e3a0df52eb345c09aa0202f706a78ca1fddad1d2d572ec45a865a26a929b355a30619fa9a2ab6223417c738df4 + languageName: node + linkType: hard + +"@firebase/remote-config-types@npm:0.5.1": + version: 0.5.1 + resolution: "@firebase/remote-config-types@npm:0.5.1" + checksum: 10c0/0882c2d9015bcb55a6e460ec3e2807c060984c1a394bf430ba6fa5ef25db53e6baf1d569539ed662e04b431b7e6122ca817437521ecf8be60c31a79a18845b0e + languageName: node + linkType: hard + +"@firebase/remote-config@npm:0.8.5": + version: 0.8.5 + resolution: "@firebase/remote-config@npm:0.8.5" + dependencies: + "@firebase/component": "npm:0.7.3" + "@firebase/installations": "npm:0.6.22" + "@firebase/logger": "npm:0.5.1" + "@firebase/util": "npm:1.15.1" + tslib: "npm:^2.1.0" + peerDependencies: + "@firebase/app": 0.x + checksum: 10c0/2f1f4e098953c75592573d511053a5a95cd5aadc236d591bfe791a5b95b488ef421ca62d595b69fc1cf5d02911bb32aaf13a8b5f5f8fddbc96365d4d822e41b5 + languageName: node + linkType: hard + +"@firebase/storage-compat@npm:0.4.3": + version: 0.4.3 + resolution: "@firebase/storage-compat@npm:0.4.3" + dependencies: + "@firebase/component": "npm:0.7.3" + "@firebase/storage": "npm:0.14.3" + "@firebase/storage-types": "npm:0.8.4" + "@firebase/util": "npm:1.15.1" + tslib: "npm:^2.1.0" + peerDependencies: + "@firebase/app-compat": 0.x + checksum: 10c0/4aa392d291dc0864fd286261bac6b00f5c90b9a16a366026db8631f51c7583807e28a00a040dd2d4f1fa897bb60f93a93da61eb16804e7a4075dd19e485e6239 + languageName: node + linkType: hard + +"@firebase/storage-types@npm:0.8.4": + version: 0.8.4 + resolution: "@firebase/storage-types@npm:0.8.4" + peerDependencies: + "@firebase/app-types": 0.x + "@firebase/util": 1.x + checksum: 10c0/66ec5b6c6a703c4eb0fb99761d741148d9ae2b5d06c4d5d6efcfbea368fb80480fb037378e3e5ec2aa8adcfe97bb64016eb263220f7d1a91439e46f23254d5b6 + languageName: node + linkType: hard + +"@firebase/storage@npm:0.14.3": + version: 0.14.3 + resolution: "@firebase/storage@npm:0.14.3" + dependencies: + "@firebase/component": "npm:0.7.3" + "@firebase/util": "npm:1.15.1" + tslib: "npm:^2.1.0" + peerDependencies: + "@firebase/app": 0.x + checksum: 10c0/6ec4d412cd658fd37759d2ad92e57edd5481e63373a38c0b2c5febb7a617e16b6646f94862168282e9aacec72d291af0f48181cb09f806ff66f82d31842b3bf8 + languageName: node + linkType: hard + +"@firebase/util@npm:1.15.1": + version: 1.15.1 + resolution: "@firebase/util@npm:1.15.1" + dependencies: + tslib: "npm:^2.1.0" + checksum: 10c0/7fdb0dd80c98181969375d7a2ee9309783061a77e63c0ddc4935915ca97917cb1bc560bcfd21b9b4171630cae8579f6f21241ae260e4b18789e14adbdd329eea + languageName: node + linkType: hard + +"@firebase/webchannel-wrapper@npm:1.0.6": + version: 1.0.6 + resolution: "@firebase/webchannel-wrapper@npm:1.0.6" + checksum: 10c0/2597e8a9482b47266a8ac2f6dea80062391c90170ad602c315154e1cddeb5eb6b8e609055f26cf31d4a231baa6cd94b628c9d5d26e5b0e44d645833d5b763a5f + languageName: node + linkType: hard + "@fishjam-cloud/protobufs@workspace:*, @fishjam-cloud/protobufs@workspace:^, @fishjam-cloud/protobufs@workspace:packages/protobufs": version: 0.0.0-use.local resolution: "@fishjam-cloud/protobufs@workspace:packages/protobufs" @@ -4237,7 +4779,17 @@ __metadata: languageName: node linkType: hard -"@grpc/proto-loader@npm:^0.7.13": +"@grpc/grpc-js@npm:~1.9.0": + version: 1.9.16 + resolution: "@grpc/grpc-js@npm:1.9.16" + dependencies: + "@grpc/proto-loader": "npm:^0.7.8" + "@types/node": "npm:>=12.12.47" + checksum: 10c0/dfe2b1a670d650489fe82bb7afc1c0336313b0cde4a102623a1331267cbdeb1bde9409143fb1432db074bc1845b38d96b82a4277bdd99929aa8322cf97d305aa + languageName: node + linkType: hard + +"@grpc/proto-loader@npm:^0.7.13, @grpc/proto-loader@npm:^0.7.8": version: 0.7.15 resolution: "@grpc/proto-loader@npm:0.7.15" dependencies: @@ -5200,6 +5752,22 @@ __metadata: languageName: node linkType: hard +"@react-native-firebase/app@npm:^25.1.0": + version: 25.1.0 + resolution: "@react-native-firebase/app@npm:25.1.0" + dependencies: + firebase: "npm:12.15.0" + peerDependencies: + expo: ">=47.0.0" + react: "*" + react-native: "*" + peerDependenciesMeta: + expo: + optional: true + checksum: 10c0/c6939ee4bc6613a67099252f027494e9e5e687baa6c42de8627719eb39a2128a2dd11f08e87e6c7907467d92489288dae9bb4200baab1598c07df06b498b5377 + 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" @@ -6216,6 +6784,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:>=12.12.47": + version: 26.1.0 + resolution: "@types/node@npm:26.1.0" + dependencies: + undici-types: "npm:~8.3.0" + checksum: 10c0/61c22ad1ef215e24138cb8b7368fb68bab9de4c123b3f23d411749b015ba1b7d22f878ad54add26a0b69068d7e07c04af665069d8571b6c6c3b9b2fb73b7bd91 + languageName: node + linkType: hard + "@types/node@npm:^18.11.18": version: 18.19.86 resolution: "@types/node@npm:18.19.86" @@ -11259,6 +11836,15 @@ __metadata: languageName: node linkType: hard +"faye-websocket@npm:0.11.4": + version: 0.11.4 + resolution: "faye-websocket@npm:0.11.4" + dependencies: + websocket-driver: "npm:>=0.5.1" + checksum: 10c0/c6052a0bb322778ce9f89af92890f6f4ce00d5ec92418a35e5f4c6864a4fe736fec0bcebd47eac7c0f0e979b01530746b1c85c83cb04bae789271abf19737420 + languageName: node + linkType: hard + "fb-watchman@npm:^2.0.0": version: 2.0.2 resolution: "fb-watchman@npm:2.0.2" @@ -11401,6 +11987,42 @@ __metadata: languageName: node linkType: hard +"firebase@npm:12.15.0": + version: 12.15.0 + resolution: "firebase@npm:12.15.0" + dependencies: + "@firebase/ai": "npm:2.13.1" + "@firebase/analytics": "npm:0.10.22" + "@firebase/analytics-compat": "npm:0.2.28" + "@firebase/app": "npm:0.15.0" + "@firebase/app-check": "npm:0.12.0" + "@firebase/app-check-compat": "npm:0.4.5" + "@firebase/app-compat": "npm:0.5.14" + "@firebase/app-types": "npm:0.9.5" + "@firebase/auth": "npm:1.13.3" + "@firebase/auth-compat": "npm:0.6.8" + "@firebase/data-connect": "npm:0.7.1" + "@firebase/database": "npm:1.1.3" + "@firebase/database-compat": "npm:2.1.4" + "@firebase/firestore": "npm:4.16.0" + "@firebase/firestore-compat": "npm:0.4.11" + "@firebase/functions": "npm:0.13.5" + "@firebase/functions-compat": "npm:0.4.5" + "@firebase/installations": "npm:0.6.22" + "@firebase/installations-compat": "npm:0.2.22" + "@firebase/messaging": "npm:0.13.0" + "@firebase/messaging-compat": "npm:0.2.27" + "@firebase/performance": "npm:0.7.12" + "@firebase/performance-compat": "npm:0.2.25" + "@firebase/remote-config": "npm:0.8.5" + "@firebase/remote-config-compat": "npm:0.2.26" + "@firebase/storage": "npm:0.14.3" + "@firebase/storage-compat": "npm:0.4.3" + "@firebase/util": "npm:1.15.1" + checksum: 10c0/a9b575fd9b2582c8f02772cea5a58c79f68ad80911cc1507663fde3f17493ca28fe5292e5e2c3dc68539aba0812e1bcd36d688f8bacd9ab329bf42aeaf0d3b65 + languageName: node + linkType: hard + "fishjam-chat@workspace:examples/mobile-client/fishjam-chat": version: 0.0.0-use.local resolution: "fishjam-chat@workspace:examples/mobile-client/fishjam-chat" @@ -12154,6 +12776,13 @@ __metadata: languageName: node linkType: hard +"http-parser-js@npm:>=0.5.1": + version: 0.5.10 + resolution: "http-parser-js@npm:0.5.10" + checksum: 10c0/8bbcf1832a8d70b2bd515270112116333add88738a2cc05bfb94ba6bde3be4b33efee5611584113818d2bcf654fdc335b652503be5a6b4c0b95e46f214187d93 + languageName: node + linkType: hard + "http-proxy-agent@npm:^5.0.0": version: 5.0.0 resolution: "http-proxy-agent@npm:5.0.0" @@ -12234,6 +12863,13 @@ __metadata: languageName: node linkType: hard +"idb@npm:7.1.1": + version: 7.1.1 + resolution: "idb@npm:7.1.1" + checksum: 10c0/72418e4397638797ee2089f97b45fc29f937b830bc0eb4126f4a9889ecf10320ceacf3a177fe5d7ffaf6b4fe38b20bbd210151549bfdc881db8081eed41c870d + languageName: node + linkType: hard + "idb@npm:8.0.3": version: 8.0.3 resolution: "idb@npm:8.0.3" @@ -15676,6 +16312,13 @@ __metadata: languageName: node linkType: hard +"re2js@npm:^0.4.2": + version: 0.4.3 + resolution: "re2js@npm:0.4.3" + checksum: 10c0/6fd9c438afa2438d102c355c00a05144a771b4e64207e25d379592b4d3fc0d7822eae6011181a4326e7c37bee0488d5a0332cc21b236bf712b619c1d329639bd + languageName: node + linkType: hard + "react-devtools-core@npm:^6.1.5": version: 6.1.5 resolution: "react-devtools-core@npm:6.1.5" @@ -16598,7 +17241,7 @@ __metadata: languageName: node linkType: hard -"safe-buffer@npm:5.2.1, safe-buffer@npm:~5.2.0": +"safe-buffer@npm:5.2.1, safe-buffer@npm:>=5.1.0, safe-buffer@npm:~5.2.0": version: 5.2.1 resolution: "safe-buffer@npm:5.2.1" checksum: 10c0/6501914237c0a86e9675d4e51d89ca3c21ffd6a31642efeba25ad65720bce6921c9e7e974e5be91a786b25aa058b5303285d3c15dbabf983a919f5f630d349f3 @@ -18415,6 +19058,13 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:~8.3.0": + version: 8.3.0 + resolution: "undici-types@npm:8.3.0" + checksum: 10c0/c8aa7e2fbebfce519654dafadc0ece59be888d2ccaf180fb4495da875e7b536d2456345c384069c7e6f3e9c9ab7435f074957da306f142343eee86ff8048855a + languageName: node + linkType: hard + "undici@npm:^5.28.5": version: 5.29.0 resolution: "undici@npm:5.29.0" @@ -18945,6 +19595,7 @@ __metadata: dependencies: "@fishjam-cloud/react-native-client": "workspace:*" "@react-native-async-storage/async-storage": "npm:^3.1.1" + "@react-native-firebase/app": "npm:^25.1.0" "@types/react": "npm:~19.1.0" eslint-config-expo: "npm:~8.0.1" expo: "npm:~54.0.30" @@ -19015,6 +19666,13 @@ __metadata: languageName: node linkType: hard +"web-vitals@npm:^4.2.4": + version: 4.2.4 + resolution: "web-vitals@npm:4.2.4" + checksum: 10c0/383c9281d5b556bcd190fde3c823aeb005bb8cf82e62c75b47beb411014a4ed13fa5c5e0489ed0f1b8d501cd66b0bebcb8624c1a75750bd5df13e2a3b1b2d194 + languageName: node + linkType: hard + "webidl-conversions@npm:^3.0.0": version: 3.0.1 resolution: "webidl-conversions@npm:3.0.1" @@ -19043,6 +19701,24 @@ __metadata: languageName: node linkType: hard +"websocket-driver@npm:>=0.5.1": + version: 0.7.5 + resolution: "websocket-driver@npm:0.7.5" + dependencies: + http-parser-js: "npm:>=0.5.1" + safe-buffer: "npm:>=5.1.0" + websocket-extensions: "npm:>=0.1.1" + checksum: 10c0/843a3c9f529ce07f2721829f70f62986247525ab22625e857b69da13dc90967bacfe57b85e196801b565cd797e309ddd5b06fa8435732e34e8a8ab6201d7abed + languageName: node + linkType: hard + +"websocket-extensions@npm:>=0.1.1": + version: 0.1.4 + resolution: "websocket-extensions@npm:0.1.4" + checksum: 10c0/bbc8c233388a0eb8a40786ee2e30d35935cacbfe26ab188b3e020987e85d519c2009fe07cfc37b7f718b85afdba7e54654c9153e6697301f72561bfe429177e0 + languageName: node + linkType: hard + "whatwg-encoding@npm:^2.0.0": version: 2.0.0 resolution: "whatwg-encoding@npm:2.0.0" From 93fb54945af23d71e779440d8cfee1ae4f53ea59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gadomski?= Date: Tue, 7 Jul 2026 15:18:28 +0200 Subject: [PATCH 06/52] Decouple sdk from callkit only --- .../voip-call/app/src/voip/VoipProvider.tsx | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx b/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx index 1ecb51795..530c7aac9 100644 --- a/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx +++ b/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx @@ -4,6 +4,7 @@ import { useConnection, useMicrophone, usePeers, + useTelecom, useVoIPEvents, type VoipIncomingPayload, } from '@fishjam-cloud/react-native-client'; @@ -14,6 +15,7 @@ import { useMemo, useState, } from 'react'; +import { Platform } from 'react-native'; import { type CurrentCall, @@ -64,8 +66,23 @@ export function VoipProvider({ const { startMicrophone, stopMicrophone } = useMicrophone(); const { joinRoom, leaveRoom } = useConnection(); const { startCallKitSession, endCallKitSession } = useCallKit(); + const { startCall: startTelecomSession, endCall: endTelecomSession } = + useTelecom(); const { remotePeers } = usePeers(); + const startNativeCallSession = useCallback( + (to: string) => + Platform.OS === 'ios' + ? startCallKitSession({ displayName: to, isVideo }) + : startTelecomSession({ displayName: to, isVideo }), + [startCallKitSession, startTelecomSession, isVideo], + ); + + const endNativeCallSession = useCallback( + () => (Platform.OS === 'ios' ? endCallKitSession() : endTelecomSession()), + [endCallKitSession, endTelecomSession], + ); + const handleJoinRoom = useCallback( async (roomName: string) => { const token = await getPeerToken(roomName); @@ -85,11 +102,11 @@ export function VoipProvider({ }, [leaveRoom, stopCamera, stopMicrophone]); const endCall = useCallback(async () => { - await endCallKitSession(); + await endNativeCallSession(); await handleLeaveRoom(); setCurrentCall(null); setStatus('available'); - }, [endCallKitSession, handleLeaveRoom]); + }, [endNativeCallSession, handleLeaveRoom]); const startCall = useCallback( async (to: string, roomName: string) => { @@ -103,14 +120,14 @@ export function VoipProvider({ try { await requestCall({ to, roomName, isVideo }); - await startCallKitSession({ displayName: to, isVideo }); + await startNativeCallSession(to); await handleJoinRoom(roomName); } catch (err) { console.error('Failed to start call:', err); await endCall(); } }, - [requestCall, handleJoinRoom, startCallKitSession, isVideo], + [requestCall, handleJoinRoom, startNativeCallSession, isVideo], ); const answerCall = useCallback(async () => { From f5bd82728c1d8bcf5d74eabd0b1434d8959c9d0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gadomski?= Date: Tue, 7 Jul 2026 19:36:26 +0200 Subject: [PATCH 07/52] Keep the current call in a ref to prevent race condition --- .../voip-call/app/src/voip/VoipProvider.tsx | 35 +++++++++++++------ 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx b/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx index 530c7aac9..d409a29c1 100644 --- a/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx +++ b/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx @@ -13,6 +13,7 @@ import { useCallback, useEffect, useMemo, + useRef, useState, } from 'react'; import { Platform } from 'react-native'; @@ -62,6 +63,8 @@ export function VoipProvider({ const [voipToken, setVoipToken] = useState(null); const [status, setStatus] = useState('available'); const [currentCall, setCurrentCall] = useState(null); + + const currentCallRef = useRef(null); const { startCamera, stopCamera } = useCamera(); const { startMicrophone, stopMicrophone } = useMicrophone(); const { joinRoom, leaveRoom } = useConnection(); @@ -104,18 +107,21 @@ export function VoipProvider({ const endCall = useCallback(async () => { await endNativeCallSession(); await handleLeaveRoom(); + currentCallRef.current = null; setCurrentCall(null); setStatus('available'); }, [endNativeCallSession, handleLeaveRoom]); const startCall = useCallback( async (to: string, roomName: string) => { - setCurrentCall({ + const call: CurrentCall = { roomName, displayName: to, isVideo, startedAt: null, - }); + }; + currentCallRef.current = call; + setCurrentCall(call); setStatus('connecting'); try { @@ -131,46 +137,55 @@ export function VoipProvider({ ); const answerCall = useCallback(async () => { - if (!currentCall) return; + const call = currentCallRef.current; + if (!call) return; setStatus('connecting'); try { - await handleJoinRoom(currentCall.roomName); + await handleJoinRoom(call.roomName); } catch (err) { console.error('Failed to join room on answer:', err); await endCall(); } - }, [handleJoinRoom, endCall, currentCall]); + }, [handleJoinRoom, endCall]); useVoIPEvents({ onRegistered: useCallback((token: string) => { setVoipToken(token); + console.log('onRegistered', token); }, []), onIncoming: useCallback((payload: VoipIncomingPayload) => { - setCurrentCall({ + console.log('onIncoming', payload); + const call: CurrentCall = { roomName: payload.roomName, displayName: payload.displayName, isVideo: payload.isVideo, startedAt: null, - }); + }; + currentCallRef.current = call; + setCurrentCall(call); setStatus('incoming'); }, []), onAnswered: useCallback(async () => { + console.log('onAnswered'); await answerCall(); }, [answerCall]), onEnded: useCallback(async () => { + console.log('onEnded'); await endCall(); }, [endCall]), }); useEffect(() => { if (status === 'connecting' && remotePeers.length > 0) { - setCurrentCall((prev) => - prev ? { ...prev, startedAt: Date.now() } : prev, - ); + if (currentCallRef.current) { + const call = { ...currentCallRef.current, startedAt: Date.now() }; + currentCallRef.current = call; + setCurrentCall(call); + } setStatus('active'); } else if (status === 'active' && remotePeers.length === 0) { endCall().catch((err) => console.error('Failed to end call:', err)); From 1da7c6d71c1e30b19b5a3af1572332616fbb993c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gadomski?= Date: Tue, 7 Jul 2026 20:53:11 +0200 Subject: [PATCH 08/52] Remove testing files --- examples/mobile-client/voip-call/app/App.tsx | 5 +- .../app/src/screens/TelecomTestScreen.tsx | 216 ------------------ 2 files changed, 1 insertion(+), 220 deletions(-) delete mode 100644 examples/mobile-client/voip-call/app/src/screens/TelecomTestScreen.tsx diff --git a/examples/mobile-client/voip-call/app/App.tsx b/examples/mobile-client/voip-call/app/App.tsx index 3c5e19374..8a8be98c0 100644 --- a/examples/mobile-client/voip-call/app/App.tsx +++ b/examples/mobile-client/voip-call/app/App.tsx @@ -12,7 +12,6 @@ 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 { TelecomTestScreen } from './src/screens/TelecomTestScreen'; import { BrandColors } from './src/theme/colors'; import { UserProvider, useUser } from './src/user'; import { VoipProvider, useVoip } from './src/voip'; @@ -57,7 +56,7 @@ function VoipWrapper({ children }: PropsWithChildren) { + isVideo={false}> {children} @@ -115,8 +114,6 @@ const App = () => ( - {/* Dev-only: exercises the Android Telecom native path directly. */} - diff --git a/examples/mobile-client/voip-call/app/src/screens/TelecomTestScreen.tsx b/examples/mobile-client/voip-call/app/src/screens/TelecomTestScreen.tsx deleted file mode 100644 index e61a84737..000000000 --- a/examples/mobile-client/voip-call/app/src/screens/TelecomTestScreen.tsx +++ /dev/null @@ -1,216 +0,0 @@ -import { - getVoipToken, - useTelecom, - useTelecomEvent, - type TelecomEvent, -} from '@fishjam-cloud/react-native-webrtc'; -import { useCallback, useEffect, useState } from 'react'; -import { - Modal, - PermissionsAndroid, - Platform, - Pressable, - ScrollView, - StyleSheet, - Text, - TouchableOpacity, - View, -} from 'react-native'; - -/** - * Throwaway dev screen to exercise the Android Telecom native path directly, - * before it's wired into VoipProvider. Renders a floating button that opens a - * panel with the raw Telecom controls + a live event log. - */ -async function ensureNotificationPermission() { - if (Platform.OS !== 'android' || Platform.Version < 33) return; - try { - await PermissionsAndroid.request( - PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS, - ); - } catch { - // ignore — the user can grant it manually via adb / settings - } -} - -export function TelecomTestScreen() { - const [open, setOpen] = useState(false); - const [log, setLog] = useState([]); - const [state, setState] = useState({ hasActiveCall: false, isAnswered: false }); - - const { - startCall, - reportIncomingCall, - answerCall, - setCallActive, - endCall, - hasActiveCall, - isAnswered, - } = useTelecom(); - - const append = useCallback((line: string) => { - const stamp = new Date().toLocaleTimeString(); - setLog((prev) => [`${stamp} ${line}`, ...prev].slice(0, 40)); - }, []); - - const refreshState = useCallback(() => { - setState({ hasActiveCall: hasActiveCall(), isAnswered: isAnswered() }); - }, [hasActiveCall, isAnswered]); - - useTelecomEvent( - useCallback( - (e: TelecomEvent) => { - append(`event → ${JSON.stringify(e)}`); - refreshState(); - }, - [append, refreshState], - ), - ); - - useEffect(() => { - getVoipToken().then((token) => - console.log('[voip] FCM installation id =', token), - ); - }, []); - - useEffect(() => { - if (open) { - ensureNotificationPermission(); - refreshState(); - } - }, [open, refreshState]); - - const run = (label: string, fn: () => Promise) => async () => { - try { - await fn(); - append(`${label} → ok`); - } catch (err) { - append(`${label} FAILED: ${(err as Error)?.message ?? err}`); - } - refreshState(); - }; - - return ( - <> - setOpen(true)} - accessibilityLabel="Open Telecom test panel"> - ☎︎ - - - setOpen(false)}> - - - Telecom native test - setOpen(false)}> - Close - - - - - - hasActiveCall: {String(state.hasActiveCall)} - - - isAnswered: {String(state.isAnswered)} - - - - - - - - - reportIncomingCall({ displayName: 'Alice', isVideo: false }), - )} - /> - - - startCall({ displayName: 'Bob', isVideo: false }), - )} - /> - - - - - Event log - - {log.map((line, i) => ( - - {line} - - ))} - - - - - ); -} - -function Btn({ label, onPress }: { label: string; onPress: () => void }) { - return ( - - {label} - - ); -} - -const styles = StyleSheet.create({ - fab: { - position: 'absolute', - right: 16, - bottom: 32, - width: 56, - height: 56, - borderRadius: 28, - backgroundColor: '#2563EB', - alignItems: 'center', - justifyContent: 'center', - elevation: 6, - zIndex: 999, - }, - fabText: { color: '#fff', fontSize: 24 }, - container: { flex: 1, backgroundColor: '#0B1220', padding: 16 }, - header: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginTop: 32, - marginBottom: 12, - }, - title: { color: '#fff', fontSize: 20, fontWeight: '600' }, - close: { color: '#93C5FD', fontSize: 16 }, - stateRow: { - flexDirection: 'row', - alignItems: 'center', - gap: 16, - marginBottom: 12, - }, - stateText: { color: '#E5E7EB', fontFamily: 'monospace', fontSize: 12 }, - refresh: { color: '#93C5FD', fontSize: 18 }, - buttons: { gap: 8 }, - btn: { - backgroundColor: '#1F2937', - paddingVertical: 12, - paddingHorizontal: 16, - borderRadius: 8, - }, - btnText: { color: '#fff', fontSize: 15, textAlign: 'center' }, - logTitle: { color: '#9CA3AF', marginTop: 16, marginBottom: 6, fontSize: 13 }, - logBox: { flex: 1, backgroundColor: '#111827', borderRadius: 6 }, - logContent: { padding: 8 }, - logLine: { - color: '#93C5FD', - fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', - fontSize: 11, - marginBottom: 2, - }, -}); From f84a6ce3cfde96104d9c841defe481ff0d1167ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gadomski?= Date: Wed, 8 Jul 2026 13:15:02 +0200 Subject: [PATCH 09/52] Enable toggling speaker on android + correctly set outgoing call with telecom --- .../voip-call/app/src/screens/InCallScreen.tsx | 13 +++++++++++-- .../voip-call/app/src/voip/VoipProvider.tsx | 15 ++++++++++++--- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/examples/mobile-client/voip-call/app/src/screens/InCallScreen.tsx b/examples/mobile-client/voip-call/app/src/screens/InCallScreen.tsx index a85963199..1401c94e4 100644 --- a/examples/mobile-client/voip-call/app/src/screens/InCallScreen.tsx +++ b/examples/mobile-client/voip-call/app/src/screens/InCallScreen.tsx @@ -44,7 +44,8 @@ export function InCallScreen() { const { isMicrophoneOn, toggleMicrophone } = useMicrophone(); const { isCameraOn, toggleCamera } = useCamera(); - const { currentAudioOutput, ios, android } = useAudioOutput(); + const { currentAudioOutput, availableAudioOutputs, ios, android } = + useAudioOutput(); const { remotePeers } = usePeers(); const peerIds = useMemo(() => remotePeers.map((p) => p.id), [remotePeers]); @@ -61,8 +62,16 @@ export function InCallScreen() { 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)); } - // add android later }; const controls = ( diff --git a/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx b/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx index d409a29c1..1ce8aa8be 100644 --- a/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx +++ b/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx @@ -69,8 +69,11 @@ export function VoipProvider({ const { startMicrophone, stopMicrophone } = useMicrophone(); const { joinRoom, leaveRoom } = useConnection(); const { startCallKitSession, endCallKitSession } = useCallKit(); - const { startCall: startTelecomSession, endCall: endTelecomSession } = - useTelecom(); + const { + startCall: startTelecomSession, + endCall: endTelecomSession, + setCallActive: setTelecomCallActive, + } = useTelecom(); const { remotePeers } = usePeers(); const startNativeCallSession = useCallback( @@ -187,10 +190,16 @@ export function VoipProvider({ setCurrentCall(call); } setStatus('active'); + + if (Platform.OS === 'android') { + setTelecomCallActive().catch((err) => + console.warn('Failed to activate telecom call:', err), + ); + } } else if (status === 'active' && remotePeers.length === 0) { endCall().catch((err) => console.error('Failed to end call:', err)); } - }, [remotePeers.length, status, endCall]); + }, [remotePeers.length, status, endCall, setTelecomCallActive]); const voipValue = useMemo( () => ({ From da6998b79e4e2177095308dbc633957b18fad66c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gadomski?= Date: Wed, 8 Jul 2026 19:26:43 +0200 Subject: [PATCH 10/52] Add vibrations + Camera permissions --- examples/mobile-client/voip-call/app/App.tsx | 3 +-- examples/mobile-client/voip-call/app/app.json | 1 + packages/mobile-client/plugin/src/withFishjamVoip.ts | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/examples/mobile-client/voip-call/app/App.tsx b/examples/mobile-client/voip-call/app/App.tsx index 8a8be98c0..75b21cefc 100644 --- a/examples/mobile-client/voip-call/app/App.tsx +++ b/examples/mobile-client/voip-call/app/App.tsx @@ -56,8 +56,7 @@ function VoipWrapper({ children }: PropsWithChildren) { - + isVideo={true}> {children} ); diff --git a/examples/mobile-client/voip-call/app/app.json b/examples/mobile-client/voip-call/app/app.json index 4470186e1..e89ec4366 100644 --- a/examples/mobile-client/voip-call/app/app.json +++ b/examples/mobile-client/voip-call/app/app.json @@ -21,6 +21,7 @@ "googleServicesFile": "./google-services.json", "edgeToEdgeEnabled": true, "permissions": [ + "android.permission.CAMERA", "android.permission.RECORD_AUDIO", "android.permission.MODIFY_AUDIO_SETTINGS", "android.permission.ACCESS_NETWORK_STATE", diff --git a/packages/mobile-client/plugin/src/withFishjamVoip.ts b/packages/mobile-client/plugin/src/withFishjamVoip.ts index 1e71442d9..0bf75025a 100644 --- a/packages/mobile-client/plugin/src/withFishjamVoip.ts +++ b/packages/mobile-client/plugin/src/withFishjamVoip.ts @@ -18,11 +18,13 @@ import type { FishjamPluginOptions } from './types'; * - USE_FULL_SCREEN_INTENT: the incoming-call ring screen over the lock screen. * (The ongoing-call notification is posted by the foreground service and * does not use a full-screen intent.) + * - VIBRATE: the looping ring vibration driven while an incoming call rings. */ const VOIP_PERMISSIONS = [ 'android.permission.MANAGE_OWN_CALLS', 'android.permission.POST_NOTIFICATIONS', 'android.permission.USE_FULL_SCREEN_INTENT', + 'android.permission.VIBRATE', ]; const INCOMING_CALL_ACTIVITY = { From a7b56855d812527029ebd82b70ef89c7def6db95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gadomski?= Date: Thu, 9 Jul 2026 11:23:24 +0200 Subject: [PATCH 11/52] Bump --- packages/react-native-webrtc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native-webrtc b/packages/react-native-webrtc index 97223d162..9050b002c 160000 --- a/packages/react-native-webrtc +++ b/packages/react-native-webrtc @@ -1 +1 @@ -Subproject commit 97223d162a4ae7b175a3eb30a321fea4f4ca8da1 +Subproject commit 9050b002c7c515511ee4ceea75928d128e96b821 From 76621bebd01b99617fba79c0c937c156d7d415cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gadomski?= Date: Thu, 9 Jul 2026 12:17:50 +0200 Subject: [PATCH 12/52] Get rid of unecessary comments --- packages/mobile-client/plugin/src/types.ts | 6 ------ .../mobile-client/plugin/src/withFishjamAndroid.ts | 3 --- packages/mobile-client/plugin/src/withFishjamVoip.ts | 12 +----------- 3 files changed, 1 insertion(+), 20 deletions(-) diff --git a/packages/mobile-client/plugin/src/types.ts b/packages/mobile-client/plugin/src/types.ts index a44212ddf..04dabdf9e 100644 --- a/packages/mobile-client/plugin/src/types.ts +++ b/packages/mobile-client/plugin/src/types.ts @@ -4,12 +4,6 @@ export type FishjamPluginOptions = enableForegroundService?: boolean; enableScreensharing?: boolean; supportsPictureInPicture?: boolean; - /** - * Adds the Telecom (VoIP calling) manifest entries: call permissions, - * the incoming-call ring activity, the notification-action receiver, - * and the foreground service that hosts the ongoing-call notification. - * Leave off if your app doesn't implement calling. - */ enableVoip?: boolean; }; ios?: { diff --git a/packages/mobile-client/plugin/src/withFishjamAndroid.ts b/packages/mobile-client/plugin/src/withFishjamAndroid.ts index 76d989c22..e926051fe 100644 --- a/packages/mobile-client/plugin/src/withFishjamAndroid.ts +++ b/packages/mobile-client/plugin/src/withFishjamAndroid.ts @@ -5,9 +5,6 @@ import { getMainApplicationOrThrow } from '@expo/config-plugins/build/android/Ma import type { FishjamPluginOptions } from './types'; import { withFishjamVoipAndroid } from './withFishjamVoip'; -// The VoIP integration posts its ongoing-call notification through -// WebRTCForegroundService, so enabling VoIP implies the service (and its -// permissions) even when the app never enables the room foreground service. const needsForegroundService = (props: FishjamPluginOptions) => Boolean(props?.android?.enableForegroundService || props?.android?.enableVoip); diff --git a/packages/mobile-client/plugin/src/withFishjamVoip.ts b/packages/mobile-client/plugin/src/withFishjamVoip.ts index 0bf75025a..4a9d42929 100644 --- a/packages/mobile-client/plugin/src/withFishjamVoip.ts +++ b/packages/mobile-client/plugin/src/withFishjamVoip.ts @@ -6,18 +6,12 @@ import type { FishjamPluginOptions } from './types'; /** * Manifest entries required by the Android Telecom (VoIP calling) integration. - * - * These deliberately live in the app's manifest (injected here) instead of the - * react-native-webrtc library manifest, so SDK users who don't build a calling - * app never declare call-related permissions or components. Opt in with - * `android.enableVoip`. + * Opt in with `android.enableVoip`. * * - MANAGE_OWN_CALLS: required to register calls with Telecom via * androidx.core.telecom CallsManager. * - POST_NOTIFICATIONS: the incoming/ongoing CallStyle notifications. * - USE_FULL_SCREEN_INTENT: the incoming-call ring screen over the lock screen. - * (The ongoing-call notification is posted by the foreground service and - * does not use a full-screen intent.) * - VIBRATE: the looping ring vibration driven while an incoming call rings. */ const VOIP_PERMISSIONS = [ @@ -61,10 +55,6 @@ const MESSAGING_SERVICE = { ], }; -// Enables the Firebase Installations path that backs FirebaseMessagingService.onRegistered -// (the non-deprecated token callback PushNotificationService uses). Without it, FCM only -// fires the deprecated onNewToken and onRegistered never fires. Note: this turns on the -// Firebase installation ID, a stable per-install identifier. const INSTALLATION_ID_META = { $: { 'android:name': 'firebase_messaging_installation_id_enabled', From a64a15e9cab00ac0c1dd83d6177f77e4a1802ce5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gadomski?= Date: Thu, 9 Jul 2026 14:15:01 +0200 Subject: [PATCH 13/52] Ask for camera permissions on initial app start --- examples/mobile-client/voip-call/app/App.tsx | 35 ++++++++++++++++++- .../app/src/components/VideoCallView.tsx | 8 ++++- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/examples/mobile-client/voip-call/app/App.tsx b/examples/mobile-client/voip-call/app/App.tsx index 75b21cefc..4af949e46 100644 --- a/examples/mobile-client/voip-call/app/App.tsx +++ b/examples/mobile-client/voip-call/app/App.tsx @@ -1,10 +1,18 @@ import { FishjamProvider, + useCameraPermissions, + useMicrophonePermissions, useSandbox, } from '@fishjam-cloud/react-native-client'; import { StatusBar } from 'expo-status-bar'; import { useCallback, useEffect } from 'react'; -import { ActivityIndicator, StyleSheet, View } from 'react-native'; +import { + ActivityIndicator, + PermissionsAndroid, + Platform, + StyleSheet, + View, +} from 'react-native'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import type { PropsWithChildren } from 'react'; @@ -57,6 +65,7 @@ function VoipWrapper({ children }: PropsWithChildren) { getPeerToken={getPeerToken} requestCall={requestCall} isVideo={true}> + {children} ); @@ -78,9 +87,33 @@ function DeviceRegistration() { return null; } +function requestPermissions() { + const [, requestCamera] = useCameraPermissions(); + const [, requestMicrophone] = useMicrophonePermissions(); + + useEffect(() => { + (async () => { + const microphoneStatus = await requestMicrophone(); + if (microphoneStatus !== 'granted') { + throw new Error('Microphone permission not granted'); + } + const cameraStatus = await requestCamera(); + if (cameraStatus !== 'granted') { + throw new Error('Camera permission not granted'); + } + if (Platform.OS === 'android' && Number(Platform.Version) >= 33) { + await PermissionsAndroid.request( + PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS, + ); + } + })(); + }, [requestCamera, requestMicrophone]); +} + function AppScreens() { const { username, isLoading } = useUser(); const { status } = useVoip(); + requestPermissions(); if (isLoading) { return ( diff --git a/examples/mobile-client/voip-call/app/src/components/VideoCallView.tsx b/examples/mobile-client/voip-call/app/src/components/VideoCallView.tsx index 83ecaf1b6..b578819d5 100644 --- a/examples/mobile-client/voip-call/app/src/components/VideoCallView.tsx +++ b/examples/mobile-client/voip-call/app/src/components/VideoCallView.tsx @@ -35,6 +35,7 @@ export function VideoCallView({ remoteName, localName }: VideoCallViewProps) { mediaStream={remoteStream} objectFit="cover" style={styles.remoteVideo} + zOrder={0} /> ) : ( @@ -44,7 +45,12 @@ export function VideoCallView({ remoteName, localName }: VideoCallViewProps) { {localStream ? ( - + ) : ( From da7c9eed831a8491638b30cefc5b196586ee72f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gadomski?= Date: Thu, 9 Jul 2026 14:23:38 +0200 Subject: [PATCH 14/52] Format + lint --- examples/mobile-client/voip-call/README.md | 8 ++++---- examples/mobile-client/voip-call/app/App.tsx | 4 ++-- .../mobile-client/voip-call/app/src/user/UserProvider.tsx | 7 +++++-- .../mobile-client/voip-call/app/src/voip/VoipProvider.tsx | 2 +- examples/mobile-client/voip-call/server/README.md | 2 +- packages/react-native-webrtc | 2 +- 6 files changed, 14 insertions(+), 11 deletions(-) diff --git a/examples/mobile-client/voip-call/README.md b/examples/mobile-client/voip-call/README.md index e87b72794..647c0d2d7 100644 --- a/examples/mobile-client/voip-call/README.md +++ b/examples/mobile-client/voip-call/README.md @@ -80,12 +80,12 @@ public override func application( `VoipManager` is Objective-C; Swift needs it imported through the bridging header. `ios/voipcall/voipcall-Bridging-Header.h`: -~~~objc +```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 `. @@ -96,10 +96,10 @@ header. `ios/voipcall/voipcall-Bridging-Header.h`: (add the **Push Notifications** capability in Xcode → Signing & Capabilities, which writes this for you). Already present in this app: -~~~xml +```xml aps-environment development -~~~ +``` ## 4. Info.plist — background modes & permissions diff --git a/examples/mobile-client/voip-call/app/App.tsx b/examples/mobile-client/voip-call/app/App.tsx index 4af949e46..7cfa6094f 100644 --- a/examples/mobile-client/voip-call/app/App.tsx +++ b/examples/mobile-client/voip-call/app/App.tsx @@ -87,7 +87,7 @@ function DeviceRegistration() { return null; } -function requestPermissions() { +function useRequestPermissions() { const [, requestCamera] = useCameraPermissions(); const [, requestMicrophone] = useMicrophonePermissions(); @@ -113,7 +113,7 @@ function requestPermissions() { function AppScreens() { const { username, isLoading } = useUser(); const { status } = useVoip(); - requestPermissions(); + useRequestPermissions(); if (isLoading) { return ( diff --git a/examples/mobile-client/voip-call/app/src/user/UserProvider.tsx b/examples/mobile-client/voip-call/app/src/user/UserProvider.tsx index b0cac9966..fa679323d 100644 --- a/examples/mobile-client/voip-call/app/src/user/UserProvider.tsx +++ b/examples/mobile-client/voip-call/app/src/user/UserProvider.tsx @@ -9,7 +9,8 @@ import React, { import { UserContext } from './UserContext'; -const SERVER_URL = process.env.EXPO_PUBLIC_VOIP_SERVER_URL ?? 'http://localhost:4400'; +const SERVER_URL = + process.env.EXPO_PUBLIC_VOIP_SERVER_URL ?? 'http://localhost:4400'; const USERNAME_STORAGE_KEY = 'voip.username'; export function UserProvider({ children }: PropsWithChildren) { @@ -21,7 +22,9 @@ export function UserProvider({ children }: PropsWithChildren) { const fetchUsers = useCallback(async (exclude: string) => { try { - const res = await fetch(`${SERVER_URL}/users?exclude=${encodeURIComponent(exclude)}`); + const res = await fetch( + `${SERVER_URL}/users?exclude=${encodeURIComponent(exclude)}`, + ); if (!res.ok) return; const list: string[] = await res.json(); setUsers(list); diff --git a/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx b/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx index 1ce8aa8be..b87841260 100644 --- a/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx +++ b/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx @@ -136,7 +136,7 @@ export function VoipProvider({ await endCall(); } }, - [requestCall, handleJoinRoom, startNativeCallSession, isVideo], + [requestCall, handleJoinRoom, startNativeCallSession, isVideo, endCall], ); const answerCall = useCallback(async () => { diff --git a/examples/mobile-client/voip-call/server/README.md b/examples/mobile-client/voip-call/server/README.md index f735facea..7bf4ece19 100644 --- a/examples/mobile-client/voip-call/server/README.md +++ b/examples/mobile-client/voip-call/server/README.md @@ -24,7 +24,7 @@ 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 | diff --git a/packages/react-native-webrtc b/packages/react-native-webrtc index 9050b002c..c89c376da 160000 --- a/packages/react-native-webrtc +++ b/packages/react-native-webrtc @@ -1 +1 @@ -Subproject commit 9050b002c7c515511ee4ceea75928d128e96b821 +Subproject commit c89c376dae15db82b656e80417e2cd76f45b0e3e From 35b46a6d3e4bb30292dd9a723139aa7ddbc027c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gadomski?= Date: Thu, 9 Jul 2026 14:28:09 +0200 Subject: [PATCH 15/52] Update readme --- .../mobile-client/voip-call/server/README.md | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/examples/mobile-client/voip-call/server/README.md b/examples/mobile-client/voip-call/server/README.md index 7bf4ece19..d5dce9a78 100644 --- a/examples/mobile-client/voip-call/server/README.md +++ b/examples/mobile-client/voip-call/server/README.md @@ -21,6 +21,18 @@ sandbox host are set at the top of `main.ts`. 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`. + ## API | Method | Path | Body / Query | Description | @@ -42,3 +54,26 @@ The push payload forwarded to the callee's device: ``` 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): + +```json +{ + "message": { + "token": "", + "data": { + "roomName": "", + "displayName": "", + "isVideo": "false" + }, + "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. From c659edbf57350a14f781983d5a6d5f17055465fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gadomski?= Date: Thu, 9 Jul 2026 15:23:28 +0200 Subject: [PATCH 16/52] Distinguish APNs and FCM --- examples/mobile-client/voip-call/app/App.tsx | 3 +- .../mobile-client/voip-call/server/README.md | 21 +++-- .../mobile-client/voip-call/server/main.ts | 85 ++++++++++--------- 3 files changed, 62 insertions(+), 47 deletions(-) diff --git a/examples/mobile-client/voip-call/app/App.tsx b/examples/mobile-client/voip-call/app/App.tsx index 7cfa6094f..ac02b12c7 100644 --- a/examples/mobile-client/voip-call/app/App.tsx +++ b/examples/mobile-client/voip-call/app/App.tsx @@ -77,10 +77,11 @@ function DeviceRegistration() { 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 }), + body: JSON.stringify({ username, voipToken, platform: Platform.OS }), }).catch(() => {}); }, [username, voipToken]); diff --git a/examples/mobile-client/voip-call/server/README.md b/examples/mobile-client/voip-call/server/README.md index d5dce9a78..24d5870f2 100644 --- a/examples/mobile-client/voip-call/server/README.md +++ b/examples/mobile-client/voip-call/server/README.md @@ -33,13 +33,24 @@ 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. Both services must therefore be configured for +cross-platform calls to work. + ## 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 | +| 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 | + +`platform` must be `"ios"` or `"android"`; anything else is rejected with `400`. ## APNs VoIP push payload diff --git a/examples/mobile-client/voip-call/server/main.ts b/examples/mobile-client/voip-call/server/main.ts index b8f7068e1..57f11043a 100644 --- a/examples/mobile-client/voip-call/server/main.ts +++ b/examples/mobile-client/voip-call/server/main.ts @@ -3,18 +3,28 @@ import { JWT } from "google-auth-library"; const db = new Database("voip.db"); -// TODO: voip_token should be primary key but for testing it's easier if i do username :D +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, + platform TEXT NOT NULL CHECK (platform IN ('ios', 'android')), updated_at INTEGER NOT NULL ) `); -// --- FCM push (Android) --- +type PushParams = { + token: string; + roomName: string; + displayName: string; + isVideo: boolean; +}; -const USE_FCM = true; +// --- FCM push (Android) --- type ServiceAccount = { client_email: string; @@ -38,12 +48,7 @@ async function getAccessToken(): Promise { return token; } -async function sendFcmPush(params: { - fcmToken: string; - roomName: string; - displayName: string; - isVideo: boolean; -}): Promise { +async function sendFcmPush(params: PushParams): Promise { const accessToken = await getAccessToken(); const res = await fetch( @@ -56,7 +61,7 @@ async function sendFcmPush(params: { }, body: JSON.stringify({ message: { - token: params.fcmToken, + token: params.token, data: { roomName: params.roomName, displayName: params.displayName, @@ -82,13 +87,8 @@ 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}`, { +async function sendApnsPush(params: PushParams): Promise { + const res = await fetch(`https://${APNS_HOST}/3/device/${params.token}`, { client: apnsClient, method: "POST", headers: { @@ -109,6 +109,14 @@ async function sendVoipPush(params: { } } +// --- Push routing --- + +const sendPush: Record Promise> = + { + ios: sendApnsPush, + android: sendFcmPush, + }; + // --- Helpers --- const json = (data: unknown, status = 200) => Response.json(data, { status }); @@ -119,19 +127,23 @@ Deno.serve({ port: 4400 }, async (req) => { const url = new URL(req.url); console.log(`${req.method} ${url.pathname}`); - // POST /register { username, voipToken } + // POST /register { username, voipToken, platform } if (req.method === "POST" && url.pathname === "/register") { - const { username, voipToken } = (await req.json()) as { + 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); + } 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()], + `INSERT INTO users (username, voip_token, platform, updated_at) VALUES (?, ?, ?, ?) + ON CONFLICT(username) DO UPDATE SET voip_token=excluded.voip_token, platform=excluded.platform, updated_at=excluded.updated_at`, + [username, voipToken, platform, Date.now()], ); return json({ ok: true }); } @@ -157,31 +169,22 @@ Deno.serve({ port: 4400 }, async (req) => { 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} + const calleeRows = db.sql<{ voip_token: string; platform: DevicePlatform }>` + SELECT voip_token, platform FROM users WHERE username = ${to} `; if (calleeRows.length === 0) return json({ error: "callee not found" }, 404); - const voipToken = calleeRows[0].voip_token; + const { voip_token: voipToken, platform } = calleeRows[0]; try { - if (USE_FCM) { - await sendFcmPush({ - fcmToken: voipToken, - roomName: roomName, - displayName: from, - isVideo: isVideo, - }); - } else { - await sendVoipPush({ - voipToken, - roomName: roomName, - displayName: from, - isVideo: isVideo, - }); - } + await sendPush[platform]({ + token: voipToken, + roomName: roomName, + displayName: from, + isVideo: isVideo, + }); } catch (err) { - console.error("Failed to send VoIP push:", err); + console.error(`Failed to send ${platform} VoIP push:`, err); return json({ error: "failed to send VoIP push" }, 502); } From a097da1f8cc62662893b9f51bbf063174fa84776 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gadomski?= Date: Thu, 9 Jul 2026 15:36:22 +0200 Subject: [PATCH 17/52] Bump --- packages/react-native-webrtc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native-webrtc b/packages/react-native-webrtc index c89c376da..5576ef4cc 160000 --- a/packages/react-native-webrtc +++ b/packages/react-native-webrtc @@ -1 +1 @@ -Subproject commit c89c376dae15db82b656e80417e2cd76f45b0e3e +Subproject commit 5576ef4cc8e8782f70f5a5aaa533ae1a1668d549 From a489710fe9cf64bd101ecb928e457e4be6ffa257 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gadomski?= Date: Thu, 9 Jul 2026 15:44:54 +0200 Subject: [PATCH 18/52] Fix copilot issues --- examples/mobile-client/voip-call/app/App.tsx | 6 +- .../voip-call/app/src/voip/VoipProvider.tsx | 4 -- .../mobile-client/voip-call/server/README.md | 10 ++- .../mobile-client/voip-call/server/main.ts | 65 ++++++++++++++----- 4 files changed, 61 insertions(+), 24 deletions(-) diff --git a/examples/mobile-client/voip-call/app/App.tsx b/examples/mobile-client/voip-call/app/App.tsx index ac02b12c7..6e959f3f6 100644 --- a/examples/mobile-client/voip-call/app/App.tsx +++ b/examples/mobile-client/voip-call/app/App.tsx @@ -96,18 +96,18 @@ function useRequestPermissions() { (async () => { const microphoneStatus = await requestMicrophone(); if (microphoneStatus !== 'granted') { - throw new Error('Microphone permission not granted'); + console.warn('Microphone permission not granted — calls will be muted'); } const cameraStatus = await requestCamera(); if (cameraStatus !== 'granted') { - throw new Error('Camera permission not 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/voip/VoipProvider.tsx b/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx index b87841260..3287e9e7b 100644 --- a/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx +++ b/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx @@ -155,11 +155,9 @@ export function VoipProvider({ useVoIPEvents({ onRegistered: useCallback((token: string) => { setVoipToken(token); - console.log('onRegistered', token); }, []), onIncoming: useCallback((payload: VoipIncomingPayload) => { - console.log('onIncoming', payload); const call: CurrentCall = { roomName: payload.roomName, displayName: payload.displayName, @@ -172,12 +170,10 @@ export function VoipProvider({ }, []), onAnswered: useCallback(async () => { - console.log('onAnswered'); await answerCall(); }, [answerCall]), onEnded: useCallback(async () => { - console.log('onEnded'); await endCall(); }, [endCall]), }); diff --git a/examples/mobile-client/voip-call/server/README.md b/examples/mobile-client/voip-call/server/README.md index 24d5870f2..789a448f6 100644 --- a/examples/mobile-client/voip-call/server/README.md +++ b/examples/mobile-client/voip-call/server/README.md @@ -6,6 +6,13 @@ 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 @@ -39,8 +46,7 @@ A push token is only valid with the service that issued it, so each device recor 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. Both services must therefore be configured for -cross-platform calls to work. +Android callee goes out over FCM. ## API diff --git a/examples/mobile-client/voip-call/server/main.ts b/examples/mobile-client/voip-call/server/main.ts index 57f11043a..e0eef28d3 100644 --- a/examples/mobile-client/voip-call/server/main.ts +++ b/examples/mobile-client/voip-call/server/main.ts @@ -24,6 +24,16 @@ type PushParams = { isVideo: boolean; }; +// Each service is optional, so the server runs with only iOS, only Android, or both. +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 = { @@ -32,24 +42,30 @@ type ServiceAccount = { project_id: string; }; -const serviceAccount: ServiceAccount = JSON.parse( - await Deno.readTextFile("./fcm-credentials.json"), -); - -const authClient = new JWT({ - email: serviceAccount.client_email, - key: serviceAccount.private_key, - scopes: ["https://www.googleapis.com/auth/firebase.messaging"], -}); - -async function getAccessToken(): Promise { - const { token } = await authClient.getAccessToken(); +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 { - const accessToken = await getAccessToken(); + 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`, @@ -84,10 +100,15 @@ async function sendFcmPush(params: PushParams): Promise { 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 }); +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", @@ -117,6 +138,17 @@ const sendPush: Record Promise> = android: sendFcmPush, }; +const isConfigured: Record = { + ios: apnsClient !== null, + android: authClient !== null, +}; + +console.log( + `Push services — iOS/APNs: ${isConfigured.ios ? "on" : "off"}, Android/FCM: ${ + isConfigured.android ? "on" : "off" + }`, +); + // --- Helpers --- const json = (data: unknown, status = 200) => Response.json(data, { status }); @@ -175,6 +207,9 @@ Deno.serve({ port: 4400 }, async (req) => { if (calleeRows.length === 0) return json({ error: "callee not found" }, 404); const { voip_token: voipToken, platform } = calleeRows[0]; + if (!isConfigured[platform]) { + return json({ error: `${platform} push is not configured` }, 503); + } try { await sendPush[platform]({ From f933188295be17f1f75f92c606a83ff8b9567579 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gadomski?= Date: Thu, 9 Jul 2026 19:27:55 +0200 Subject: [PATCH 19/52] Remove firebase/app --- examples/mobile-client/voip-call/app/app.json | 1 - examples/mobile-client/voip-call/app/package.json | 1 - 2 files changed, 2 deletions(-) diff --git a/examples/mobile-client/voip-call/app/app.json b/examples/mobile-client/voip-call/app/app.json index e89ec4366..d39514542 100644 --- a/examples/mobile-client/voip-call/app/app.json +++ b/examples/mobile-client/voip-call/app/app.json @@ -29,7 +29,6 @@ ] }, "plugins": [ - "@react-native-firebase/app", [ "@fishjam-cloud/react-native-client", { diff --git a/examples/mobile-client/voip-call/app/package.json b/examples/mobile-client/voip-call/app/package.json index dfaaf8146..beb18f242 100644 --- a/examples/mobile-client/voip-call/app/package.json +++ b/examples/mobile-client/voip-call/app/package.json @@ -12,7 +12,6 @@ "dependencies": { "@fishjam-cloud/react-native-client": "workspace:*", "@react-native-async-storage/async-storage": "^3.1.1", - "@react-native-firebase/app": "^25.1.0", "expo": "~54.0.30", "expo-status-bar": "~3.0.9", "react": "19.1.0", From 5d1b14de373da2d4896cc89b1ccc1d69464cf588 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gadomski?= Date: Thu, 9 Jul 2026 20:13:42 +0200 Subject: [PATCH 20/52] Update README --- packages/mobile-client/README.md | 36 ++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/packages/mobile-client/README.md b/packages/mobile-client/README.md index 45254077e..92624e798 100644 --- a/packages/mobile-client/README.md +++ b/packages/mobile-client/README.md @@ -10,6 +10,42 @@ npm install @fishjam-cloud/react-native-client yarn add @fishjam-cloud/react-native-client ``` +## Android VoIP setup + +Incoming calls on Android are delivered over Firebase Cloud Messaging, so Firebase is +only pulled into your build when you opt in. iOS uses PushKit/APNs and needs none of this. + +### Expo + +Enable VoIP in the config plugin and point Expo at your `google-services.json`: + +```json +{ + "expo": { + "android": { "googleServicesFile": "./google-services.json" }, + "plugins": [["@fishjam-cloud/react-native-client", { "android": { "enableVoip": true } }]] + } +} +``` + +Prebuild does the rest. [`android.googleServicesFile`](https://docs.expo.dev/versions/latest/config/app/#googleservicesfile) +makes Expo add the `com.google.gms:google-services` classpath, apply the Gradle plugin, and copy +the file into `android/app/`. Omitting it while `enableVoip` is on is a prebuild error. + +### Bare React Native + +Config plugins do not run, and the [`google-services` Gradle plugin](https://developers.google.com/android/guides/google-services-plugin) +must be applied to the **application** module — a library cannot do it for you. Follow the +[Firebase Android setup guide](https://firebase.google.com/docs/android/setup) to add +`google-services.json` and apply the plugin. + +You must also declare by hand what the config plugin would otherwise inject into +`AndroidManifest.xml` — the `MANAGE_OWN_CALLS`, `POST_NOTIFICATIONS`, +`USE_FULL_SCREEN_INTENT` and `VIBRATE` permissions, the `IncomingCallActivity`, +the `EndCallNotificationReceiver`, and the `PushNotificationService` with its +`com.google.firebase.MESSAGING_EVENT` intent filter. See `plugin/src/withFishjamVoip.ts` +for the exact entries. + ## Local Development with WebRTC Fork This package depends on `@fishjam-cloud/react-native-webrtc`, a fork of `react-native-webrtc`. The fork lives in [its own GitHub repo](https://github.com/fishjam-cloud/fishjam-react-native-webrtc) and is included in this monorepo as a git submodule at `packages/react-native-webrtc/`, wired up as a yarn workspace. No manual linking is required. From fe408275e8893dd117119ca37f585918fa80d4d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gadomski?= Date: Fri, 10 Jul 2026 13:49:31 +0200 Subject: [PATCH 21/52] Add websocket signaling --- examples/mobile-client/voip-call/README.md | 183 +++++++++++++++++- examples/mobile-client/voip-call/app/App.tsx | 14 +- .../{DirectoryScreen.tsx => UsersScreen.tsx} | 4 +- .../app/src/signaling/useCallSignaling.ts | 98 ++++++++++ .../voip-call/app/src/voip/VoipContext.ts | 2 + .../voip-call/app/src/voip/VoipProvider.tsx | 3 + .../mobile-client/voip-call/server/README.md | 43 +++- .../mobile-client/voip-call/server/main.ts | 109 +++++------ packages/react-native-webrtc | 2 +- 9 files changed, 393 insertions(+), 65 deletions(-) rename examples/mobile-client/voip-call/app/src/screens/{DirectoryScreen.tsx => UsersScreen.tsx} (97%) create mode 100644 examples/mobile-client/voip-call/app/src/signaling/useCallSignaling.ts diff --git a/examples/mobile-client/voip-call/README.md b/examples/mobile-client/voip-call/README.md index 647c0d2d7..fbd2de888 100644 --- a/examples/mobile-client/voip-call/README.md +++ b/examples/mobile-client/voip-call/README.md @@ -1,4 +1,8 @@ -# iOS VoIP Push Notifications — setup +# VoIP Push Notifications — setup + +Sections 1–8 cover **iOS** (APNs + PushKit + CallKit). Android is covered in +[section 9](#9-android-setup) and uses a completely separate transport: +FCM instead of APNs, Telecom instead of CallKit. 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 @@ -184,3 +188,180 @@ re-applied on every prebuild. the app is backgrounded or killed. 4. Tap **Answer** / **End** on the system UI and confirm the `answer` / `ended` events log in Metro. + +--- + +## 9. Android setup + +Android does not use APNs or CallKit. Incoming calls arrive as **FCM** messages and +are surfaced through **Telecom**, so Firebase must be configured. VoIP on Android is +opt-in, and turning it on requires **two** things in `app.json`. Setting only one of +them leaves you with a build that either fails or silently never rings. + +### 9.1 Enable the plugin option + +`android.enableVoip` makes the Fishjam config plugin inject the VoIP manifest entries +— the `MANAGE_OWN_CALLS` / `POST_NOTIFICATIONS` / `USE_FULL_SCREEN_INTENT` / `VIBRATE` +permissions, `IncomingCallActivity`, `EndCallNotificationReceiver`, and the +`PushNotificationService` that receives the FCM wake-up push: + +```json +"plugins": [ + [ + "@fishjam-cloud/react-native-client", + { + "android": { + "enableVoip": true + }, + "ios": { + "enableVoIPBackgroundMode": true + } + } + ] +] +``` + +### 9.2 Point Expo at `google-services.json` + +`android.googleServicesFile` is what actually wires Firebase into the native build: + +```json +"android": { + "package": "io.fishjam.example.voipcall", + "googleServicesFile": "./google-services.json", + "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" + ] +} +``` + +On every `expo prebuild`, Expo's built-in Android mods read this one field and then: + +1. add `classpath 'com.google.gms:google-services'` to `android/build.gradle`, +2. append `apply plugin: 'com.google.gms.google-services'` to `android/app/build.gradle`, +3. copy your file to `android/app/google-services.json`. + +At build time the Gradle plugin turns that JSON into string resources, and at runtime +`FirebaseInitProvider` (a `ContentProvider` shipped inside `firebase-common`) reads them +and initializes Firebase **before** `Application.onCreate` — which is what lets an FCM +push wake a killed app and reach `PushNotificationService`. No JS Firebase SDK and no +`@react-native-firebase/app` config plugin are involved. + +> Because the copy happens during prebuild, never place `google-services.json` inside +> `android/` by hand — that directory is generated and `expo prebuild --clean` wipes it. +> The app root is the durable location. + +### 9.3 Obtain `google-services.json` + +The file is **gitignored** (`app/.gitignore`), so it never arrives via `git clone` and +each developer must fetch their own. In the [Firebase console](https://console.firebase.google.com/), +open the same project the server's `fcm-credentials.json` came from (see +[`server/README.md`](./server/README.md)) → *Project settings* → *Your apps* → add an +**Android** app if none exists → download `google-services.json` → save it at +`app/google-services.json`. + +The app's `package_name` in that file must match `android.package` in `app.json` +exactly (`io.fishjam.example.voipcall`), or the Gradle plugin fails the build with +`No matching client found for package name`. + +### 9.4 What goes wrong if you set only one + +| `enableVoip` | `googleServicesFile` | Result | +| --- | --- | --- | +| `true` | set, file present | Works. | +| `true` | set, file missing | `expo prebuild` fails: `Cannot copy google-services.json`. | +| `true` | absent | Prebuild **succeeds**, Firebase is never wired up, pushes never arrive. | +| `false` | absent | Fine — no Firebase, no VoIP. This is the opt-out. | + +The third row is the dangerous one: it fails silently at runtime rather than at build +time. If Android calls never ring, check this first. + +### 9.5 Bare workflow (without Expo) + +Config plugins only run during `expo prebuild`. In a bare React Native project the two +`app.json` fields above do **nothing**, and you own `android/` yourself — so you have to +perform by hand everything sections 9.1 and 9.2 would have generated. + +First, Firebase, per the [Firebase Android setup guide](https://firebase.google.com/docs/android/setup). +The [`google-services` Gradle plugin](https://developers.google.com/android/guides/google-services-plugin) +must be applied to the **application** module — a library cannot apply it on your behalf, +because it reads your `applicationId` to select the matching client from the JSON and +writes string resources into your app's `res/`: + +```groovy +// android/build.gradle +buildscript { + dependencies { + classpath 'com.google.gms:google-services:4.4.1' + } +} +``` + +```groovy +// android/app/build.gradle +apply plugin: 'com.google.gms.google-services' +``` + +Then place `google-services.json` at `android/app/google-services.json` (in a bare +project this *is* the durable location — nothing regenerates the directory). + +Second, the manifest entries. Add to `android/app/src/main/AndroidManifest.xml`: + +```xml + + + + + + + + + + + + + + + + + + +``` + +`packages/mobile-client/plugin/src/withFishjamVoip.ts` is the source of truth for these +entries — if the plugin gains one, mirror it here. + +Note that `firebase-messaging` arrives automatically as a transitive dependency of +`@fishjam-cloud/react-native-webrtc`, so you never declare it yourself. Omitting the +Gradle plugin above does not remove Firebase from your build; it only leaves it +unconfigured, which is the silent failure from the table in 9.4. + +To opt **out** of VoIP in a bare project, simply omit all of the above — the manifest +entries are what activate the feature. + +### 9.6 Testing + +1. Run on a **real device or emulator with Google Play services** (FCM needs them). +2. Confirm the FCM token is logged on startup — that is the push destination the + server sends to (`sendFcmPush` in `server/main.ts`). +3. Kill the app, then trigger a call. The full-screen incoming-call UI should appear + over the lock screen. diff --git a/examples/mobile-client/voip-call/app/App.tsx b/examples/mobile-client/voip-call/app/App.tsx index 6e959f3f6..e7379bd13 100644 --- a/examples/mobile-client/voip-call/app/App.tsx +++ b/examples/mobile-client/voip-call/app/App.tsx @@ -16,10 +16,11 @@ import { import { SafeAreaProvider } from 'react-native-safe-area-context'; 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 { UsersScreen } from './src/screens/UsersScreen'; +import { useCallSignaling } from './src/signaling/useCallSignaling'; import { BrandColors } from './src/theme/colors'; import { UserProvider, useUser } from './src/user'; import { VoipProvider, useVoip } from './src/voip'; @@ -28,8 +29,16 @@ const SERVER_URL = process.env.EXPO_PUBLIC_VOIP_SERVER_URL ?? 'http://localhost:4400'; const SANDBOX_API_URL = process.env.EXPO_PUBLIC_SANDBOX_API_URL ?? ''; +// Thin wrapper that calls the signaling hook. +// Must be inside VoipProvider so useCallSignaling can access useVoip(). +function CallSignaling({ username }: { username: string | null }) { + useCallSignaling({ serverUrl: SERVER_URL, username }); + return null; +} + function VoipWrapper({ children }: PropsWithChildren) { const { username } = useUser(); + const { getSandboxPeerToken } = useSandbox({ sandboxApiUrl: SANDBOX_API_URL, }); @@ -66,6 +75,7 @@ function VoipWrapper({ children }: PropsWithChildren) { requestCall={requestCall} isVideo={true}> + {children} ); @@ -136,7 +146,7 @@ function AppScreens() { return ; } - return ; + return ; } const App = () => ( diff --git a/examples/mobile-client/voip-call/app/src/screens/DirectoryScreen.tsx b/examples/mobile-client/voip-call/app/src/screens/UsersScreen.tsx similarity index 97% rename from examples/mobile-client/voip-call/app/src/screens/DirectoryScreen.tsx rename to examples/mobile-client/voip-call/app/src/screens/UsersScreen.tsx index e180ab153..c24a25966 100644 --- a/examples/mobile-client/voip-call/app/src/screens/DirectoryScreen.tsx +++ b/examples/mobile-client/voip-call/app/src/screens/UsersScreen.tsx @@ -23,7 +23,7 @@ function makeRoomName() { return `voip-${id}`; } -export function DirectoryScreen() { +export function UsersScreen() { const { username, users, refreshUsers, logout } = useUser(); const { status, startCall } = useVoip(); const isCalling = status === 'connecting' || status === 'active'; @@ -44,7 +44,7 @@ export function DirectoryScreen() { - Directory + Users logout()} diff --git a/examples/mobile-client/voip-call/app/src/signaling/useCallSignaling.ts b/examples/mobile-client/voip-call/app/src/signaling/useCallSignaling.ts new file mode 100644 index 000000000..874f40987 --- /dev/null +++ b/examples/mobile-client/voip-call/app/src/signaling/useCallSignaling.ts @@ -0,0 +1,98 @@ +import { useCallback, useEffect, useRef } from 'react'; + +import { type CurrentCall, type VoipCallStatus, useVoip } from '../voip'; + +type Params = { + serverUrl: string; + username: string | null; +}; + +export function useCallSignaling({ serverUrl, username }: Params): void { + const { endCall, currentCall, status } = useVoip(); + + 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(() => { + if (!username) return; + + const wsUrl = + serverUrl.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. + if (msg.type === 'call-cancelled' && !currentCall.isOutgoing) { + void endCall(); + } + // The callee rejected while we (the caller) are still ringing out. + else if (msg.type === 'call-rejected' && currentCall.isOutgoing) { + void endCall(); + } + }; + + return () => { + ws.close(); + socketRef.current = null; + }; + }, [serverUrl, 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<{ status: VoipCallStatus; call: CurrentCall | null }>({ + status, + call: currentCall, + }); + + useEffect(() => { + const { status: prevStatus, call: prevCall } = prevRef.current; + + if (prevCall && prevCall.startedAt === null && status === 'available') { + // Caller cancelled an outgoing call that was still ringing. + if (prevStatus === 'connecting' && prevCall.isOutgoing) { + sendSignal({ + type: 'call-cancelled', + to: prevCall.displayName, + roomName: prevCall.roomName, + }); + } + // Callee rejected an incoming call before answering. + else if (prevStatus === 'incoming' && !prevCall.isOutgoing) { + sendSignal({ + type: 'call-rejected', + to: prevCall.displayName, + roomName: prevCall.roomName, + }); + } + } + + prevRef.current = { status, call: currentCall }; + }, [status, currentCall, sendSignal]); +} diff --git a/examples/mobile-client/voip-call/app/src/voip/VoipContext.ts b/examples/mobile-client/voip-call/app/src/voip/VoipContext.ts index 23af187c2..107316258 100644 --- a/examples/mobile-client/voip-call/app/src/voip/VoipContext.ts +++ b/examples/mobile-client/voip-call/app/src/voip/VoipContext.ts @@ -22,6 +22,8 @@ export type CurrentCall = { isVideo: boolean; /** Timestamp (ms) when the call became `active`, or `null` if not yet connected. */ startedAt: number | null; + /** `true` when this device initiated the call, `false` when receiving it. */ + isOutgoing: boolean; }; /** diff --git a/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx b/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx index 3287e9e7b..bb2400153 100644 --- a/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx +++ b/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx @@ -65,6 +65,7 @@ export function VoipProvider({ const [currentCall, setCurrentCall] = useState(null); const currentCallRef = useRef(null); + const { startCamera, stopCamera } = useCamera(); const { startMicrophone, stopMicrophone } = useMicrophone(); const { joinRoom, leaveRoom } = useConnection(); @@ -122,6 +123,7 @@ export function VoipProvider({ displayName: to, isVideo, startedAt: null, + isOutgoing: true, }; currentCallRef.current = call; setCurrentCall(call); @@ -163,6 +165,7 @@ export function VoipProvider({ displayName: payload.displayName, isVideo: payload.isVideo, startedAt: null, + isOutgoing: false, }; currentCallRef.current = call; setCurrentCall(call); diff --git a/examples/mobile-client/voip-call/server/README.md b/examples/mobile-client/voip-call/server/README.md index 789a448f6..c776e32f0 100644 --- a/examples/mobile-client/voip-call/server/README.md +++ b/examples/mobile-client/voip-call/server/README.md @@ -50,11 +50,44 @@ 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 | +| 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 | + +## 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. + +> **Known limitation:** on iOS, a push can wake the app before the WebSocket +> reconnects. A very fast cancel (< ~1.5 s after the push) may therefore be +> missed on the callee side. This is acceptable for an example; a production +> implementation would add a server-side timeout or an APNs cancel push. `platform` must be `"ios"` or `"android"`; anything else is rejected with `400`. diff --git a/examples/mobile-client/voip-call/server/main.ts b/examples/mobile-client/voip-call/server/main.ts index e0eef28d3..dca47dd0e 100644 --- a/examples/mobile-client/voip-call/server/main.ts +++ b/examples/mobile-client/voip-call/server/main.ts @@ -11,8 +11,8 @@ const isDevicePlatform = (value: unknown): value is DevicePlatform => db.exec(` CREATE TABLE IF NOT EXISTS users ( username TEXT NOT NULL PRIMARY KEY, - voip_token TEXT NOT NULL, - platform TEXT NOT NULL CHECK (platform IN ('ios', 'android')), + voip_token TEXT NOT NULL UNIQUE, + platform TEXT NOT NULL, updated_at INTEGER NOT NULL ) `); @@ -24,16 +24,6 @@ type PushParams = { isVideo: boolean; }; -// Each service is optional, so the server runs with only iOS, only Android, or both. -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 = { @@ -42,30 +32,24 @@ type ServiceAccount = { 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(); +const serviceAccount: ServiceAccount = JSON.parse( + await Deno.readTextFile("./fcm-credentials.json"), +); + +const authClient = new JWT({ + email: serviceAccount.client_email, + key: serviceAccount.private_key, + scopes: ["https://www.googleapis.com/auth/firebase.messaging"], +}); + +async function getAccessToken(): Promise { + const { token } = await authClient.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 accessToken = await getAccessToken(); const res = await fetch( `https://fcm.googleapis.com/v1/projects/${serviceAccount.project_id}/messages:send`, @@ -100,15 +84,10 @@ async function sendFcmPush(params: PushParams): Promise { 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; +const apnsPem = await Deno.readTextFile("./apns.pem"); +const apnsClient = Deno.createHttpClient({ cert: apnsPem, key: apnsPem }); 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", @@ -138,21 +117,14 @@ const sendPush: Record Promise> = android: sendFcmPush, }; -const isConfigured: Record = { - ios: apnsClient !== null, - android: authClient !== null, -}; - -console.log( - `Push services — iOS/APNs: ${isConfigured.ios ? "on" : "off"}, Android/FCM: ${ - isConfigured.android ? "on" : "off" - }`, -); - // --- 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) => { @@ -173,8 +145,7 @@ Deno.serve({ port: 4400 }, async (req) => { return json({ error: 'platform must be "ios" or "android"' }, 400); } db.exec( - `INSERT INTO users (username, voip_token, platform, updated_at) VALUES (?, ?, ?, ?) - ON CONFLICT(username) DO UPDATE SET voip_token=excluded.voip_token, platform=excluded.platform, updated_at=excluded.updated_at`, + `INSERT OR REPLACE INTO users (username, voip_token, platform, updated_at) VALUES (?, ?, ?, ?)`, [username, voipToken, platform, Date.now()], ); return json({ ok: true }); @@ -201,14 +172,14 @@ Deno.serve({ port: 4400 }, async (req) => { if (!from || !to || !roomName) return json({ error: "from, to and roomName are required" }, 400); - const calleeRows = db.sql<{ voip_token: string; platform: DevicePlatform }>` + 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 (!isConfigured[platform]) { - return json({ error: `${platform} push is not configured` }, 503); + if (!isDevicePlatform(platform)) { + return json({ error: "callee registered without a known platform" }, 409); } try { @@ -226,5 +197,35 @@ Deno.serve({ port: 4400 }, async (req) => { 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; + } + return new Response("Not found", { status: 404 }); }); diff --git a/packages/react-native-webrtc b/packages/react-native-webrtc index 5576ef4cc..18fc9fe98 160000 --- a/packages/react-native-webrtc +++ b/packages/react-native-webrtc @@ -1 +1 @@ -Subproject commit 5576ef4cc8e8782f70f5a5aaa533ae1a1668d549 +Subproject commit 18fc9fe98d4ac911c47e7a212ce0478e1e9016ef From 1f8284f2ab80988f50efcac1f3b2588eb1d48a70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gadomski?= Date: Fri, 10 Jul 2026 13:49:49 +0200 Subject: [PATCH 22/52] Add future plans (to delete later) --- .../voip-call/FCM-COEXISTENCE-PLAN.md | 158 +++++++++ .../mobile-client/voip-call/FEATURE-IDEAS.md | 305 ++++++++++++++++++ 2 files changed, 463 insertions(+) create mode 100644 examples/mobile-client/voip-call/FCM-COEXISTENCE-PLAN.md create mode 100644 examples/mobile-client/voip-call/FEATURE-IDEAS.md diff --git a/examples/mobile-client/voip-call/FCM-COEXISTENCE-PLAN.md b/examples/mobile-client/voip-call/FCM-COEXISTENCE-PLAN.md new file mode 100644 index 000000000..10aa8f92a --- /dev/null +++ b/examples/mobile-client/voip-call/FCM-COEXISTENCE-PLAN.md @@ -0,0 +1,158 @@ +# Plan: FCM messaging-service coexistence (Android) + +> Companion to [FEATURE-IDEAS.md](./FEATURE-IDEAS.md) item #7 (⏸ DEFERRED). That item holds +> the original single-forwarder design; this file extends it with research into how other +> VoIP SDKs solve the problem and a layered plan that supports **multiple** push-notification +> libraries and lets the host app **intercept or forward** payloads. + +## Problem (recap) + +Android delivers each FCM message to **exactly one** service per app — the first +`com.google.firebase.MESSAGING_EVENT` match in the merged manifest +([FirebaseMessagingService](https://firebase.google.com/docs/reference/android/com/google/firebase/messaging/FirebaseMessagingService)). +Our `PushNotificationService` claims that slot and consumes everything, so a host app that +also uses expo-notifications / `@react-native-firebase/messaging` / Notifee / OneSignal +loses either its notifications or our calls, plus `onNewToken` on the losing side. +iOS is unaffected — VoIP pushes ride PushKit's separate channel (see FEATURE-IDEAS #7). + +## Research: how other SDKs solve it (verified 2026-07-10) + +### expo-callkit-telecom — inheritance, single hard-coded partner +`ExpoCallKitTelecomMessagingService extends ExpoFirebaseMessagingService` +(expo-notifications' service); non-call messages go to `super.onMessageReceived()`, and the +config plugin strips expo-notifications' own service with +[`tools:node="remove"`](https://developer.android.com/build/manage-manifests#node_markers). +- ✅ Zero config for Expo apps; token callbacks chain via `super.onNewToken()`. +- ❌ Compile-time dependency on expo-notifications; coexists with **only** that library; + requires Expo prebuild. Not transferable to bare RN. + +### Twilio Voice React Native SDK — built-in service + opt-out + JS handler API +Contrary to first impressions, Twilio **does** ship a React Native SDK +([@twilio/voice-react-native-sdk](https://github.com/twilio/twilio-voice-react-native)), and +since v1.2.1 it documents this exact problem in +[out-of-band-firebase-messaging-service.md](https://github.com/twilio/twilio-voice-react-native/blob/main/docs/out-of-band-firebase-messaging-service.md): +- Built-in `FirebaseMessagingService` is ON by default (zero-config path). +- Opt-out via a **boolean resource** — the app adds `android/app/src/main/res/values/config.xml`: + ```xml + false + ``` + (no manifest surgery needed — the service checks the flag at runtime). +- Escape hatch is a **JS API**: `voice.handleFirebaseMessage(remoteMessage.data)` returns + `Promise` (`true` = it was a Twilio call push). The user wires it inside + `@react-native-firebase/messaging`'s + [`onMessage`](https://rnfirebase.io/reference/messaging#onMessage) / + [`setBackgroundMessageHandler`](https://rnfirebase.io/reference/messaging#setBackgroundMessageHandler). +- ⚠️ Known weakness: the out-of-band path depends on RNFB's **headless JS** in killed + state — see [issue #445](https://github.com/twilio/twilio-voice-react-native/issues/445) + (crash answering from killed state via out-of-band service) and + [issue #370](https://github.com/twilio/twilio-voice-react-native/issues/370) + (adding RNFB breaks incoming calls until you go out-of-band). + +### Stream (GetStream) Video React Native SDK — no native service at all +Stream ships **no** `FirebaseMessagingService`. It mandates +`@react-native-firebase/messaging` and exposes JS helpers — a payload discriminator +`isFirebaseStreamVideoMessage(msg)` and a processor `firebaseDataHandler(msg.data)` — that +the user calls inside `onMessage` / `setBackgroundMessageHandler` +([push notification docs](https://getstream.io/video/docs/react-native/incoming-calls/other-than-ringing-setup/react-native/)). +- ✅ Perfect coexistence by construction: the app owns the single handler and chains any + number of SDKs; full payload interception. +- ❌ Forces an RNFB dependency; killed-state handling rides RNFB's headless JS task — + slower to post the CallStyle notification than a native service, and subject to OEM + battery-optimization kills. + +### Pattern summary + +| | Native service? | Multiple PN libs? | Payload interception? | Killed-state robustness | +|---|---|---|---|---| +| expo-callkit-telecom | yes (subclass) | ❌ expo-notifications only | ❌ | ✅ native | +| Twilio Voice RN | yes + opt-out flag | ✅ via JS dispatcher | ✅ JS | ✅ native default / ⚠️ JS out-of-band | +| Stream Video RN | no | ✅ via JS dispatcher | ✅ JS | ⚠️ headless JS only | +| **Ours (planned)** | yes + opt-out | ✅ native fwd + dispatcher | ✅ native + JS | ✅ native in every mode | + +Industry consensus: **a payload discriminator + a public `handleMessage(data): Boolean` +helper + user-owned dispatcher service** is the standard escape hatch. Nobody else offers +automatic *native* forwarding to an unknown next service — that part of our design is a +genuine differentiator (better zero-config story than any of the above). + +## Plan — three layers, all compatible + +### Layer 1 (default, zero config): gated native service with runtime forwarding +The deferred design from FEATURE-IDEAS #7, unchanged: +1. Server adds `voip: "true"` to the FCM data payload; `PushNotificationService` only + handles messages carrying it. +2. Everything else (messages **and** `onNewToken`) is forwarded to the next + `MESSAGING_EVENT` service found via + [`queryIntentServices`](https://developer.android.com/reference/android/content/pm/PackageManager#queryIntentServices(android.content.Intent,%20int)), + instantiated reflectively with the app context attached. +3. `android:priority="1"` on our intent-filter (app manifest, via `withFishjamVoip.ts` and + the bare-RN docs snippet) makes us deterministically first. + +Covers: no other PN library, or exactly one — the 90% case — with native killed-state +handling and no work from the user. + +### Layer 2 (multiple libraries / interception): public native helpers + host-owned dispatcher +Promote the companion helpers to documented public API: +```kotlin +// in PushNotificationService.companion +fun handleVoipMessage(context: Context, message: RemoteMessage): Boolean // true = was a VoIP push, call reported +fun handleNewToken(token: String) +``` +Documented recipe (mirrors Twilio/Stream, but the handler chain runs in **native**, so +killed-state timing is not compromised): +```kotlin +class MyMessagingService : FirebaseMessagingService() { + override fun onMessageReceived(message: RemoteMessage) { + if (PushNotificationService.handleVoipMessage(this, message)) return + // full interception point: inspect/mutate/route message.data here + MyChatSdk.handle(message) // any number of other SDKs, user-chosen order + } + override fun onNewToken(token: String) { + PushNotificationService.handleNewToken(token) + MyChatSdk.onNewToken(token) + } +} +``` +The user's service lives in the **app manifest**, so it outranks ours automatically +([merge priorities](https://developer.android.com/build/manage-manifests#merge_priorities)); +with an explicit disable (Layer 3) there's no ambiguity at all. + +### Layer 3: clean opt-out of the built-in service +Adopt Twilio's **boolean-resource** mechanism — better than manifest surgery because it +needs no `tools:` namespace and works identically in bare RN and Expo: +- Our service reads `R.bool.fishjam_voip_messaging_service_enabled` (default `true`, + defined in the library's `res/values`) at the top of `onMessageReceived`/`onNewToken` + and immediately delegates/forwards when `false`. +- Bare RN: app overrides it in `android/app/src/main/res/values/config.xml`. +- Expo: `withFishjamVoip` prop `android.voipMessagingService: false` (plugin then also + skips injecting our service into the manifest — cleaner than the runtime check alone). + +### Optional Layer 4 (later, if asked): JS-level handler for RNFB users +A JS `handleVoipMessage(data): Promise` wrapper (Twilio-style) so RNFB-centric +apps can keep a pure-JS dispatcher. **Explicitly second-class**: document that killed-state +delivery then depends on RNFB headless JS (Twilio #445-class issues), and recommend the +Layer 2 native dispatcher for production. + +## Implementation checklist (when un-deferred) + +- [ ] `PushNotificationService.kt`: `voip:"true"` gate; runtime forwarding + (`nextMessagingService()` via `queryIntentServices`, reflective `attachBaseContext`); + public companion helpers; `fishjam_voip_messaging_service_enabled` check +- [ ] library `res/values/config.xml`: `fishjam_voip_messaging_service_enabled = true` +- [ ] `server/main.ts`: add `voip: "true"` to FCM data; update payload docs in both READMEs +- [ ] example `AndroidManifest.xml` + README §9 snippet: `android:priority="1"` +- [ ] `withFishjamVoip.ts`: priority on injected intent-filter; `voipMessagingService` prop +- [ ] Docs: coexistence section — Layer 1 default, Layer 2 dispatcher recipe, Layer 3 opt-out +- [ ] iOS: **no change** (PushKit isolation verified — see FEATURE-IDEAS #7; a payload gate + on iOS would violate the report-a-call rule, + [pushRegistry(_:didReceiveIncomingPushWith:for:completion:)](https://developer.apple.com/documentation/pushkit/pkpushregistrydelegate/pushregistry(_:didreceiveincomingpushwith:for:completion:))) +- [ ] Verify: compile (`JAVA_HOME=~/.sdkman/candidates/java/17.0.18-zulu ./gradlew + :fishjam-cloud_react-native-webrtc:compileDebugKotlin`), then a device test with a + second messaging service registered (e.g. RNFB) in both winner orders + +## Open questions + +- Forward non-VoIP messages to the **next** service only (Android-semantics-faithful, + current design) or to **all** other registered services? Next-only recommended; + fan-out risks duplicate notifications when two libraries both display the same message. +- Should Layer 2's `handleVoipMessage` also accept a raw `Map` overload for + hosts that transform payloads before dispatch? diff --git a/examples/mobile-client/voip-call/FEATURE-IDEAS.md b/examples/mobile-client/voip-call/FEATURE-IDEAS.md new file mode 100644 index 000000000..6a4ab083d --- /dev/null +++ b/examples/mobile-client/voip-call/FEATURE-IDEAS.md @@ -0,0 +1,305 @@ +# VoIP feature ideas (from expo-callkit-telecom gap analysis) + +Comparison of `~/Desktop/expo-callkit-telecom` against our `packages/react-native-webrtc` +VoIP layer (`CallKit.ts` / `Telecom.ts` / `VoIP.ts`, `useVoIPEvents` / `useCallKit` / +`useTelecom`) and its native code (`ios/RCTWebRTC/CallKitManager.m`, `VoipManager.m`, +`android/.../voip/*`). Sorted by priority. Analysis: 2026-07-09 (two passes). + +See the bottom for a **mapping to the FCE-3435 task** ("Handle common VoIP patterns"). + +Legend: 🔴 correctness / product quality · 🟠 user-visible polish · 🟡 API maturity · ⚪ DX/docs + +--- + +## P0 — Correctness wins 🔴 + +### 1. Proper answer/connect handshake (fulfill/fail instead of instant-fulfill) +- **Ours:** iOS `performAnswerCallAction` fulfills the `CXAnswerCallAction` immediately + (`CallKitManager.m:160`), so the system UI shows "connected" before WebRTC media exists, + and a failed room-join can never be reported back to CallKit. +- **Theirs:** the action is parked in a `FulfillRequestManager` keyed by `requestId`; + JS connects media then calls `fulfillIncomingCallConnected(requestId)` or + `failIncomingCallConnected()` (fails the CXAction → CallKit tears the call down cleanly), + with a configurable timeout (default 30 s). The iOS delegate literally `await`s the + fulfill/cancel/timeout result before calling `action.fulfill()` / `action.fail()` + (`CallManager+CXProviderDelegate.swift:58-95`). Same pattern on Android. +- Docs: [CXAnswerCallAction](https://developer.apple.com/documentation/callkit/cxanswercallaction) + +### 2. Real outgoing-call connection reporting (+ dialtone hook) +- **Ours:** `startCallWithDisplayName` reports `startedConnecting` and `connected` + back-to-back the moment the transaction succeeds (`CallKitManager.m:76-77`), so the + system call timer starts before the remote party answers. +- **Theirs:** report `startedConnecting`, play optional dialtone (started from + `didActivate audioSession` only while the outgoing call is still `connecting`), and only + report connected when the app calls `reportOutgoingCallConnected(id)`. Correct call + duration in the dynamic island / status bar / Recents. +- Docs: [reportOutgoingCall(with:connectedAt:)](https://developer.apple.com/documentation/callkit/cxprovider/reportoutgoingcall(with:connectedat:)) + +### 3. Call timeouts (incoming / outgoing / fulfill-answer) +- **Ours:** none — an unanswered incoming call rings until something external stops it. +- **Theirs:** three configurable timeouts (incoming 45 s, outgoing 60 s, fulfill 30 s) + baked into Info.plist / manifest metadata by the config plugin; expiry auto-ends the + call with reason `unanswered` and stops the dialtone. + +### 4. `serverCallId` + opaque `metadata` in the push payload, with dedup +- **Ours:** payload hardwired to `roomName` / `displayName` / `isVideo`; any new field + requires native changes on both platforms. No dedup — a duplicate FCM send + double-reports the call on Android. +- **Theirs:** `{ eventId, serverCallId, hasVideo, startedAt, caller: {...}, metadata }` + wrapped under a canonical `incomingCall` key (snake_case `incoming_call` accepted for + back-compat). `metadata` is forwarded verbatim (auth token, chatId, room config…). + `eventId` drives a dedup window on Android (`ExpoCallKitTelecomMessagingService.kt`) + and `Equatable` on iOS. `startedAt` lets the call be backdated. Documented payload + shape in `docs/voip-push.md`. + +### 5. Killed-app decline broadcast (Android) +- **Ours:** if the user declines while the app process is dead, no JS ever runs and the + decline is silently dropped — the caller keeps ringing until a server timeout. +- **Theirs:** a package-internal broadcast carrying the full event JSON (embedding the + session, so `serverCallId` + push `metadata` like a decline auth token are available + with zero app-side state) fires when a call event can't reach a live JS observer. + The host app registers a manifest `BroadcastReceiver` (plugin prop + `androidEventReceiver`) and POSTs the decline to the backend. Their test-push script + even demos it: `--metadata '{"declineToken":"abc"}'`. See their `docs/platform-notes.md`. + +### 6. End reasons (`CallEndedReason`) +- **Ours:** bare `endCall()`, no reasons anywhere. +- **Theirs:** `reportCallEnded(id, reason)` with + `failed | remoteEnded | unanswered | answeredElsewhere | declinedElsewhere`, mapped to + [CXCallEndedReason](https://developer.apple.com/documentation/callkit/cxcallendedreason) + — correct "missed" vs "declined" in Recents, and multi-device "answered elsewhere" + becomes expressible. + +### 7. FCM messaging-service coexistence (Android) — ⏸ DEFERRED +Implemented and verified (compiled) on 2026-07-09, then reverted — to be revisited. +The design below is ready to re-apply. **Extended plan** (research into Twilio / Stream / +expo-callkit-telecom approaches + multi-library support and payload interception): +see [FCM-COEXISTENCE-PLAN.md](./FCM-COEXISTENCE-PLAN.md). + +- **Problem:** `PushNotificationService` extends `FirebaseMessagingService` directly and + silently swallows non-VoIP data messages (`onMessageReceived` returns early). Android + routes `MESSAGING_EVENT` to **one** service per app — so a host app that also uses + `expo-notifications` / react-native-firebase / Notifee either loses its notifications + or our calls. A production blocker for any app with normal pushes. +- **Why routing is deterministic (not an unknown):** our service is declared in the + **app** manifest (bare setup + `withFishjamVoip.ts`), while notification libraries + declare theirs in their **library** manifests; the manifest merger puts app-declared + components first and FCM binds the first `MESSAGING_EVENT` match — so ours always + receives first. Adding `android:priority="1"` to our intent-filter makes this a hard + guarantee instead of a merge-order convention. We do NOT need to know which + notification library the user has. +- **Designed fix** (was in `PushNotificationService.kt`): + 1. VoIP pushes must carry `voip: "true"` in the FCM data (breaking: server must add + it — one line in `server/main.ts` + payload docs in both READMEs). + 2. Non-VoIP messages and `onNewToken` refreshes are relayed to the *next* + `MESSAGING_EVENT` service found via `packageManager.queryIntentServices` — + discovered at runtime, so it works with any notification library, no Expo + dependency (their `super`-delegation trick only works for expo-notifications). + The delegate is instantiated reflectively with the app context attached + (`ContextWrapper.attachBaseContext` via reflection — public SDK method). + 3. Companion helpers `handleVoipMessage(context, message): Boolean` / + `handleNewToken(token)` for the inverse setup where the host insists on keeping + its own service registered and delegates the VoIP path to us. +- **iOS verified, no change needed:** VoIP pushes ride PushKit (`apns-push-type: voip`, + topic `.voip`) — a channel separate from regular APNs; `VoipManager` registers + only `PKPushTypeVoIP` and the pod never touches `UNUserNotificationCenter` / + `didReceiveRemoteNotification`, so no cross-interception is possible in either + direction. A payload gate on iOS would actually be harmful: iOS 13+ kills apps that + receive a VoIP push without reporting a CallKit call. + +--- + +## P1 — User-visible polish 🟠 + +### 8. Rich caller identity: `CallParticipant` + avatar pictures +- **Theirs:** remote party is `{ id, displayName, avatarUrl, phoneNumber, email }`; + we pass a bare display-name string. +- **Avatar on the Android full-screen UI:** their `IncomingCallActivity` downloads + `avatarUrl` (5 s connect/read timeouts, circular crop via + `RoundedBitmapDrawableFactory`, silent initials fallback). Ours already has the + initials circle — drop-in spot for the photo. +- **Avatar in the Android notification (our idea — neither library does it):** set the + downloaded bitmap as the [`Person.Builder.setIcon`](https://developer.android.com/reference/androidx/core/app/Person.Builder#setIcon(androidx.core.graphics.drawable.IconCompat)) + on the CallStyle notification → caller's face in the shade + lock-screen banner, + WhatsApp-style. Needs an async fetch + `notify()` update once the bitmap lands + (post the text-only notification first to stay within Telecom's ~5 s window). +- **iOS caveat:** CallKit's system UI cannot render caller images — `avatarUrl` is still + worth carrying in the session for our own in-app UI. + +### 9. App logo in the iOS CallKit UI (our idea — neither library does it) +- Set [`CXProviderConfiguration.iconTemplateImageData`](https://developer.apple.com/documentation/callkit/cxproviderconfiguration/icontemplateimagedata) + — a 40×40 pt template (alpha-mask) PNG shown as the app button/badge in the native + incoming-call and in-call UI. Without it iOS shows a generic blank button. + Also worth setting `localizedName` explicitly. Ships as a bundled asset; could be a + config-plugin prop (`callkitIconIos`) later. +- Android equivalent exists in their full-screen activity: an app-branding row + (app icon + label from `PackageManager`) at the top of the incoming screen. + +### 10. Custom sounds: ringtone (iOS + Android) and outgoing dialtone +- **iOS ringtone:** bundled sound on + [`CXProviderConfiguration.ringtoneSound`](https://developer.apple.com/documentation/callkit/cxproviderconfiguration/ringtonesound). +- **Android ringtone:** set as the incoming channel sound; channel settings are immutable + after creation, so they embed the ringtone name in the channel id and rotate channels + on config change (`CallNotificationManager.kt` in their repo) — copy that trick. +- **Dialtone:** looping fade-in ringback while an outgoing call connects + (`DialtonePlayer` on both platforms), stopped on connect/timeout/end. We are silent. + +### 11. iOS Recents + Siri ("call X") integration +- `includesCallsInRecents = true` (we set `NO`), handles from `phoneNumber`/`email` as + typed [`CXHandle`](https://developer.apple.com/documentation/callkit/cxhandle)s + (enables Contacts matching → name + photo on the lock screen via the contact card), + [`INStartCallIntent`](https://developer.apple.com/documentation/sirikit/instartcallintent) + handling in the app delegate → `CallIntentReceivedEvent { handle, handleType, hasVideo }`, + `NSUserActivityTypes` registration, and an `outgoingSystem` session origin. + +### 12. Android notification lifecycle: dialing + ended states +- **Theirs:** four states — incoming (ring + FSI), **dialing** ("Dialing…" with hangup + for outgoing; we show nothing while dialing), ongoing (chronometer), and **ended** + (brief "Call ended", auto-dismiss after 2 s). We only do incoming → ongoing. +- Bonus neither has: i18n / copy overrides for channel names and notification strings + ("Incoming video call", "Dialing…") — all hardcoded English in both libraries. + +### 13. Incoming-call activity refinements (Android) — *found on second pass* +Our `IncomingCallActivity` (swipe-to-answer) is nicer visually, but theirs has behaviors +worth porting: +- **Keyguard dismissal:** answer → `KeyguardManager.requestDismissKeyguard` with a + callback, launching the main activity on success/cancel/error. The call is answered + *before* unlock ("audio connects before the device is unlocked", matching iOS). +- **Auto-dismiss via session state:** observes the call-store `Flow` and finishes when + status leaves `RINGING` (answered elsewhere, timeout, remote hangup) + re-checks in + `onResume`. Ours relies on a single `ACTION_CALL_ENDED` broadcast. +- **Video-call affordance:** answer button switches to a videocam icon for video calls. + +--- + +## P2 — API maturity 🟡 + +### 14. Call-session model + store events +- Native `CallStore` of `CallSession { id, options, origin, remoteParticipants, + incomingCallEvent, status, connectedAt, isMuted, isOnHold, dtmfDigits }` with lifecycle + `requesting → ringing/connecting → connected → ended`; + `onCallSessionAdded/Updated/Removed` events + `getActiveCallSession()`. + Ours: two booleans. +- Their example distills this into a ~25-line `useCallSession()` hook (hydrate from + `getActiveCallSession()`, then the three listeners) — the exact shape a `useVoIP` + SDK hook could return. +- Also: `providerDidReset` does full cleanup (stop dialtone, cancel all timeouts, + cancel pending fulfill requests, clear store); ours only nulls the call UUID. + +### 15. Event queueing with `meta: { flushed, timestamp }` +- Per-event queues with limits (1 for most, 0 = drop for stateful ones like mute), + flushed on `startObserving`, every event stamped with meta so JS can tell replayed + cold-start events from live ones. Ours: a single `pendingIncomingCall` slot plus + ad-hoc `isCallAnswered()` re-checks. + +### 16. Mid-call controls +- **Hold:** `setHeld(id, onHold)` + `SetHeldActionEvent` + `isOnHold` on the session. + On Android the core-telecom `onSetActive` / `onSetInactive` callbacks are mapped to + held-state events too — so a **cellular call taking over the device** properly puts + the VoIP call on hold and JS hears about it (`CallManager.kt:699-700` in their repo). + Our iOS delegate has `onCallHeld` but no JS initiator; our Android `CallAction.Hold` + is unreachable from JS and `onSetInactive` is an empty block. +- **DTMF:** `playDTMF(id, digits)` + `DTMFEvent` via + [CXPlayDTMFCallAction](https://developer.apple.com/documentation/callkit/cxplaydtmfcallaction). +- **Video upgrade/downgrade:** `reportVideo(id, enabled)` → session update + audio-session + re-prepare + [`CXCallUpdate`](https://developer.apple.com/documentation/callkit/cxcallupdate) + so the system UI flips audio↔video mid-call. Our `isVideo` is frozen at start. +- **Programmatic answer:** `answerCall(id)` drives a real `CXAnswerCallAction` so + answering from in-app UI keeps CallKit in sync (our iOS has no programmatic answer). +- **Programmatic mute:** `setMuted(id, muted)` via `CXSetMutedCallAction` with + state-dedup, so CallKit's mute button and the in-app one never diverge. + +### 17. Audio session APIs +- `prepareAudioSessionForCall(hasVideo)` / `restoreAudioSession()` — snapshot & restore + the pre-call [AVAudioSession](https://developer.apple.com/documentation/avfaudio/avaudiosession) + config (snapshot-once semantics so repeated prepares don't clobber the saved state; + video → speaker default via `.defaultToSpeaker` + `videoChat` mode, audio → earpiece + via `voiceChat` mode). +- `getAudioSession()` — one cross-platform snapshot: active flags, category/mode/options, + sample rate, mic permission, `isOtherAudioPlaying`, current + available routes with a + normalized port-type enum (`builtInReceiver`, `bluetoothHFP`, `carAudio`, `airPlay`…). +- `setAudioSessionPortOverride(speaker)` — one-call speakerphone toggle. +- `onAudioSessionActivated/Deactivated` (with the affected call ids) and + `onAudioRouteChanged` — driven by a `NotificationCenter` route-change observer, so + route events (AirPods in/out) flow even outside calls. +- We already have `RTCAudioSession.ts` / `useAudioOutput`; the gap is the snapshot/restore + pair, the unified snapshot, and call-linked activation events. + +### 18. VoIP token API shape +- `getVoIPPushToken()` returns `{ token, type: "APNS_VOIP" | "FCM" }` so the backend + knows the transport; a token-**invalidated** event (`token: undefined`) — our iOS + `didInvalidatePushTokenForType` nulls the token and JS never learns; and a small + `useVoIPPushToken()` hook. + +### 19. Malformed-push fallback (iOS) +- PushKit requires reporting a call for every VoIP push or the app is terminated. + On parse failure they report a placeholder "Invalid Call" and immediately end it + (`VoIPPushManager+PKPushRegistryDelegate.swift`). Ours defaults the display name but + JS-side `assertRoomName` can still leave a stuck session. + +### 20. Capture-session info +- `getCaptureSession()`: camera permission + + [`isMultitaskingCameraAccessSupported`](https://developer.apple.com/documentation/avfoundation/avcapturesession/ismultitaskingcameraaccesssupported) + (iOS 16+) — relevant for keeping video capture alive in PiP during CallKit calls. + +--- + +## P3 — DX & docs ⚪ + +### 21. Expo config plugin +- Automates what we document as manual steps: `aps-environment` entitlement, + `UIBackgroundModes` (`voip`, `audio`), mic/camera permission strings (customizable), + Siri intents, timeout constants, sound bundling into Xcode + `res/raw` (with raw-name + sanitization), FCM service manifest surgery (`tools:node="remove"`), `MANAGE_OWN_CALLS` + wiring, `androidEventReceiver` registration. +- Docs: [Expo config plugins](https://docs.expo.dev/config-plugins/introduction/) + +### 22. Test-push server script — *found on second pass* +- `example/server/send-test-push.ts`: zero-dependency (node:crypto + node:http2 + fetch) + script that signs an APNs JWT and an FCM OAuth token from key files and sends the same + `IncomingCallEvent` over both transports, with flags for `--video`, `--display`, + `--phoneNumber` (E.164 → contact matching), `--metadata '{...}'`, `--production`, + `--ios/--android` auto-detection. Our `examples/mobile-client/voip-call/server` could + adopt the flag ergonomics + shared event-builder (`lib/event.ts`) structure. + +### 23. Structured native logging +- Category-based [`os.Logger`](https://developer.apple.com/documentation/os/logger) on + iOS, tagged lazy-lambda logging on Android — vs our `NSLog` / silently-swallowed + exceptions. + +### 24. Docs & discoverability +- Typedoc-generated API reference, docs site, `llms.txt`, documented push-payload shape, + and a keep-alive note (pair with native timers, e.g. + [react-native-nitro-keepalive-timer](https://www.npmjs.com/package/react-native-nitro-keepalive-timer), + for background WebSocket signaling) — directly relevant to our deferred + WebSocket-signaling design. + +--- + +## Mapping to FCE-3435 — "Handle common VoIP patterns" + +Task: make the server aware of the call; production-ready example. + +| Task bullet | Relevant items above | +| --- | --- | +| **WebSocket connection to monitor the call** | Nothing in expo-callkit-telecom does signaling itself — but: background JS timers throttle once the screen locks, so the socket heartbeat needs native keep-alive timers (#24); `serverCallId` + `metadata` (#4) is how the push hands the socket URL/auth to the app; `onIncomingCallReported` fires *before* answer, which is the right moment to open the socket early. | +| **Management token to create rooms (drop sandbox)** | Server-side only — no library feature. Their `example/server` structure (#22) is the pattern: env-validated key files + a shared event builder producing the push payload with `serverCallId` minted at room-creation time. | +| **Inform user if call was rejected** | Callee-side rejection reaching the server even when the app is killed: decline broadcast (#5) with `metadata.declineToken`. Caller-side display of the rejection: `reportCallEnded(id, 'remoteEnded'/'declinedElsewhere')` with `CallEndedReason` (#6) so the system UI ends with the right reason, plus the transient "Call ended" notification state (#12). Multi-device: `answeredElsewhere`/`declinedElsewhere` (#6). | +| **Handle hang up** | The `endCall()` (local user) vs `reportCallEnded(reason)` (remote/server signal) split (#6); terminal events embed the full session (#14) so the hang-up handler has `serverCallId` without extra state; outgoing/incoming timeouts (#3) as the safety net when the socket dies mid-call. | +| **Handle call on-hold** | `setHeld` + `SetHeldActionEvent` + `isOnHold` on the session (#16) — including Android's `onSetActive`/`onSetInactive` mapping so a cellular call auto-holds the VoIP call and JS can pause media + tell the server. | +| **"Almost production-ready example"** | FCM service coexistence (#7) — without it, any consumer app that also shows normal notifications breaks; answer/connect handshake (#1) so a failed room-join doesn't leave a phantom "connected" call; dedup (#4); their example's `useCallSession` / pending-`requestId` hook patterns (#14, #1). | + +--- + +## Keep (things we have that expo-callkit-telecom lacks) + +Don't regress these while adopting the above: +- Swipe-to-answer custom full-screen `IncomingCallActivity`. +- Foreground service with `mediaProjection` type → screen share during calls. +- React warm-up on FCM push (`PushNotificationService.warmUpReact()`). +- Telecom-owned audio-routing replay for cold starts + (`CallManager.setAudioOutputManager`). + +Both Android sides use Jetpack +[core-telecom](https://developer.android.com/jetpack/androidx/releases/core-telecom), +so ports are mostly straightforward. From bc7e232752c70a1526f4dc36eb045ad5d1026311 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gadomski?= Date: Fri, 10 Jul 2026 14:03:40 +0200 Subject: [PATCH 23/52] Make camera PIP on android look better --- .../app/src/components/VideoCallView.tsx | 5 +- .../mobile-client/voip-call/server/README.md | 28 +- yarn.lock | 680 +----------------- 3 files changed, 20 insertions(+), 693 deletions(-) diff --git a/examples/mobile-client/voip-call/app/src/components/VideoCallView.tsx b/examples/mobile-client/voip-call/app/src/components/VideoCallView.tsx index b578819d5..ce06e2005 100644 --- a/examples/mobile-client/voip-call/app/src/components/VideoCallView.tsx +++ b/examples/mobile-client/voip-call/app/src/components/VideoCallView.tsx @@ -47,6 +47,7 @@ export function VideoCallView({ remoteName, localName }: VideoCallViewProps) { {localStream ? ( ` | | 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 | +| 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 | ## 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 +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. @@ -71,24 +71,26 @@ 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": "" } +{ + "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. -> **Known limitation:** on iOS, a push can wake the app before the WebSocket -> reconnects. A very fast cancel (< ~1.5 s after the push) may therefore be -> missed on the callee side. This is acceptable for an example; a production -> implementation would add a server-side timeout or an APNs cancel push. - `platform` must be `"ios"` or `"android"`; anything else is rejected with `400`. ## APNs VoIP push payload diff --git a/yarn.lock b/yarn.lock index c0c0e1683..5b7f19074 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3997,548 +3997,6 @@ __metadata: languageName: node linkType: hard -"@firebase/ai@npm:2.13.1": - version: 2.13.1 - resolution: "@firebase/ai@npm:2.13.1" - dependencies: - "@firebase/app-check-interop-types": "npm:0.3.4" - "@firebase/component": "npm:0.7.3" - "@firebase/logger": "npm:0.5.1" - "@firebase/util": "npm:1.15.1" - tslib: "npm:^2.1.0" - peerDependencies: - "@firebase/app": 0.x - "@firebase/app-types": 0.x - checksum: 10c0/826876c4e4fc9102e33d5afba1d00727efeef9c5a5db2d81c562342c458f0020243e47b5db1eb7ef03aeafc785581a47f42ac5da98f423efecf156a97861b3ad - languageName: node - linkType: hard - -"@firebase/analytics-compat@npm:0.2.28": - version: 0.2.28 - resolution: "@firebase/analytics-compat@npm:0.2.28" - dependencies: - "@firebase/analytics": "npm:0.10.22" - "@firebase/analytics-types": "npm:0.8.4" - "@firebase/component": "npm:0.7.3" - "@firebase/util": "npm:1.15.1" - tslib: "npm:^2.1.0" - peerDependencies: - "@firebase/app-compat": 0.x - checksum: 10c0/e8508b26bc75f47cd90f7cd31b36084439179fe85eac8ebe02f3ac6ffb0176d8f77f1e91de07fedf3307ea3ef0b5c547b89ba175c0f6277829d3df1d48a713e3 - languageName: node - linkType: hard - -"@firebase/analytics-types@npm:0.8.4": - version: 0.8.4 - resolution: "@firebase/analytics-types@npm:0.8.4" - checksum: 10c0/5dd5929eab551855c8338ed97c1a4ef18713501611ea9226f391410800f9736490500d6decdda777c8dc37ac615b59fd50127559e486dfac91690a07394a0fb6 - languageName: node - linkType: hard - -"@firebase/analytics@npm:0.10.22": - version: 0.10.22 - resolution: "@firebase/analytics@npm:0.10.22" - dependencies: - "@firebase/component": "npm:0.7.3" - "@firebase/installations": "npm:0.6.22" - "@firebase/logger": "npm:0.5.1" - "@firebase/util": "npm:1.15.1" - tslib: "npm:^2.1.0" - peerDependencies: - "@firebase/app": 0.x - checksum: 10c0/2f6398e1fe197ff2f3eab5fac7f2296d666799eb1598a35d71e996d365478b35dd00001e57e5e0b4e18d9ea6996a9aded36847dbc88091022c3d1df095df9ad8 - languageName: node - linkType: hard - -"@firebase/app-check-compat@npm:0.4.5": - version: 0.4.5 - resolution: "@firebase/app-check-compat@npm:0.4.5" - dependencies: - "@firebase/app-check": "npm:0.12.0" - "@firebase/app-check-types": "npm:0.5.4" - "@firebase/component": "npm:0.7.3" - "@firebase/logger": "npm:0.5.1" - "@firebase/util": "npm:1.15.1" - tslib: "npm:^2.1.0" - peerDependencies: - "@firebase/app-compat": 0.x - checksum: 10c0/8ab0ddd04a20f8908cb53b728967571e035816dfb43b1ea9753a5f0ceb08c58aa5b3f682244e0e66e87f7a63998d50b1c276da8a833209ce6237124ee79bb458 - languageName: node - linkType: hard - -"@firebase/app-check-interop-types@npm:0.3.4": - version: 0.3.4 - resolution: "@firebase/app-check-interop-types@npm:0.3.4" - checksum: 10c0/1861470d0e3f4fcff5e46bd9e9cc9c2408988eb01d661bac791e0dd511dde20e148503c923c8808f6921a70c523740f102c55615e0393158277d643fcec21c1c - languageName: node - linkType: hard - -"@firebase/app-check-types@npm:0.5.4": - version: 0.5.4 - resolution: "@firebase/app-check-types@npm:0.5.4" - checksum: 10c0/945edf0f14a8e432893a36594028b78c728e56ebbf41543019b0b5319b987e38b7b374268589905b8827bb16af06cbb2682218d9b0bb50efd7d2cab13e40e825 - languageName: node - linkType: hard - -"@firebase/app-check@npm:0.12.0": - version: 0.12.0 - resolution: "@firebase/app-check@npm:0.12.0" - dependencies: - "@firebase/component": "npm:0.7.3" - "@firebase/logger": "npm:0.5.1" - "@firebase/util": "npm:1.15.1" - tslib: "npm:^2.1.0" - peerDependencies: - "@firebase/app": 0.x - checksum: 10c0/ccca7fad754ccfb9862ab8346de61c981759cb6e33851f57410ef375f9e992937883238e7f3c163f4e5bff044e93d1368c949bc2d026a1504f35d5f39459623d - languageName: node - linkType: hard - -"@firebase/app-compat@npm:0.5.14": - version: 0.5.14 - resolution: "@firebase/app-compat@npm:0.5.14" - dependencies: - "@firebase/app": "npm:0.15.0" - "@firebase/component": "npm:0.7.3" - "@firebase/logger": "npm:0.5.1" - "@firebase/util": "npm:1.15.1" - tslib: "npm:^2.1.0" - checksum: 10c0/7cfb3169883850fd23635422fcc9d267a92b10c09d3bc52c2e163c87c885c5da7db4fd0d420fa8ed15153585803328fcfa970010ce5cb15769f32ce4ed77bec3 - languageName: node - linkType: hard - -"@firebase/app-types@npm:0.9.5": - version: 0.9.5 - resolution: "@firebase/app-types@npm:0.9.5" - dependencies: - "@firebase/logger": "npm:0.5.1" - checksum: 10c0/997c2deab31ac1666b10dd9fbcf6198266581ac9d1228c90969331edf1c94554312f3c828748322a444d00dcbf88e06071926c2ba565228f128d7af3e70811bd - languageName: node - linkType: hard - -"@firebase/app@npm:0.15.0": - version: 0.15.0 - resolution: "@firebase/app@npm:0.15.0" - dependencies: - "@firebase/component": "npm:0.7.3" - "@firebase/logger": "npm:0.5.1" - "@firebase/util": "npm:1.15.1" - idb: "npm:7.1.1" - tslib: "npm:^2.1.0" - checksum: 10c0/18c90bb85839172ffe722561cb23869a75329747ecaab9bbe294d0d871986faa9f78139a70ce63939ea41b5c8d7e5b5f9603d5c0a315a0832ab2a1cd5dcfa956 - languageName: node - linkType: hard - -"@firebase/auth-compat@npm:0.6.8": - version: 0.6.8 - resolution: "@firebase/auth-compat@npm:0.6.8" - dependencies: - "@firebase/auth": "npm:1.13.3" - "@firebase/auth-types": "npm:0.13.1" - "@firebase/component": "npm:0.7.3" - "@firebase/util": "npm:1.15.1" - tslib: "npm:^2.1.0" - peerDependencies: - "@firebase/app-compat": 0.x - checksum: 10c0/5af65a62b3b5ad571f3c7db9fc8e75ef7f926b8fa4376052166a6c00d7122e298dcf48fd890a787fa16481b5756a5c27eb8819e3b6b6aec3d0c770c615739be6 - languageName: node - linkType: hard - -"@firebase/auth-interop-types@npm:0.2.5": - version: 0.2.5 - resolution: "@firebase/auth-interop-types@npm:0.2.5" - checksum: 10c0/1a0a72935b2809f1b9c57e1fda2eb53b741cf4f2e78b295d45bf4d14829d51ce3b6f86b6d4ce6b867b5878c223c10032d11fdf82d0ab4a10b30dd4803fa287ae - languageName: node - linkType: hard - -"@firebase/auth-types@npm:0.13.1": - version: 0.13.1 - resolution: "@firebase/auth-types@npm:0.13.1" - peerDependencies: - "@firebase/app-types": 0.x - "@firebase/util": 1.x - checksum: 10c0/598273dfccb4d112fd9e6026433ac8ce4716b7ac466e80c0f9b1f6d892c44a575f52f7e43341fe5bdae863f59f5532816f2acd13479e88db6650d421c17839e2 - languageName: node - linkType: hard - -"@firebase/auth@npm:1.13.3": - version: 1.13.3 - resolution: "@firebase/auth@npm:1.13.3" - dependencies: - "@firebase/component": "npm:0.7.3" - "@firebase/logger": "npm:0.5.1" - "@firebase/util": "npm:1.15.1" - tslib: "npm:^2.1.0" - peerDependencies: - "@firebase/app": 0.x - "@react-native-async-storage/async-storage": ^2.2.0 || ^3.0.0 - peerDependenciesMeta: - "@react-native-async-storage/async-storage": - optional: true - checksum: 10c0/aca4ec9ff0ac4f5a72de994fdb243d02770f64a363f0f7b082c52877f40b4dc208cbc2fadd9b3e6f23c865bcd74cf0dea564c5025fae4e2f8e529c1dca5b8030 - languageName: node - linkType: hard - -"@firebase/component@npm:0.7.3": - version: 0.7.3 - resolution: "@firebase/component@npm:0.7.3" - dependencies: - "@firebase/util": "npm:1.15.1" - tslib: "npm:^2.1.0" - checksum: 10c0/a2c3400afecfa90cf6ec3526579ed3557828aafa8d4c3b34af7221a92ec2d9261c4a85b01c31b89774efcb6194c63f23ff219efe6a9cd842de2a6d41728bbd35 - languageName: node - linkType: hard - -"@firebase/data-connect@npm:0.7.1": - version: 0.7.1 - resolution: "@firebase/data-connect@npm:0.7.1" - dependencies: - "@firebase/auth-interop-types": "npm:0.2.5" - "@firebase/component": "npm:0.7.3" - "@firebase/logger": "npm:0.5.1" - "@firebase/util": "npm:1.15.1" - tslib: "npm:^2.1.0" - peerDependencies: - "@firebase/app": 0.x - checksum: 10c0/e4df223abdaae906ef3c226aadebbfb00ef9eae56bf8c4226ce4cb7a708e4de6ae381db4f7d188f82d2868da3ed64847a09b39c6dec34248528c522d9bb74baa - languageName: node - linkType: hard - -"@firebase/database-compat@npm:2.1.4": - version: 2.1.4 - resolution: "@firebase/database-compat@npm:2.1.4" - dependencies: - "@firebase/component": "npm:0.7.3" - "@firebase/database": "npm:1.1.3" - "@firebase/database-types": "npm:1.0.20" - "@firebase/logger": "npm:0.5.1" - "@firebase/util": "npm:1.15.1" - tslib: "npm:^2.1.0" - checksum: 10c0/e0d889acca69ee55030bc2fdd223d5b692e8a15656aa4403291f57b36401be89e915354260817a7be8f4eaa988d744d36d07933a0e8c9f79a46a971218f02dd6 - languageName: node - linkType: hard - -"@firebase/database-types@npm:1.0.20": - version: 1.0.20 - resolution: "@firebase/database-types@npm:1.0.20" - dependencies: - "@firebase/app-types": "npm:0.9.5" - "@firebase/util": "npm:1.15.1" - checksum: 10c0/bc2848ded44b3bbf0352cd9f5580190b4e82b83c99618e0cf76a84a68850a4cf87954b1d9fff134ae4d930be79188eb5e7e34e3007e25576d630f1c83a7ace0f - languageName: node - linkType: hard - -"@firebase/database@npm:1.1.3": - version: 1.1.3 - resolution: "@firebase/database@npm:1.1.3" - dependencies: - "@firebase/app-check-interop-types": "npm:0.3.4" - "@firebase/auth-interop-types": "npm:0.2.5" - "@firebase/component": "npm:0.7.3" - "@firebase/logger": "npm:0.5.1" - "@firebase/util": "npm:1.15.1" - faye-websocket: "npm:0.11.4" - tslib: "npm:^2.1.0" - checksum: 10c0/90aceab86ffff9bbccc9f1d6bfc83d450e74a6817c3605f8b61b3f8ea028b89f7d81017d12b61ff805506684e12435476da4cb0ce37dfb773c95807709e75e25 - languageName: node - linkType: hard - -"@firebase/firestore-compat@npm:0.4.11": - version: 0.4.11 - resolution: "@firebase/firestore-compat@npm:0.4.11" - dependencies: - "@firebase/component": "npm:0.7.3" - "@firebase/firestore": "npm:4.16.0" - "@firebase/firestore-types": "npm:3.0.4" - "@firebase/util": "npm:1.15.1" - tslib: "npm:^2.1.0" - peerDependencies: - "@firebase/app-compat": 0.x - checksum: 10c0/d0ea00b7bf37f172d64b2dd786f7e919f3314bf745b8b842b0c4029bd3a0b7db787450e274c6582b982d5afe7d7ff32d8d7244f32c166a5760df7c1a66bfd20c - languageName: node - linkType: hard - -"@firebase/firestore-types@npm:3.0.4": - version: 3.0.4 - resolution: "@firebase/firestore-types@npm:3.0.4" - peerDependencies: - "@firebase/app-types": 0.x - "@firebase/util": 1.x - checksum: 10c0/8370f76b9ed06e8a6982af70d077b4b369ebcb322e334180c74d5c581d6487353f1bdf64a5ba0d62cac64dfc5973f4697d81257cf2927e305fa4e54fad3d9e6d - languageName: node - linkType: hard - -"@firebase/firestore@npm:4.16.0": - version: 4.16.0 - resolution: "@firebase/firestore@npm:4.16.0" - dependencies: - "@firebase/component": "npm:0.7.3" - "@firebase/logger": "npm:0.5.1" - "@firebase/util": "npm:1.15.1" - "@firebase/webchannel-wrapper": "npm:1.0.6" - "@grpc/grpc-js": "npm:~1.9.0" - "@grpc/proto-loader": "npm:^0.7.8" - re2js: "npm:^0.4.2" - tslib: "npm:^2.1.0" - peerDependencies: - "@firebase/app": 0.x - checksum: 10c0/f89796fbab2e1305fd17871f1d870d281feae77b0acd2aee6cdb61854269ce235ea9934229ed5da319e3bd5f8e4cd54339d2b88a9814cdc3e68fde9aae267d4d - languageName: node - linkType: hard - -"@firebase/functions-compat@npm:0.4.5": - version: 0.4.5 - resolution: "@firebase/functions-compat@npm:0.4.5" - dependencies: - "@firebase/component": "npm:0.7.3" - "@firebase/functions": "npm:0.13.5" - "@firebase/functions-types": "npm:0.6.4" - "@firebase/util": "npm:1.15.1" - tslib: "npm:^2.1.0" - peerDependencies: - "@firebase/app-compat": 0.x - checksum: 10c0/38c614ec794ad6d702e366670df21f14a5057093264bc871d0101a224921663092b373c3ac08917f299f09b9b867984d934ef123376d75160fcc48a8d480dd6a - languageName: node - linkType: hard - -"@firebase/functions-types@npm:0.6.4": - version: 0.6.4 - resolution: "@firebase/functions-types@npm:0.6.4" - checksum: 10c0/50f1e50fc58f9a297e05c888a555b7bf085be5f5c4284e035cf3ebfc63016fa2b6dbf8de5e4a343b04f98e357525968e6254eabd271eb3b2548fa297770c7f68 - languageName: node - linkType: hard - -"@firebase/functions@npm:0.13.5": - version: 0.13.5 - resolution: "@firebase/functions@npm:0.13.5" - dependencies: - "@firebase/app-check-interop-types": "npm:0.3.4" - "@firebase/auth-interop-types": "npm:0.2.5" - "@firebase/component": "npm:0.7.3" - "@firebase/messaging-interop-types": "npm:0.2.5" - "@firebase/util": "npm:1.15.1" - tslib: "npm:^2.1.0" - peerDependencies: - "@firebase/app": 0.x - checksum: 10c0/92a5d6261feee27aa5ad7f0c1c55e3abf44c3ddd66f65f2bbf1001b06bb1d5bb2fdad21e6539299ae41079a8293da583c11bde0de949d736221426ab0bc663d1 - languageName: node - linkType: hard - -"@firebase/installations-compat@npm:0.2.22": - version: 0.2.22 - resolution: "@firebase/installations-compat@npm:0.2.22" - dependencies: - "@firebase/component": "npm:0.7.3" - "@firebase/installations": "npm:0.6.22" - "@firebase/installations-types": "npm:0.5.4" - "@firebase/util": "npm:1.15.1" - tslib: "npm:^2.1.0" - peerDependencies: - "@firebase/app-compat": 0.x - checksum: 10c0/0ba91d39f68507bdfa1f5cc494bbefc2af7acb885ba226e2ff0005e63233b623242bfef083cb6123a839a4dce28f0a54745932c06d821d04ad0dcdbb6a11b7cc - languageName: node - linkType: hard - -"@firebase/installations-types@npm:0.5.4": - version: 0.5.4 - resolution: "@firebase/installations-types@npm:0.5.4" - peerDependencies: - "@firebase/app-types": 0.x - checksum: 10c0/56148089894be055fa3b98b666bc2e75c6f6c679f79a6fc9b1d65e6f58e2d545df283149c990ae56910e39487a5cd65b4d753dd3b4eeea30b61e1691e7c5f134 - languageName: node - linkType: hard - -"@firebase/installations@npm:0.6.22": - version: 0.6.22 - resolution: "@firebase/installations@npm:0.6.22" - dependencies: - "@firebase/component": "npm:0.7.3" - "@firebase/util": "npm:1.15.1" - idb: "npm:7.1.1" - tslib: "npm:^2.1.0" - peerDependencies: - "@firebase/app": 0.x - checksum: 10c0/c8c0f5135b81254f85d68cee80561abad916238220802a5c5734dbd76e52742b91a61767aa1a14e0363e5aa6fd21c67aad47a7117f9c99a2902ef01af8c86739 - languageName: node - linkType: hard - -"@firebase/logger@npm:0.5.1": - version: 0.5.1 - resolution: "@firebase/logger@npm:0.5.1" - dependencies: - tslib: "npm:^2.1.0" - checksum: 10c0/ce8644943452e2e1cc00fe4eab2d63c8a0ba6767beb0e57d2ea11f609b5ffa6956c2ec8d6a01efae07a4932b70180c8a07540c53e1ddd876b2baa5858185c247 - languageName: node - linkType: hard - -"@firebase/messaging-compat@npm:0.2.27": - version: 0.2.27 - resolution: "@firebase/messaging-compat@npm:0.2.27" - dependencies: - "@firebase/component": "npm:0.7.3" - "@firebase/messaging": "npm:0.13.0" - "@firebase/util": "npm:1.15.1" - tslib: "npm:^2.1.0" - peerDependencies: - "@firebase/app-compat": 0.x - checksum: 10c0/f2831bdce11e51d041766823312425a75d94a71c9b5d9787412f4b45678c6600c32d571763ea3699d7e28a8b422a6376d37c83d21875d602a2ba90c789609cbb - languageName: node - linkType: hard - -"@firebase/messaging-interop-types@npm:0.2.5": - version: 0.2.5 - resolution: "@firebase/messaging-interop-types@npm:0.2.5" - checksum: 10c0/edffb0e4e5b630ee05f65ab3bbcf71657f05f67da645ec4c1f2d3e7a4abf5d541ef38348599ae5fb584ae9b341a74abab9a162be01695f4181e02b234c549159 - languageName: node - linkType: hard - -"@firebase/messaging@npm:0.13.0": - version: 0.13.0 - resolution: "@firebase/messaging@npm:0.13.0" - dependencies: - "@firebase/component": "npm:0.7.3" - "@firebase/installations": "npm:0.6.22" - "@firebase/messaging-interop-types": "npm:0.2.5" - "@firebase/util": "npm:1.15.1" - idb: "npm:7.1.1" - tslib: "npm:^2.1.0" - peerDependencies: - "@firebase/app": 0.x - checksum: 10c0/e194de2655aedf458cba5b5e3884635da8519f196f2dd3bc04224966c4503448f6937209eea7a93a16685c7d37c04ee3a71793e4463ef6ae7db64e635b67ba56 - languageName: node - linkType: hard - -"@firebase/performance-compat@npm:0.2.25": - version: 0.2.25 - resolution: "@firebase/performance-compat@npm:0.2.25" - dependencies: - "@firebase/component": "npm:0.7.3" - "@firebase/logger": "npm:0.5.1" - "@firebase/performance": "npm:0.7.12" - "@firebase/performance-types": "npm:0.2.4" - "@firebase/util": "npm:1.15.1" - tslib: "npm:^2.1.0" - peerDependencies: - "@firebase/app-compat": 0.x - checksum: 10c0/2fce37ed67f8921eadff7efaedefd892281cf1ccd6277413f23598335e95d6a0e93d5b2b673c93fada18f0c244669abd6a066f8249782a394931a8777c8df998 - languageName: node - linkType: hard - -"@firebase/performance-types@npm:0.2.4": - version: 0.2.4 - resolution: "@firebase/performance-types@npm:0.2.4" - checksum: 10c0/5a823641e637d2b51cda6865804a5f05dba1d0f14b8664f396b5a0c029f5bb74c421240d3bd3fadf307dd9b71571ffcb32d04f5058c65eadbde1bfe4b85ab6c8 - languageName: node - linkType: hard - -"@firebase/performance@npm:0.7.12": - version: 0.7.12 - resolution: "@firebase/performance@npm:0.7.12" - dependencies: - "@firebase/component": "npm:0.7.3" - "@firebase/installations": "npm:0.6.22" - "@firebase/logger": "npm:0.5.1" - "@firebase/util": "npm:1.15.1" - tslib: "npm:^2.1.0" - web-vitals: "npm:^4.2.4" - peerDependencies: - "@firebase/app": 0.x - checksum: 10c0/58211e8bd59b1d4c40c2ebb66504ea0745301308a8c3911da29014176bedf3953521f8dc4d9f0d320d955934a1bef18b17bf4a5dca0546dba702e05e18ed28f9 - languageName: node - linkType: hard - -"@firebase/remote-config-compat@npm:0.2.26": - version: 0.2.26 - resolution: "@firebase/remote-config-compat@npm:0.2.26" - dependencies: - "@firebase/component": "npm:0.7.3" - "@firebase/logger": "npm:0.5.1" - "@firebase/remote-config": "npm:0.8.5" - "@firebase/remote-config-types": "npm:0.5.1" - "@firebase/util": "npm:1.15.1" - tslib: "npm:^2.1.0" - peerDependencies: - "@firebase/app-compat": 0.x - checksum: 10c0/6f799383eef4758a245bf2f1f8ee591dc3e680e3a0df52eb345c09aa0202f706a78ca1fddad1d2d572ec45a865a26a929b355a30619fa9a2ab6223417c738df4 - languageName: node - linkType: hard - -"@firebase/remote-config-types@npm:0.5.1": - version: 0.5.1 - resolution: "@firebase/remote-config-types@npm:0.5.1" - checksum: 10c0/0882c2d9015bcb55a6e460ec3e2807c060984c1a394bf430ba6fa5ef25db53e6baf1d569539ed662e04b431b7e6122ca817437521ecf8be60c31a79a18845b0e - languageName: node - linkType: hard - -"@firebase/remote-config@npm:0.8.5": - version: 0.8.5 - resolution: "@firebase/remote-config@npm:0.8.5" - dependencies: - "@firebase/component": "npm:0.7.3" - "@firebase/installations": "npm:0.6.22" - "@firebase/logger": "npm:0.5.1" - "@firebase/util": "npm:1.15.1" - tslib: "npm:^2.1.0" - peerDependencies: - "@firebase/app": 0.x - checksum: 10c0/2f1f4e098953c75592573d511053a5a95cd5aadc236d591bfe791a5b95b488ef421ca62d595b69fc1cf5d02911bb32aaf13a8b5f5f8fddbc96365d4d822e41b5 - languageName: node - linkType: hard - -"@firebase/storage-compat@npm:0.4.3": - version: 0.4.3 - resolution: "@firebase/storage-compat@npm:0.4.3" - dependencies: - "@firebase/component": "npm:0.7.3" - "@firebase/storage": "npm:0.14.3" - "@firebase/storage-types": "npm:0.8.4" - "@firebase/util": "npm:1.15.1" - tslib: "npm:^2.1.0" - peerDependencies: - "@firebase/app-compat": 0.x - checksum: 10c0/4aa392d291dc0864fd286261bac6b00f5c90b9a16a366026db8631f51c7583807e28a00a040dd2d4f1fa897bb60f93a93da61eb16804e7a4075dd19e485e6239 - languageName: node - linkType: hard - -"@firebase/storage-types@npm:0.8.4": - version: 0.8.4 - resolution: "@firebase/storage-types@npm:0.8.4" - peerDependencies: - "@firebase/app-types": 0.x - "@firebase/util": 1.x - checksum: 10c0/66ec5b6c6a703c4eb0fb99761d741148d9ae2b5d06c4d5d6efcfbea368fb80480fb037378e3e5ec2aa8adcfe97bb64016eb263220f7d1a91439e46f23254d5b6 - languageName: node - linkType: hard - -"@firebase/storage@npm:0.14.3": - version: 0.14.3 - resolution: "@firebase/storage@npm:0.14.3" - dependencies: - "@firebase/component": "npm:0.7.3" - "@firebase/util": "npm:1.15.1" - tslib: "npm:^2.1.0" - peerDependencies: - "@firebase/app": 0.x - checksum: 10c0/6ec4d412cd658fd37759d2ad92e57edd5481e63373a38c0b2c5febb7a617e16b6646f94862168282e9aacec72d291af0f48181cb09f806ff66f82d31842b3bf8 - languageName: node - linkType: hard - -"@firebase/util@npm:1.15.1": - version: 1.15.1 - resolution: "@firebase/util@npm:1.15.1" - dependencies: - tslib: "npm:^2.1.0" - checksum: 10c0/7fdb0dd80c98181969375d7a2ee9309783061a77e63c0ddc4935915ca97917cb1bc560bcfd21b9b4171630cae8579f6f21241ae260e4b18789e14adbdd329eea - languageName: node - linkType: hard - -"@firebase/webchannel-wrapper@npm:1.0.6": - version: 1.0.6 - resolution: "@firebase/webchannel-wrapper@npm:1.0.6" - checksum: 10c0/2597e8a9482b47266a8ac2f6dea80062391c90170ad602c315154e1cddeb5eb6b8e609055f26cf31d4a231baa6cd94b628c9d5d26e5b0e44d645833d5b763a5f - languageName: node - linkType: hard - "@fishjam-cloud/protobufs@workspace:*, @fishjam-cloud/protobufs@workspace:^, @fishjam-cloud/protobufs@workspace:packages/protobufs": version: 0.0.0-use.local resolution: "@fishjam-cloud/protobufs@workspace:packages/protobufs" @@ -4779,17 +4237,7 @@ __metadata: languageName: node linkType: hard -"@grpc/grpc-js@npm:~1.9.0": - version: 1.9.16 - resolution: "@grpc/grpc-js@npm:1.9.16" - dependencies: - "@grpc/proto-loader": "npm:^0.7.8" - "@types/node": "npm:>=12.12.47" - checksum: 10c0/dfe2b1a670d650489fe82bb7afc1c0336313b0cde4a102623a1331267cbdeb1bde9409143fb1432db074bc1845b38d96b82a4277bdd99929aa8322cf97d305aa - languageName: node - linkType: hard - -"@grpc/proto-loader@npm:^0.7.13, @grpc/proto-loader@npm:^0.7.8": +"@grpc/proto-loader@npm:^0.7.13": version: 0.7.15 resolution: "@grpc/proto-loader@npm:0.7.15" dependencies: @@ -5752,22 +5200,6 @@ __metadata: languageName: node linkType: hard -"@react-native-firebase/app@npm:^25.1.0": - version: 25.1.0 - resolution: "@react-native-firebase/app@npm:25.1.0" - dependencies: - firebase: "npm:12.15.0" - peerDependencies: - expo: ">=47.0.0" - react: "*" - react-native: "*" - peerDependenciesMeta: - expo: - optional: true - checksum: 10c0/c6939ee4bc6613a67099252f027494e9e5e687baa6c42de8627719eb39a2128a2dd11f08e87e6c7907467d92489288dae9bb4200baab1598c07df06b498b5377 - 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" @@ -6784,15 +6216,6 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:>=12.12.47": - version: 26.1.0 - resolution: "@types/node@npm:26.1.0" - dependencies: - undici-types: "npm:~8.3.0" - checksum: 10c0/61c22ad1ef215e24138cb8b7368fb68bab9de4c123b3f23d411749b015ba1b7d22f878ad54add26a0b69068d7e07c04af665069d8571b6c6c3b9b2fb73b7bd91 - languageName: node - linkType: hard - "@types/node@npm:^18.11.18": version: 18.19.86 resolution: "@types/node@npm:18.19.86" @@ -11836,15 +11259,6 @@ __metadata: languageName: node linkType: hard -"faye-websocket@npm:0.11.4": - version: 0.11.4 - resolution: "faye-websocket@npm:0.11.4" - dependencies: - websocket-driver: "npm:>=0.5.1" - checksum: 10c0/c6052a0bb322778ce9f89af92890f6f4ce00d5ec92418a35e5f4c6864a4fe736fec0bcebd47eac7c0f0e979b01530746b1c85c83cb04bae789271abf19737420 - languageName: node - linkType: hard - "fb-watchman@npm:^2.0.0": version: 2.0.2 resolution: "fb-watchman@npm:2.0.2" @@ -11987,42 +11401,6 @@ __metadata: languageName: node linkType: hard -"firebase@npm:12.15.0": - version: 12.15.0 - resolution: "firebase@npm:12.15.0" - dependencies: - "@firebase/ai": "npm:2.13.1" - "@firebase/analytics": "npm:0.10.22" - "@firebase/analytics-compat": "npm:0.2.28" - "@firebase/app": "npm:0.15.0" - "@firebase/app-check": "npm:0.12.0" - "@firebase/app-check-compat": "npm:0.4.5" - "@firebase/app-compat": "npm:0.5.14" - "@firebase/app-types": "npm:0.9.5" - "@firebase/auth": "npm:1.13.3" - "@firebase/auth-compat": "npm:0.6.8" - "@firebase/data-connect": "npm:0.7.1" - "@firebase/database": "npm:1.1.3" - "@firebase/database-compat": "npm:2.1.4" - "@firebase/firestore": "npm:4.16.0" - "@firebase/firestore-compat": "npm:0.4.11" - "@firebase/functions": "npm:0.13.5" - "@firebase/functions-compat": "npm:0.4.5" - "@firebase/installations": "npm:0.6.22" - "@firebase/installations-compat": "npm:0.2.22" - "@firebase/messaging": "npm:0.13.0" - "@firebase/messaging-compat": "npm:0.2.27" - "@firebase/performance": "npm:0.7.12" - "@firebase/performance-compat": "npm:0.2.25" - "@firebase/remote-config": "npm:0.8.5" - "@firebase/remote-config-compat": "npm:0.2.26" - "@firebase/storage": "npm:0.14.3" - "@firebase/storage-compat": "npm:0.4.3" - "@firebase/util": "npm:1.15.1" - checksum: 10c0/a9b575fd9b2582c8f02772cea5a58c79f68ad80911cc1507663fde3f17493ca28fe5292e5e2c3dc68539aba0812e1bcd36d688f8bacd9ab329bf42aeaf0d3b65 - languageName: node - linkType: hard - "fishjam-chat@workspace:examples/mobile-client/fishjam-chat": version: 0.0.0-use.local resolution: "fishjam-chat@workspace:examples/mobile-client/fishjam-chat" @@ -12776,13 +12154,6 @@ __metadata: languageName: node linkType: hard -"http-parser-js@npm:>=0.5.1": - version: 0.5.10 - resolution: "http-parser-js@npm:0.5.10" - checksum: 10c0/8bbcf1832a8d70b2bd515270112116333add88738a2cc05bfb94ba6bde3be4b33efee5611584113818d2bcf654fdc335b652503be5a6b4c0b95e46f214187d93 - languageName: node - linkType: hard - "http-proxy-agent@npm:^5.0.0": version: 5.0.0 resolution: "http-proxy-agent@npm:5.0.0" @@ -12863,13 +12234,6 @@ __metadata: languageName: node linkType: hard -"idb@npm:7.1.1": - version: 7.1.1 - resolution: "idb@npm:7.1.1" - checksum: 10c0/72418e4397638797ee2089f97b45fc29f937b830bc0eb4126f4a9889ecf10320ceacf3a177fe5d7ffaf6b4fe38b20bbd210151549bfdc881db8081eed41c870d - languageName: node - linkType: hard - "idb@npm:8.0.3": version: 8.0.3 resolution: "idb@npm:8.0.3" @@ -16312,13 +15676,6 @@ __metadata: languageName: node linkType: hard -"re2js@npm:^0.4.2": - version: 0.4.3 - resolution: "re2js@npm:0.4.3" - checksum: 10c0/6fd9c438afa2438d102c355c00a05144a771b4e64207e25d379592b4d3fc0d7822eae6011181a4326e7c37bee0488d5a0332cc21b236bf712b619c1d329639bd - languageName: node - linkType: hard - "react-devtools-core@npm:^6.1.5": version: 6.1.5 resolution: "react-devtools-core@npm:6.1.5" @@ -17241,7 +16598,7 @@ __metadata: languageName: node linkType: hard -"safe-buffer@npm:5.2.1, safe-buffer@npm:>=5.1.0, safe-buffer@npm:~5.2.0": +"safe-buffer@npm:5.2.1, safe-buffer@npm:~5.2.0": version: 5.2.1 resolution: "safe-buffer@npm:5.2.1" checksum: 10c0/6501914237c0a86e9675d4e51d89ca3c21ffd6a31642efeba25ad65720bce6921c9e7e974e5be91a786b25aa058b5303285d3c15dbabf983a919f5f630d349f3 @@ -19058,13 +18415,6 @@ __metadata: languageName: node linkType: hard -"undici-types@npm:~8.3.0": - version: 8.3.0 - resolution: "undici-types@npm:8.3.0" - checksum: 10c0/c8aa7e2fbebfce519654dafadc0ece59be888d2ccaf180fb4495da875e7b536d2456345c384069c7e6f3e9c9ab7435f074957da306f142343eee86ff8048855a - languageName: node - linkType: hard - "undici@npm:^5.28.5": version: 5.29.0 resolution: "undici@npm:5.29.0" @@ -19595,7 +18945,6 @@ __metadata: dependencies: "@fishjam-cloud/react-native-client": "workspace:*" "@react-native-async-storage/async-storage": "npm:^3.1.1" - "@react-native-firebase/app": "npm:^25.1.0" "@types/react": "npm:~19.1.0" eslint-config-expo: "npm:~8.0.1" expo: "npm:~54.0.30" @@ -19666,13 +19015,6 @@ __metadata: languageName: node linkType: hard -"web-vitals@npm:^4.2.4": - version: 4.2.4 - resolution: "web-vitals@npm:4.2.4" - checksum: 10c0/383c9281d5b556bcd190fde3c823aeb005bb8cf82e62c75b47beb411014a4ed13fa5c5e0489ed0f1b8d501cd66b0bebcb8624c1a75750bd5df13e2a3b1b2d194 - languageName: node - linkType: hard - "webidl-conversions@npm:^3.0.0": version: 3.0.1 resolution: "webidl-conversions@npm:3.0.1" @@ -19701,24 +19043,6 @@ __metadata: languageName: node linkType: hard -"websocket-driver@npm:>=0.5.1": - version: 0.7.5 - resolution: "websocket-driver@npm:0.7.5" - dependencies: - http-parser-js: "npm:>=0.5.1" - safe-buffer: "npm:>=5.1.0" - websocket-extensions: "npm:>=0.1.1" - checksum: 10c0/843a3c9f529ce07f2721829f70f62986247525ab22625e857b69da13dc90967bacfe57b85e196801b565cd797e309ddd5b06fa8435732e34e8a8ab6201d7abed - languageName: node - linkType: hard - -"websocket-extensions@npm:>=0.1.1": - version: 0.1.4 - resolution: "websocket-extensions@npm:0.1.4" - checksum: 10c0/bbc8c233388a0eb8a40786ee2e30d35935cacbfe26ab188b3e020987e85d519c2009fe07cfc37b7f718b85afdba7e54654c9153e6697301f72561bfe429177e0 - languageName: node - linkType: hard - "whatwg-encoding@npm:^2.0.0": version: 2.0.0 resolution: "whatwg-encoding@npm:2.0.0" From 8c45dac4240ef1632fe0456535ceaa49c3793bbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gadomski?= Date: Fri, 10 Jul 2026 16:38:00 +0200 Subject: [PATCH 24/52] Add better endCall events with reasons --- .../app/src/screens/InCallScreen.tsx | 2 +- .../app/src/screens/OutgoingCallScreen.tsx | 2 +- .../app/src/signaling/useCallSignaling.ts | 10 ++-- .../voip-call/app/src/voip/VoipContext.ts | 14 ++++- .../voip-call/app/src/voip/VoipProvider.tsx | 56 ++++++++++++++----- packages/mobile-client/src/index.ts | 1 + 6 files changed, 61 insertions(+), 24 deletions(-) diff --git a/examples/mobile-client/voip-call/app/src/screens/InCallScreen.tsx b/examples/mobile-client/voip-call/app/src/screens/InCallScreen.tsx index 1401c94e4..c569c9d8e 100644 --- a/examples/mobile-client/voip-call/app/src/screens/InCallScreen.tsx +++ b/examples/mobile-client/voip-call/app/src/screens/InCallScreen.tsx @@ -99,7 +99,7 @@ export function InCallScreen() { endCall('local')} accessibilityLabel="End call" /> diff --git a/examples/mobile-client/voip-call/app/src/screens/OutgoingCallScreen.tsx b/examples/mobile-client/voip-call/app/src/screens/OutgoingCallScreen.tsx index e607d5d89..1268a6a6b 100644 --- a/examples/mobile-client/voip-call/app/src/screens/OutgoingCallScreen.tsx +++ b/examples/mobile-client/voip-call/app/src/screens/OutgoingCallScreen.tsx @@ -50,7 +50,7 @@ export function OutgoingCallScreen() { endCall('local')} accessibilityLabel="Cancel call" /> diff --git a/examples/mobile-client/voip-call/app/src/signaling/useCallSignaling.ts b/examples/mobile-client/voip-call/app/src/signaling/useCallSignaling.ts index 874f40987..77fcfe0f5 100644 --- a/examples/mobile-client/voip-call/app/src/signaling/useCallSignaling.ts +++ b/examples/mobile-client/voip-call/app/src/signaling/useCallSignaling.ts @@ -47,13 +47,15 @@ export function useCallSignaling({ serverUrl, username }: Params): void { if (!currentCall || currentCall.startedAt !== null) return; if (currentCall.roomName !== msg.roomName) return; - // The caller cancelled while we (the callee) are still ringing. + // 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(); + void endCall('missed'); } - // The callee rejected while we (the caller) are still ringing out. + // The callee rejected while we (the caller) are still ringing out — the + // other party ended it, not us. else if (msg.type === 'call-rejected' && currentCall.isOutgoing) { - void endCall(); + void endCall('remote'); } }; diff --git a/examples/mobile-client/voip-call/app/src/voip/VoipContext.ts b/examples/mobile-client/voip-call/app/src/voip/VoipContext.ts index 107316258..6b3cd7251 100644 --- a/examples/mobile-client/voip-call/app/src/voip/VoipContext.ts +++ b/examples/mobile-client/voip-call/app/src/voip/VoipContext.ts @@ -1,3 +1,4 @@ +import type { CallEndedReason } from '@fishjam-cloud/react-native-client'; import { createContext, useContext } from 'react'; /** @@ -36,6 +37,12 @@ export type VoipContextValue = { voipToken: string | null; /** The call currently being handled, or `null` when `status` is `available`. */ currentCall: CurrentCall | null; + /** + * Why the most recently handled call ended. `null` until a call has ended at + * least once. Surfaced so a consumer can react to `missed`/`rejected`/etc., e.g. + * showing a "missed call" notification. + */ + lastEndedReason: CallEndedReason | null; /** * Starts an outgoing call to `to` in the given `roomName` — reports it to * CallKit and joins the room. @@ -44,10 +51,11 @@ export type VoipContextValue = { /** 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`. + * Ends or rejects the current call. Dismisses CallKit/Telecom, leaves the + * room, and resets state back to `available`. `reason` (defaults to `local`) + * is surfaced to the system call UI/log and to `lastEndedReason`. */ - endCall: () => Promise; + endCall: (reason?: CallEndedReason) => Promise; }; export const VoipContext = createContext(null); diff --git a/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx b/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx index bb2400153..31794d0e3 100644 --- a/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx +++ b/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx @@ -1,4 +1,5 @@ import { + type CallEndedReason, useCallKit, useCamera, useConnection, @@ -63,6 +64,8 @@ export function VoipProvider({ const [voipToken, setVoipToken] = useState(null); const [status, setStatus] = useState('available'); const [currentCall, setCurrentCall] = useState(null); + const [lastEndedReason, setLastEndedReason] = + useState(null); const currentCallRef = useRef(null); @@ -86,7 +89,10 @@ export function VoipProvider({ ); const endNativeCallSession = useCallback( - () => (Platform.OS === 'ios' ? endCallKitSession() : endTelecomSession()), + (reason?: CallEndedReason) => + Platform.OS === 'ios' + ? endCallKitSession(reason) + : endTelecomSession(reason), [endCallKitSession, endTelecomSession], ); @@ -108,13 +114,17 @@ export function VoipProvider({ await leaveRoom(); }, [leaveRoom, stopCamera, stopMicrophone]); - const endCall = useCallback(async () => { - await endNativeCallSession(); - await handleLeaveRoom(); - currentCallRef.current = null; - setCurrentCall(null); - setStatus('available'); - }, [endNativeCallSession, handleLeaveRoom]); + const endCall = useCallback( + async (reason: CallEndedReason = 'local') => { + await endNativeCallSession(reason); + await handleLeaveRoom(); + currentCallRef.current = null; + setCurrentCall(null); + setStatus('available'); + setLastEndedReason(reason); + }, + [endNativeCallSession, handleLeaveRoom], + ); const startCall = useCallback( async (to: string, roomName: string) => { @@ -128,6 +138,7 @@ export function VoipProvider({ currentCallRef.current = call; setCurrentCall(call); setStatus('connecting'); + setLastEndedReason(null); try { await requestCall({ to, roomName, isVideo }); @@ -135,7 +146,7 @@ export function VoipProvider({ await handleJoinRoom(roomName); } catch (err) { console.error('Failed to start call:', err); - await endCall(); + await endCall('failed'); } }, [requestCall, handleJoinRoom, startNativeCallSession, isVideo, endCall], @@ -150,7 +161,7 @@ export function VoipProvider({ await handleJoinRoom(call.roomName); } catch (err) { console.error('Failed to join room on answer:', err); - await endCall(); + await endCall('failed'); } }, [handleJoinRoom, endCall]); @@ -170,15 +181,19 @@ export function VoipProvider({ currentCallRef.current = call; setCurrentCall(call); setStatus('incoming'); + setLastEndedReason(null); }, []), onAnswered: useCallback(async () => { await answerCall(); }, [answerCall]), - onEnded: useCallback(async () => { - await endCall(); - }, [endCall]), + onEnded: useCallback( + async (reason?: CallEndedReason) => { + await endCall(reason ?? 'remote'); + }, + [endCall], + ), }); useEffect(() => { @@ -196,7 +211,9 @@ export function VoipProvider({ ); } } else if (status === 'active' && remotePeers.length === 0) { - endCall().catch((err) => console.error('Failed to end call:', err)); + endCall('remote').catch((err) => + console.error('Failed to end call:', err), + ); } }, [remotePeers.length, status, endCall, setTelecomCallActive]); @@ -205,11 +222,20 @@ export function VoipProvider({ voipToken, status, currentCall, + lastEndedReason, startCall, answerCall, endCall, }), - [voipToken, status, currentCall, startCall, answerCall, endCall], + [ + voipToken, + status, + currentCall, + lastEndedReason, + startCall, + answerCall, + endCall, + ], ); return ( diff --git a/packages/mobile-client/src/index.ts b/packages/mobile-client/src/index.ts index 9fc68bc58..f2b440598 100644 --- a/packages/mobile-client/src/index.ts +++ b/packages/mobile-client/src/index.ts @@ -31,6 +31,7 @@ export { export type { VoIPEventHandlers, VoipIncomingPayload } from '@fishjam-cloud/react-native-webrtc'; export type { + CallEndedReason, TelecomConfig, TelecomEvent, TelecomEventType, From 9bc1769bf00ce790518543b8cc166f62b9960250 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gadomski?= Date: Fri, 10 Jul 2026 16:49:43 +0200 Subject: [PATCH 25/52] Fix multiple endCall() invocatoins and log call end reasons --- examples/mobile-client/voip-call/app/App.tsx | 15 +++++++++++++++ .../voip-call/app/src/voip/VoipProvider.tsx | 6 ++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/examples/mobile-client/voip-call/app/App.tsx b/examples/mobile-client/voip-call/app/App.tsx index e7379bd13..4af96c542 100644 --- a/examples/mobile-client/voip-call/app/App.tsx +++ b/examples/mobile-client/voip-call/app/App.tsx @@ -75,12 +75,27 @@ function VoipWrapper({ children }: PropsWithChildren) { requestCall={requestCall} isVideo={true}> + {children} ); } +function CallEndedLogger() { + const { lastEndedReason } = useVoip(); + const { username } = useUser(); + + useEffect(() => { + if (!lastEndedReason) return; + console.log( + `On user: ${username}, [VoIP] Call ended — reason: ${lastEndedReason}`, + ); + }, [lastEndedReason, username]); + + return null; +} + function DeviceRegistration() { const { username } = useUser(); const { voipToken } = useVoip(); diff --git a/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx b/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx index 31794d0e3..31987630f 100644 --- a/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx +++ b/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx @@ -116,12 +116,14 @@ export function VoipProvider({ const endCall = useCallback( async (reason: CallEndedReason = 'local') => { - await endNativeCallSession(reason); - await handleLeaveRoom(); + if (!currentCallRef.current) return; currentCallRef.current = null; setCurrentCall(null); setStatus('available'); setLastEndedReason(reason); + + await endNativeCallSession(reason); + await handleLeaveRoom(); }, [endNativeCallSession, handleLeaveRoom], ); From 3f8d9dc62b02b90e242a68e6e94a055f0ddc000a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gadomski?= Date: Mon, 13 Jul 2026 15:23:02 +0200 Subject: [PATCH 26/52] Add voip answer-to-connect handshake for iOS and Android --- examples/mobile-client/voip-call/README.md | 35 +++-- .../voip-call/app/src/voip/VoipProvider.tsx | 121 ++++++++++++++---- packages/mobile-client/src/index.ts | 3 + packages/react-native-webrtc | 2 +- 4 files changed, 124 insertions(+), 37 deletions(-) diff --git a/examples/mobile-client/voip-call/README.md b/examples/mobile-client/voip-call/README.md index fbd2de888..c18d6e48e 100644 --- a/examples/mobile-client/voip-call/README.md +++ b/examples/mobile-client/voip-call/README.md @@ -155,16 +155,35 @@ Voice over IP** and **Push Notifications**. ## 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)); +import { + failIncomingCallConnected, + fulfillIncomingCallConnected, + useVoIPEvents, +} from "@fishjam-cloud/react-native-client"; + +useVoIPEvents({ + onIncoming: (payload) => console.log("incoming push:", payload), + onAnswered: async (requestId) => { + try { + await joinRoom(); + await waitForRemoteMedia(); + const connected = await fulfillIncomingCallConnected(requestId); + if (!connected) return; // The native timeout already ended the call. + } catch { + await failIncomingCallConnected(requestId); + } + }, + onEnded: (reason) => console.log("ended:", reason), + onRegistered: (token) => console.log("VoIP token:", token), +}); ``` +Answering parks the native CallKit/Core-Telecom action in a `connecting` state. +Call `fulfillIncomingCallConnected` only when remote media is live; call +`failIncomingCallConnected` if token fetching or room setup fails. The native +handshake has a fixed 10-second deadline covering JS startup, token fetching, +and room join. If it is not fulfilled in time, the call ends as failed. + --- ## 7. Expo caveat diff --git a/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx b/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx index 31987630f..b849f516a 100644 --- a/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx +++ b/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx @@ -1,5 +1,7 @@ import { type CallEndedReason, + failIncomingCallConnected, + fulfillIncomingCallConnected, useCallKit, useCamera, useConnection, @@ -68,6 +70,8 @@ export function VoipProvider({ useState(null); const currentCallRef = useRef(null); + const pendingAnswerRequestIdRef = useRef(null); + const activationInFlightRef = useRef(false); const { startCamera, stopCamera } = useCamera(); const { startMicrophone, stopMicrophone } = useMicrophone(); @@ -114,18 +118,31 @@ export function VoipProvider({ await leaveRoom(); }, [leaveRoom, stopCamera, stopMicrophone]); - const endCall = useCallback( + const resetCallState = useCallback( async (reason: CallEndedReason = 'local') => { if (!currentCallRef.current) return; currentCallRef.current = null; + pendingAnswerRequestIdRef.current = null; setCurrentCall(null); setStatus('available'); setLastEndedReason(reason); - await endNativeCallSession(reason); await handleLeaveRoom(); }, - [endNativeCallSession, handleLeaveRoom], + [handleLeaveRoom], + ); + + const endCall = useCallback( + async (reason: CallEndedReason = 'local') => { + if (!currentCallRef.current) return; + const resetPromise = resetCallState(reason); + try { + await endNativeCallSession(reason); + } finally { + await resetPromise; + } + }, + [endNativeCallSession, resetCallState], ); const startCall = useCallback( @@ -154,18 +171,35 @@ export function VoipProvider({ [requestCall, handleJoinRoom, startNativeCallSession, isVideo, endCall], ); - const answerCall = useCallback(async () => { - const call = currentCallRef.current; - if (!call) return; + const answerCall = useCallback( + async (requestId?: string) => { + const call = currentCallRef.current; + if (!call) return; - setStatus('connecting'); - try { - await handleJoinRoom(call.roomName); - } catch (err) { - console.error('Failed to join room on answer:', err); - await endCall('failed'); - } - }, [handleJoinRoom, endCall]); + if (requestId) { + pendingAnswerRequestIdRef.current = requestId; + } + setStatus('connecting'); + try { + await handleJoinRoom(call.roomName); + } catch (err) { + console.error('Failed to join room on answer:', err); + if (requestId) { + try { + await failIncomingCallConnected(requestId); + } finally { + if (pendingAnswerRequestIdRef.current === requestId) { + pendingAnswerRequestIdRef.current = null; + } + await resetCallState('failed'); + } + } else { + await endCall('failed'); + } + } + }, + [handleJoinRoom, endCall, resetCallState], + ); useVoIPEvents({ onRegistered: useCallback((token: string) => { @@ -186,9 +220,12 @@ export function VoipProvider({ setLastEndedReason(null); }, []), - onAnswered: useCallback(async () => { - await answerCall(); - }, [answerCall]), + onAnswered: useCallback( + async (requestId: string) => { + await answerCall(requestId); + }, + [answerCall], + ), onEnded: useCallback( async (reason?: CallEndedReason) => { @@ -199,19 +236,47 @@ export function VoipProvider({ }); useEffect(() => { - if (status === 'connecting' && remotePeers.length > 0) { - if (currentCallRef.current) { - const call = { ...currentCallRef.current, startedAt: Date.now() }; + if ( + status === 'connecting' && + remotePeers.length > 0 && + !activationInFlightRef.current + ) { + activationInFlightRef.current = true; + const activateCall = async () => { + const requestId = pendingAnswerRequestIdRef.current; + if (requestId) { + pendingAnswerRequestIdRef.current = null; + const connected = await fulfillIncomingCallConnected(requestId); + if (!connected) { + await endCall('failed'); + return; + } + } else if (Platform.OS === 'android') { + // Outgoing calls do not have an answer request and still need the + // existing Core-Telecom activation path. + await setTelecomCallActive(); + } + + const currentCall = currentCallRef.current; + if (!currentCall) return; + const call = { ...currentCall, startedAt: Date.now() }; currentCallRef.current = call; setCurrentCall(call); - } - setStatus('active'); - - if (Platform.OS === 'android') { - setTelecomCallActive().catch((err) => - console.warn('Failed to activate telecom call:', err), - ); - } + setStatus('active'); + }; + activateCall() + .catch((err) => { + console.error('Failed to activate call:', err); + endCall('failed').catch((endError) => + console.error( + 'Failed to end call after activation error:', + endError, + ), + ); + }) + .finally(() => { + activationInFlightRef.current = false; + }); } else if (status === 'active' && remotePeers.length === 0) { endCall('remote').catch((err) => console.error('Failed to end call:', err), diff --git a/packages/mobile-client/src/index.ts b/packages/mobile-client/src/index.ts index f2b440598..c353438a1 100644 --- a/packages/mobile-client/src/index.ts +++ b/packages/mobile-client/src/index.ts @@ -26,6 +26,9 @@ export { useVoIPEvents, useTelecom, useTelecomEvent, + fulfillIncomingCallConnected, + failIncomingCallConnected, + getPendingAnswerRequestId, } from '@fishjam-cloud/react-native-webrtc'; export type { VoIPEventHandlers, VoipIncomingPayload } from '@fishjam-cloud/react-native-webrtc'; diff --git a/packages/react-native-webrtc b/packages/react-native-webrtc index 18fc9fe98..b2e4d3755 160000 --- a/packages/react-native-webrtc +++ b/packages/react-native-webrtc @@ -1 +1 @@ -Subproject commit 18fc9fe98d4ac911c47e7a212ce0478e1e9016ef +Subproject commit b2e4d3755ed9982871d54d4c627e8d60ee828695 From 15576338ab519c9fbeed01f84c336f0fed560940 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gadomski?= Date: Mon, 13 Jul 2026 15:23:02 +0200 Subject: [PATCH 27/52] Add voip answer-to-connect handshake for iOS and Android --- examples/mobile-client/voip-call/README.md | 35 ++++-- .../voip-call/app/src/voip/VoipProvider.tsx | 119 +++++++++++++----- packages/mobile-client/src/index.ts | 3 + packages/react-native-webrtc | 2 +- 4 files changed, 122 insertions(+), 37 deletions(-) diff --git a/examples/mobile-client/voip-call/README.md b/examples/mobile-client/voip-call/README.md index fbd2de888..c18d6e48e 100644 --- a/examples/mobile-client/voip-call/README.md +++ b/examples/mobile-client/voip-call/README.md @@ -155,16 +155,35 @@ Voice over IP** and **Push Notifications**. ## 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)); +import { + failIncomingCallConnected, + fulfillIncomingCallConnected, + useVoIPEvents, +} from "@fishjam-cloud/react-native-client"; + +useVoIPEvents({ + onIncoming: (payload) => console.log("incoming push:", payload), + onAnswered: async (requestId) => { + try { + await joinRoom(); + await waitForRemoteMedia(); + const connected = await fulfillIncomingCallConnected(requestId); + if (!connected) return; // The native timeout already ended the call. + } catch { + await failIncomingCallConnected(requestId); + } + }, + onEnded: (reason) => console.log("ended:", reason), + onRegistered: (token) => console.log("VoIP token:", token), +}); ``` +Answering parks the native CallKit/Core-Telecom action in a `connecting` state. +Call `fulfillIncomingCallConnected` only when remote media is live; call +`failIncomingCallConnected` if token fetching or room setup fails. The native +handshake has a fixed 10-second deadline covering JS startup, token fetching, +and room join. If it is not fulfilled in time, the call ends as failed. + --- ## 7. Expo caveat diff --git a/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx b/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx index 31987630f..3e8fa78cb 100644 --- a/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx +++ b/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx @@ -1,5 +1,7 @@ import { type CallEndedReason, + failIncomingCallConnected, + fulfillIncomingCallConnected, useCallKit, useCamera, useConnection, @@ -68,6 +70,8 @@ export function VoipProvider({ useState(null); const currentCallRef = useRef(null); + const pendingAnswerRequestIdRef = useRef(null); + const activationInFlightRef = useRef(false); const { startCamera, stopCamera } = useCamera(); const { startMicrophone, stopMicrophone } = useMicrophone(); @@ -114,18 +118,31 @@ export function VoipProvider({ await leaveRoom(); }, [leaveRoom, stopCamera, stopMicrophone]); - const endCall = useCallback( + const resetCallState = useCallback( async (reason: CallEndedReason = 'local') => { if (!currentCallRef.current) return; currentCallRef.current = null; + pendingAnswerRequestIdRef.current = null; setCurrentCall(null); setStatus('available'); setLastEndedReason(reason); - await endNativeCallSession(reason); await handleLeaveRoom(); }, - [endNativeCallSession, handleLeaveRoom], + [handleLeaveRoom], + ); + + const endCall = useCallback( + async (reason: CallEndedReason = 'local') => { + if (!currentCallRef.current) return; + const resetPromise = resetCallState(reason); + try { + await endNativeCallSession(reason); + } finally { + await resetPromise; + } + }, + [endNativeCallSession, resetCallState], ); const startCall = useCallback( @@ -154,18 +171,35 @@ export function VoipProvider({ [requestCall, handleJoinRoom, startNativeCallSession, isVideo, endCall], ); - const answerCall = useCallback(async () => { - const call = currentCallRef.current; - if (!call) return; + const answerCall = useCallback( + async (requestId?: string) => { + const call = currentCallRef.current; + if (!call) return; - setStatus('connecting'); - try { - await handleJoinRoom(call.roomName); - } catch (err) { - console.error('Failed to join room on answer:', err); - await endCall('failed'); - } - }, [handleJoinRoom, endCall]); + if (requestId) { + pendingAnswerRequestIdRef.current = requestId; + } + setStatus('connecting'); + try { + await handleJoinRoom(call.roomName); + } catch (err) { + console.error('Failed to join room on answer:', err); + if (requestId) { + try { + await failIncomingCallConnected(requestId); + } finally { + if (pendingAnswerRequestIdRef.current === requestId) { + pendingAnswerRequestIdRef.current = null; + } + await resetCallState('failed'); + } + } else { + await endCall('failed'); + } + } + }, + [handleJoinRoom, endCall, resetCallState], + ); useVoIPEvents({ onRegistered: useCallback((token: string) => { @@ -186,9 +220,12 @@ export function VoipProvider({ setLastEndedReason(null); }, []), - onAnswered: useCallback(async () => { - await answerCall(); - }, [answerCall]), + onAnswered: useCallback( + async (requestId: string) => { + await answerCall(requestId); + }, + [answerCall], + ), onEnded: useCallback( async (reason?: CallEndedReason) => { @@ -199,19 +236,45 @@ export function VoipProvider({ }); useEffect(() => { - if (status === 'connecting' && remotePeers.length > 0) { - if (currentCallRef.current) { - const call = { ...currentCallRef.current, startedAt: Date.now() }; + if ( + status === 'connecting' && + remotePeers.length > 0 && + !activationInFlightRef.current + ) { + activationInFlightRef.current = true; + const activateCall = async () => { + const requestId = pendingAnswerRequestIdRef.current; + if (requestId) { + pendingAnswerRequestIdRef.current = null; + const connected = await fulfillIncomingCallConnected(requestId); + if (!connected) { + await endCall('failed'); + return; + } + } else if (Platform.OS === 'android') { + await setTelecomCallActive(); + } + + const currentCall = currentCallRef.current; + if (!currentCall) return; + const call = { ...currentCall, startedAt: Date.now() }; currentCallRef.current = call; setCurrentCall(call); - } - setStatus('active'); - - if (Platform.OS === 'android') { - setTelecomCallActive().catch((err) => - console.warn('Failed to activate telecom call:', err), - ); - } + setStatus('active'); + }; + activateCall() + .catch((err) => { + console.error('Failed to activate call:', err); + endCall('failed').catch((endError) => + console.error( + 'Failed to end call after activation error:', + endError, + ), + ); + }) + .finally(() => { + activationInFlightRef.current = false; + }); } else if (status === 'active' && remotePeers.length === 0) { endCall('remote').catch((err) => console.error('Failed to end call:', err), diff --git a/packages/mobile-client/src/index.ts b/packages/mobile-client/src/index.ts index f2b440598..c353438a1 100644 --- a/packages/mobile-client/src/index.ts +++ b/packages/mobile-client/src/index.ts @@ -26,6 +26,9 @@ export { useVoIPEvents, useTelecom, useTelecomEvent, + fulfillIncomingCallConnected, + failIncomingCallConnected, + getPendingAnswerRequestId, } from '@fishjam-cloud/react-native-webrtc'; export type { VoIPEventHandlers, VoipIncomingPayload } from '@fishjam-cloud/react-native-webrtc'; diff --git a/packages/react-native-webrtc b/packages/react-native-webrtc index 18fc9fe98..b2e4d3755 160000 --- a/packages/react-native-webrtc +++ b/packages/react-native-webrtc @@ -1 +1 @@ -Subproject commit 18fc9fe98d4ac911c47e7a212ce0478e1e9016ef +Subproject commit b2e4d3755ed9982871d54d4c627e8d60ee828695 From 8e5d40ed362015cec8dbf88f31063f6c3561614b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gadomski?= Date: Mon, 13 Jul 2026 18:18:43 +0200 Subject: [PATCH 28/52] Add native call timeouts --- examples/mobile-client/voip-call/README.md | 37 ++++++++++++++- packages/mobile-client/README.md | 45 ++++++++++++++++++- packages/mobile-client/plugin/src/types.ts | 5 +++ .../plugin/src/withFishjamAndroid.ts | 2 +- .../plugin/src/withFishjamIos.ts | 23 ++++++++++ ...shjamVoip.ts => withFishjamVoipAndroid.ts} | 41 +++++++++++++++-- packages/react-native-webrtc | 2 +- 7 files changed, 147 insertions(+), 8 deletions(-) rename packages/mobile-client/plugin/src/{withFishjamVoip.ts => withFishjamVoipAndroid.ts} (75%) diff --git a/examples/mobile-client/voip-call/README.md b/examples/mobile-client/voip-call/README.md index c18d6e48e..de998fd19 100644 --- a/examples/mobile-client/voip-call/README.md +++ b/examples/mobile-client/voip-call/README.md @@ -121,6 +121,20 @@ which writes this for you). Already present in this app: Allow $(PRODUCT_NAME) to access your microphone. ``` +Optional native timeout values are numeric `Info.plist` entries: + +```xml +FishjamVoipIncomingCallTimeout +45 +FishjamVoipOutgoingCallTimeout +60 +FishjamVoipFulfillAnswerTimeout +10 +``` + +They are used when the Expo plugin is not managing the values. Omit any key to +use its native default. + In Xcode → Signing & Capabilities this corresponds to **Background Modes → Voice over IP** and **Push Notifications**. @@ -234,12 +248,22 @@ permissions, `IncomingCallActivity`, `EndCallNotificationReceiver`, and the }, "ios": { "enableVoIPBackgroundMode": true + }, + "voip": { + "incomingCallTimeout": 45, + "outgoingCallTimeout": 60, + "fulfillAnswerCallTimeout": 10 } } ] ] ``` +All `voip` timeout properties are optional positive finite seconds. The defaults +are 45 seconds for incoming ringing, 60 seconds for unconnected outgoing calls, +and 10 seconds for the answer-fulfillment handshake. A ring timeout ends the +call with the existing `missed` reason. + ### 9.2 Point Expo at `google-services.json` `android.googleServicesFile` is what actually wires Firebase into the native build: @@ -363,10 +387,21 @@ Second, the manifest entries. Add to `android/app/src/main/AndroidManifest.xml`: + + + + + ``` -`packages/mobile-client/plugin/src/withFishjamVoip.ts` is the source of truth for these +`packages/mobile-client/plugin/src/withFishjamVoipAndroid.ts` is the source of truth for these entries — if the plugin gains one, mirror it here. Note that `firebase-messaging` arrives automatically as a transitive dependency of diff --git a/packages/mobile-client/README.md b/packages/mobile-client/README.md index 92624e798..14631da15 100644 --- a/packages/mobile-client/README.md +++ b/packages/mobile-client/README.md @@ -32,6 +32,35 @@ Prebuild does the rest. [`android.googleServicesFile`](https://docs.expo.dev/ver makes Expo add the `com.google.gms:google-services` classpath, apply the Gradle plugin, and copy the file into `android/app/`. Omitting it while `enableVoip` is on is a prebuild error. +### Call timeouts + +The plugin can set native timeouts in seconds. An unanswered incoming call defaults +to 45 seconds, an unconnected outgoing call defaults to 60 seconds, and the +answer-fulfillment handshake defaults to 10 seconds: + +```json +{ + "expo": { + "plugins": [ + [ + "@fishjam-cloud/react-native-client", + { + "android": { "enableVoip": true }, + "voip": { + "incomingCallTimeout": 45, + "outgoingCallTimeout": 60, + "fulfillAnswerCallTimeout": 10 + } + } + ] + ] + } +} +``` + +All timeout properties are optional and must be positive finite numbers. Omit a +property to use its native default. + ### Bare React Native Config plugins do not run, and the [`google-services` Gradle plugin](https://developers.google.com/android/guides/google-services-plugin) @@ -43,9 +72,23 @@ You must also declare by hand what the config plugin would otherwise inject into `AndroidManifest.xml` — the `MANAGE_OWN_CALLS`, `POST_NOTIFICATIONS`, `USE_FULL_SCREEN_INTENT` and `VIBRATE` permissions, the `IncomingCallActivity`, the `EndCallNotificationReceiver`, and the `PushNotificationService` with its -`com.google.firebase.MESSAGING_EVENT` intent filter. See `plugin/src/withFishjamVoip.ts` +`com.google.firebase.MESSAGING_EVENT` intent filter. See `plugin/src/withFishjamVoipAndroid.ts` for the exact entries. +Set timeout metadata manually when using bare React Native: + +```xml + + + + + +``` + +For iOS, add the same timeout values as numeric `Info.plist` keys: +`FishjamVoipIncomingCallTimeout`, `FishjamVoipOutgoingCallTimeout`, and +`FishjamVoipFulfillAnswerTimeout`. + ## Local Development with WebRTC Fork This package depends on `@fishjam-cloud/react-native-webrtc`, a fork of `react-native-webrtc`. The fork lives in [its own GitHub repo](https://github.com/fishjam-cloud/fishjam-react-native-webrtc) and is included in this monorepo as a git submodule at `packages/react-native-webrtc/`, wired up as a yarn workspace. No manual linking is required. diff --git a/packages/mobile-client/plugin/src/types.ts b/packages/mobile-client/plugin/src/types.ts index 04dabdf9e..9370149b2 100644 --- a/packages/mobile-client/plugin/src/types.ts +++ b/packages/mobile-client/plugin/src/types.ts @@ -16,5 +16,10 @@ export type FishjamPluginOptions = iphoneDeploymentTarget?: string; enableVoIPBackgroundMode?: boolean; }; + voip?: { + incomingCallTimeout?: number; + outgoingCallTimeout?: number; + fulfillAnswerCallTimeout?: number; + }; } | undefined; diff --git a/packages/mobile-client/plugin/src/withFishjamAndroid.ts b/packages/mobile-client/plugin/src/withFishjamAndroid.ts index e926051fe..ec29c583b 100644 --- a/packages/mobile-client/plugin/src/withFishjamAndroid.ts +++ b/packages/mobile-client/plugin/src/withFishjamAndroid.ts @@ -3,7 +3,7 @@ import { AndroidConfig, withAndroidManifest } from '@expo/config-plugins'; import { getMainApplicationOrThrow } from '@expo/config-plugins/build/android/Manifest'; import type { FishjamPluginOptions } from './types'; -import { withFishjamVoipAndroid } from './withFishjamVoip'; +import { withFishjamVoipAndroid } from './withFishjamVoipAndroid'; const needsForegroundService = (props: FishjamPluginOptions) => Boolean(props?.android?.enableForegroundService || props?.android?.enableVoip); diff --git a/packages/mobile-client/plugin/src/withFishjamIos.ts b/packages/mobile-client/plugin/src/withFishjamIos.ts index dab92eee1..96918a69d 100644 --- a/packages/mobile-client/plugin/src/withFishjamIos.ts +++ b/packages/mobile-client/plugin/src/withFishjamIos.ts @@ -307,6 +307,28 @@ const withFishjamVoIPBackgroundMode: ConfigPlugin = (confi return configuration; }); +const withFishjamVoipTimeouts: ConfigPlugin = (config, props) => + withInfoPlist(config, (configuration) => { + const timeouts = [ + ['FishjamVoipIncomingCallTimeout', 'incomingCallTimeout'], + ['FishjamVoipOutgoingCallTimeout', 'outgoingCallTimeout'], + ['FishjamVoipFulfillAnswerTimeout', 'fulfillAnswerCallTimeout'], + ] as const; + + timeouts.forEach(([key, option]) => { + const seconds = props?.voip?.[option]; + if (seconds === undefined) { + return; + } + if (!Number.isFinite(seconds) || seconds <= 0) { + throw new Error(`Fishjam VoIP ${option} must be a positive finite number of seconds.`); + } + configuration.modResults[key] = Math.floor(seconds); + }); + + return configuration; + }); + const withFishjamPictureInPicture: ConfigPlugin = (config, props) => withInfoPlist(config, (configuration) => { if (props?.ios?.supportsPictureInPicture) { @@ -330,6 +352,7 @@ const withFishjamIos: ConfigPlugin = (config, props) => { }); config = withFishjamPictureInPicture(config, props); config = withFishjamVoIPBackgroundMode(config, props); + config = withFishjamVoipTimeouts(config, props); return config; }; diff --git a/packages/mobile-client/plugin/src/withFishjamVoip.ts b/packages/mobile-client/plugin/src/withFishjamVoipAndroid.ts similarity index 75% rename from packages/mobile-client/plugin/src/withFishjamVoip.ts rename to packages/mobile-client/plugin/src/withFishjamVoipAndroid.ts index 4a9d42929..b4932cf72 100644 --- a/packages/mobile-client/plugin/src/withFishjamVoip.ts +++ b/packages/mobile-client/plugin/src/withFishjamVoipAndroid.ts @@ -62,6 +62,18 @@ const INSTALLATION_ID_META = { }, }; +const VOIP_TIMEOUTS = [ + ['FishjamVoipIncomingCallTimeout', 'incomingCallTimeout'], + ['FishjamVoipOutgoingCallTimeout', 'outgoingCallTimeout'], + ['FishjamVoipFulfillAnswerTimeout', 'fulfillAnswerCallTimeout'], +] as const; + +function validateTimeout(name: string, seconds: number): void { + if (!Number.isFinite(seconds) || seconds <= 0) { + throw new Error(`Fishjam VoIP ${name} must be a positive finite number of seconds.`); + } +} + export const withFishjamVoipAndroid: ConfigPlugin = (config, props) => withAndroidManifest(config, (configuration) => { if (!props?.android?.enableVoip) { @@ -116,14 +128,35 @@ export const withFishjamVoipAndroid: ConfigPlugin = (confi mainApplication.service.push(MESSAGING_SERVICE); } - mainApplication['meta-data'] = mainApplication['meta-data'] || []; + const metadataEntries = mainApplication['meta-data'] || []; + mainApplication['meta-data'] = metadataEntries; const metaName = INSTALLATION_ID_META.$['android:name']; - const existingMetaIndex = mainApplication['meta-data'].findIndex((meta) => meta.$['android:name'] === metaName); + const existingMetaIndex = metadataEntries.findIndex((meta) => meta.$['android:name'] === metaName); if (existingMetaIndex !== -1) { - mainApplication['meta-data'][existingMetaIndex] = INSTALLATION_ID_META; + metadataEntries[existingMetaIndex] = INSTALLATION_ID_META; } else { - mainApplication['meta-data'].push(INSTALLATION_ID_META); + metadataEntries.push(INSTALLATION_ID_META); } + VOIP_TIMEOUTS.forEach(([key, option]) => { + const seconds = props?.voip?.[option]; + if (seconds === undefined) { + return; + } + validateTimeout(option, seconds); + const timeoutMetadata = { + $: { + 'android:name': key, + 'android:value': String(Math.floor(seconds)), + }, + }; + const existingTimeoutIndex = metadataEntries.findIndex((meta) => meta.$['android:name'] === key); + if (existingTimeoutIndex !== -1) { + metadataEntries[existingTimeoutIndex] = timeoutMetadata; + } else { + metadataEntries.push(timeoutMetadata); + } + }); + return configuration; }); diff --git a/packages/react-native-webrtc b/packages/react-native-webrtc index b2e4d3755..bc41f1c25 160000 --- a/packages/react-native-webrtc +++ b/packages/react-native-webrtc @@ -1 +1 @@ -Subproject commit b2e4d3755ed9982871d54d4c627e8d60ee828695 +Subproject commit bc41f1c259649a45c822094671c5578cf448277a From a9551afb063149477acd5c651222f6054067d59f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gadomski?= Date: Tue, 14 Jul 2026 11:45:26 +0200 Subject: [PATCH 29/52] Add connecting state before acctually answering --- .../voip-call/app/src/voip/VoipProvider.tsx | 14 ++++++-------- packages/mobile-client/src/index.ts | 1 + packages/react-native-webrtc | 2 +- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx b/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx index 3e8fa78cb..82bb20fb3 100644 --- a/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx +++ b/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx @@ -2,6 +2,7 @@ import { type CallEndedReason, failIncomingCallConnected, fulfillIncomingCallConnected, + reportOutgoingCallConnected, useCallKit, useCamera, useConnection, @@ -77,11 +78,8 @@ export function VoipProvider({ const { startMicrophone, stopMicrophone } = useMicrophone(); const { joinRoom, leaveRoom } = useConnection(); const { startCallKitSession, endCallKitSession } = useCallKit(); - const { - startCall: startTelecomSession, - endCall: endTelecomSession, - setCallActive: setTelecomCallActive, - } = useTelecom(); + const { startCall: startTelecomSession, endCall: endTelecomSession } = + useTelecom(); const { remotePeers } = usePeers(); const startNativeCallSession = useCallback( @@ -251,8 +249,8 @@ export function VoipProvider({ await endCall('failed'); return; } - } else if (Platform.OS === 'android') { - await setTelecomCallActive(); + } else if (currentCallRef.current?.isOutgoing) { + await reportOutgoingCallConnected(); } const currentCall = currentCallRef.current; @@ -280,7 +278,7 @@ export function VoipProvider({ console.error('Failed to end call:', err), ); } - }, [remotePeers.length, status, endCall, setTelecomCallActive]); + }, [remotePeers.length, status, endCall]); const voipValue = useMemo( () => ({ diff --git a/packages/mobile-client/src/index.ts b/packages/mobile-client/src/index.ts index c353438a1..4f9ad91a6 100644 --- a/packages/mobile-client/src/index.ts +++ b/packages/mobile-client/src/index.ts @@ -29,6 +29,7 @@ export { fulfillIncomingCallConnected, failIncomingCallConnected, getPendingAnswerRequestId, + reportOutgoingCallConnected, } from '@fishjam-cloud/react-native-webrtc'; export type { VoIPEventHandlers, VoipIncomingPayload } from '@fishjam-cloud/react-native-webrtc'; diff --git a/packages/react-native-webrtc b/packages/react-native-webrtc index bc41f1c25..2dfc09afa 160000 --- a/packages/react-native-webrtc +++ b/packages/react-native-webrtc @@ -1 +1 @@ -Subproject commit bc41f1c259649a45c822094671c5578cf448277a +Subproject commit 2dfc09afab3a8c2f402b865a28eee217f362779f From e72a38ce3e00aec24ca7e470d0d97398f0dfe23f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gadomski?= Date: Tue, 14 Jul 2026 15:01:14 +0200 Subject: [PATCH 30/52] Add enableCallIntents plugin option and thread handle through example --- examples/mobile-client/voip-call/app/App.tsx | 3 +- .../voip-call/app/src/voip/VoipContext.ts | 6 ++ .../voip-call/app/src/voip/VoipProvider.tsx | 55 ++++++++++++++- packages/mobile-client/plugin/src/types.ts | 1 + .../plugin/src/withFishjamIos.ts | 68 ++++++++++++++++++- packages/mobile-client/src/index.ts | 2 +- 6 files changed, 130 insertions(+), 5 deletions(-) diff --git a/examples/mobile-client/voip-call/app/App.tsx b/examples/mobile-client/voip-call/app/App.tsx index 4af96c542..d753165b0 100644 --- a/examples/mobile-client/voip-call/app/App.tsx +++ b/examples/mobile-client/voip-call/app/App.tsx @@ -73,7 +73,8 @@ function VoipWrapper({ children }: PropsWithChildren) { + isVideo={true} + canStartOutgoingCall={Boolean(username)}> diff --git a/examples/mobile-client/voip-call/app/src/voip/VoipContext.ts b/examples/mobile-client/voip-call/app/src/voip/VoipContext.ts index 6b3cd7251..0c6617d14 100644 --- a/examples/mobile-client/voip-call/app/src/voip/VoipContext.ts +++ b/examples/mobile-client/voip-call/app/src/voip/VoipContext.ts @@ -19,6 +19,12 @@ export type CurrentCall = { roomName: string; /** Name shown in the CallKit UI (the remote party). */ displayName: string; + /** + * Stable id of the remote party. In this example the username is the identity, so + * it doubles as the handle; an app with non-unique display names should use its own + * user id here, since this is what Recents hands back for redialing. + */ + handle: string; /** Whether the call is a video call. */ isVideo: boolean; /** Timestamp (ms) when the call became `active`, or `null` if not yet connected. */ diff --git a/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx b/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx index 82bb20fb3..a7ad3f058 100644 --- a/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx +++ b/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx @@ -10,6 +10,7 @@ import { usePeers, useTelecom, useVoIPEvents, + type VoipCallIntent, type VoipIncomingPayload, } from '@fishjam-cloud/react-native-client'; import { @@ -49,8 +50,18 @@ type VoipProviderProps = PropsWithChildren & { * Make sure the underlying room type is set accordingly. Defaults to `false` (audio-only). */ isVideo?: boolean; + /** Whether the app has restored enough session state to start an intent-driven outgoing call. */ + canStartOutgoingCall?: boolean; }; +function makeRoomName() { + const bytes = crypto.getRandomValues(new Uint8Array(6)); + const id = Array.from(bytes, (byte) => + byte.toString(16).padStart(2, '0'), + ).join(''); + return `voip-${id}`; +} + /** * 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 @@ -62,6 +73,7 @@ export function VoipProvider({ getPeerToken, requestCall, isVideo = false, + canStartOutgoingCall = true, children, }: VoipProviderProps) { const [voipToken, setVoipToken] = useState(null); @@ -73,6 +85,7 @@ export function VoipProvider({ const currentCallRef = useRef(null); const pendingAnswerRequestIdRef = useRef(null); const activationInFlightRef = useRef(false); + const pendingCallIntentRef = useRef(null); const { startCamera, stopCamera } = useCamera(); const { startMicrophone, stopMicrophone } = useMicrophone(); @@ -85,8 +98,8 @@ export function VoipProvider({ const startNativeCallSession = useCallback( (to: string) => Platform.OS === 'ios' - ? startCallKitSession({ displayName: to, isVideo }) - : startTelecomSession({ displayName: to, isVideo }), + ? startCallKitSession({ displayName: to, handle: to, isVideo }) + : startTelecomSession({ displayName: to, handle: to, isVideo }), [startCallKitSession, startTelecomSession, isVideo], ); @@ -148,6 +161,7 @@ export function VoipProvider({ const call: CurrentCall = { roomName, displayName: to, + handle: to, isVideo, startedAt: null, isOutgoing: true, @@ -199,6 +213,22 @@ export function VoipProvider({ [handleJoinRoom, endCall, resetCallState], ); + const startCallFromIntent = useCallback( + async (intent: VoipCallIntent) => { + if (currentCallRef.current) { + console.warn('Ignoring call intent while another call is active'); + return; + } + + try { + await startCall(intent.handle, makeRoomName()); + } catch (err) { + console.error('Failed to start call from a Recents intent:', err); + } + }, + [startCall], + ); + useVoIPEvents({ onRegistered: useCallback((token: string) => { setVoipToken(token); @@ -208,6 +238,7 @@ export function VoipProvider({ const call: CurrentCall = { roomName: payload.roomName, displayName: payload.displayName, + handle: payload.handle, isVideo: payload.isVideo, startedAt: null, isOutgoing: false, @@ -231,8 +262,28 @@ export function VoipProvider({ }, [endCall], ), + + onCallIntent: useCallback( + async (intent: VoipCallIntent) => { + if (!canStartOutgoingCall) { + pendingCallIntentRef.current = intent; + return; + } + await startCallFromIntent(intent); + }, + [canStartOutgoingCall, startCallFromIntent], + ), }); + useEffect(() => { + if (!canStartOutgoingCall || !pendingCallIntentRef.current) { + return; + } + const intent = pendingCallIntentRef.current; + pendingCallIntentRef.current = null; + startCallFromIntent(intent); + }, [canStartOutgoingCall, startCallFromIntent]); + useEffect(() => { if ( status === 'connecting' && diff --git a/packages/mobile-client/plugin/src/types.ts b/packages/mobile-client/plugin/src/types.ts index 9370149b2..b7d258de8 100644 --- a/packages/mobile-client/plugin/src/types.ts +++ b/packages/mobile-client/plugin/src/types.ts @@ -20,6 +20,7 @@ export type FishjamPluginOptions = incomingCallTimeout?: number; outgoingCallTimeout?: number; fulfillAnswerCallTimeout?: number; + enableCallIntents?: boolean; }; } | undefined; diff --git a/packages/mobile-client/plugin/src/withFishjamIos.ts b/packages/mobile-client/plugin/src/withFishjamIos.ts index 96918a69d..27a5f0429 100644 --- a/packages/mobile-client/plugin/src/withFishjamIos.ts +++ b/packages/mobile-client/plugin/src/withFishjamIos.ts @@ -1,5 +1,11 @@ import type { ConfigPlugin } from '@expo/config-plugins'; -import { withEntitlementsPlist, withInfoPlist, withPodfileProperties, withXcodeProject } from '@expo/config-plugins'; +import { + withAppDelegate, + withEntitlementsPlist, + withInfoPlist, + withPodfileProperties, + withXcodeProject, +} from '@expo/config-plugins'; import * as fs from 'fs/promises'; import * as path from 'path'; @@ -329,6 +335,65 @@ const withFishjamVoipTimeouts: ConfigPlugin = (config, pro return configuration; }); +const withFishjamVoipRecentsAndIntents: ConfigPlugin = (config, props) => { + const enabled = Boolean(props?.voip?.enableCallIntents); + if (!enabled) { + return config; + } + + config = withInfoPlist(config, (configuration) => { + const activityTypes = new Set( + Array.isArray(configuration.modResults.NSUserActivityTypes) + ? (configuration.modResults.NSUserActivityTypes as string[]) + : [], + ); + // INStartCallIntent superseded the audio/video variants in iOS 13; the SDK targets 13.4. + activityTypes.add('INStartCallIntent'); + configuration.modResults.NSUserActivityTypes = Array.from(activityTypes); + return configuration; + }); + + config = withAppDelegate(config, (configuration) => { + const { modResults } = configuration; + if (modResults.language !== 'swift' || modResults.contents.includes('VoipManager.handleContinueUserActivity')) { + return configuration; + } + + const handler = ` + public override func application( + _ application: UIApplication, + continue userActivity: NSUserActivity, + restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void + ) -> Bool { + if VoipManager.handleContinueUserActivity(userActivity) { + return true + } + return super.application( + application, + continue: userActivity, + restorationHandler: restorationHandler + ) + } +`; + const existingHandler = + /(public override func application\(\s*_ application: UIApplication,\s*continue userActivity: NSUserActivity,\s*restorationHandler: @escaping \(\[UIUserActivityRestoring\]\?\) -> Void\s*\) -> Bool \{\n)/; + if (existingHandler.test(modResults.contents)) { + modResults.contents = modResults.contents.replace( + existingHandler, + `$1 if VoipManager.handleContinueUserActivity(userActivity) {\n return true\n }\n`, + ); + } else { + modResults.contents = modResults.contents.replace( + /\n}\n\nclass ReactNativeDelegate/, + `${handler}\n}\n\nclass ReactNativeDelegate`, + ); + } + return configuration; + }); + + return config; +}; + const withFishjamPictureInPicture: ConfigPlugin = (config, props) => withInfoPlist(config, (configuration) => { if (props?.ios?.supportsPictureInPicture) { @@ -353,6 +418,7 @@ const withFishjamIos: ConfigPlugin = (config, props) => { config = withFishjamPictureInPicture(config, props); config = withFishjamVoIPBackgroundMode(config, props); config = withFishjamVoipTimeouts(config, props); + config = withFishjamVoipRecentsAndIntents(config, props); return config; }; diff --git a/packages/mobile-client/src/index.ts b/packages/mobile-client/src/index.ts index 4f9ad91a6..686cc153c 100644 --- a/packages/mobile-client/src/index.ts +++ b/packages/mobile-client/src/index.ts @@ -32,7 +32,7 @@ export { reportOutgoingCallConnected, } from '@fishjam-cloud/react-native-webrtc'; -export type { VoIPEventHandlers, VoipIncomingPayload } from '@fishjam-cloud/react-native-webrtc'; +export type { VoIPEventHandlers, VoipCallIntent, VoipIncomingPayload } from '@fishjam-cloud/react-native-webrtc'; export type { CallEndedReason, From 37d94e17ec23115513d0d0ba297dd894dae58403 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Tue, 14 Jul 2026 15:47:52 +0200 Subject: [PATCH 31/52] Declare deprecated call intent activity types for Recents redial Recents redial still delivers INStartAudioCallIntent/INStartVideoCallIntent, so all three intent types must be listed in NSUserActivityTypes for iOS to hand the activity to the app. Co-Authored-By: Claude Opus 4.8 --- packages/mobile-client/plugin/src/withFishjamIos.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/mobile-client/plugin/src/withFishjamIos.ts b/packages/mobile-client/plugin/src/withFishjamIos.ts index 27a5f0429..a7bbfaaeb 100644 --- a/packages/mobile-client/plugin/src/withFishjamIos.ts +++ b/packages/mobile-client/plugin/src/withFishjamIos.ts @@ -347,8 +347,11 @@ const withFishjamVoipRecentsAndIntents: ConfigPlugin = (co ? (configuration.modResults.NSUserActivityTypes as string[]) : [], ); - // INStartCallIntent superseded the audio/video variants in iOS 13; the SDK targets 13.4. + // The audio/video variants are deprecated in favour of INStartCallIntent, but Recents + // redial still delivers them, so all three must be declared. activityTypes.add('INStartCallIntent'); + activityTypes.add('INStartAudioCallIntent'); + activityTypes.add('INStartVideoCallIntent'); configuration.modResults.NSUserActivityTypes = Array.from(activityTypes); return configuration; }); From 8514e9348814716837cbb9b807aaa389b534ef7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gadomski?= Date: Tue, 14 Jul 2026 16:45:58 +0200 Subject: [PATCH 32/52] Add call hold and resume support --- examples/mobile-client/voip-call/README.md | 134 +++++++++++++++++- .../app/src/components/InCallButton.tsx | 19 ++- .../app/src/screens/InCallScreen.tsx | 21 ++- .../voip-call/app/src/voip/VoipContext.ts | 4 + .../voip-call/app/src/voip/VoipProvider.tsx | 59 +++++++- packages/mobile-client/src/index.ts | 2 + packages/react-native-webrtc | 2 +- 7 files changed, 229 insertions(+), 12 deletions(-) diff --git a/examples/mobile-client/voip-call/README.md b/examples/mobile-client/voip-call/README.md index de998fd19..28233a620 100644 --- a/examples/mobile-client/voip-call/README.md +++ b/examples/mobile-client/voip-call/README.md @@ -138,6 +138,97 @@ use its native default. In Xcode → Signing & Capabilities this corresponds to **Background Modes → Voice over IP** and **Push Notifications**. +### 4.1 iOS Recents and tap-to-redial + +Recents are opt-in because entries persist in the system Phone app and may sync +through iCloud. Set `FishjamVoipIncludeCallsInRecents` to `true`: + +```xml +FishjamVoipIncludeCallsInRecents + +``` + +To make a Recents entry open the app and start a new call, add the CallKit intent +activity types: + +```xml +NSUserActivityTypes + + INStartCallIntent + INStartAudioCallIntent + INStartVideoCallIntent + +``` + +Then forward those activities to the native module **before** React Native Linking +or Expo handles them. Keep the existing fallback chain so universal links still +work: + +```swift +public override func application( + _ application: UIApplication, + continue userActivity: NSUserActivity, + restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void +) -> Bool { + if VoipManager.handleContinueUserActivity(userActivity) { + return true + } + + let result = RCTLinkingManager.application( + application, + continue: userActivity, + restorationHandler: restorationHandler + ) + return super.application( + application, + continue: userActivity, + restorationHandler: restorationHandler + ) || result +} +``` + +This uses the same iOS intent path as Siri, but the SDK does not add Siri +vocabulary, shortcuts, or other Siri-specific product behavior. + +With the Fishjam Expo plugin, use: + +```json +{ + "voip": { + "includeCallsInRecents": true + } +} +``` + +The plugin writes the two `Info.plist` settings and inserts the AppDelegate +forwarder for Swift Expo AppDelegates. It does not replace the required +`VoipManager` bridging-header import or the early +`VoipManager.registerForVoIPPushes()` registration shown above. + +### 4.2 Hold behavior + +No AppDelegate or `Info.plist` change is required for hold. The SDK exposes +CallKit/Core-Telecom hold events through `useVoIPEvents`: + +```ts +useVoIPEvents({ + onHeldChanged: async (onHold) => { + if (onHold) { + await stopMicrophone(); + await stopCamera(); + } else { + await startCamera(); + await startMicrophone(); + } + }, +}); +``` + +The application owns the media policy. The example stops its microphone and +outbound camera while held, then restores them when resumed. To initiate hold +from custom UI, use `useCallKit().setCallHeld(onHold)` on iOS or +`useTelecom().setCallHeld(onHold)` on Android. + --- ## 5. Server / APNs side @@ -198,6 +289,39 @@ Call `fulfillIncomingCallConnected` only when remote media is live; call handshake has a fixed 10-second deadline covering JS startup, token fetching, and room join. If it is not fulfilled in time, the call ends as failed. +### 6.1 Outgoing calls + +Starting an outgoing call (`useCallKit().startCallKitSession` on iOS, +`useTelecom().startCall` on Android) reports it to CallKit / Core-Telecom right +away, so the OS shows "Calling…" — but **no call timer runs yet**. Call +`reportOutgoingCallConnected()` only once the callee's media is actually live +(the same "first remote peer joined the room" signal used for the answer side); +that is what starts the CallKit / Core-Telecom call timer, so the dynamic +island, status bar, lock screen, and Android's shade all report the real +conversation duration instead of counting from the moment you dialed: + +```ts +import { reportOutgoingCallConnected } from "@fishjam-cloud/react-native-client"; + +await startCallKitSession({ displayName: "Alice", isVideo: false }); // startCall on Android +await joinRoom(); +// ...wait for the callee's track to appear in the room... +await reportOutgoingCallConnected(); +``` + +It is a cross-platform no-op if there is no active outgoing call — including +during an incoming call, and after the call has already ended (e.g. via the +outgoing timeout below). See the `remotePeers` effect in +[`VoipProvider.tsx`](./app/src/voip/VoipProvider.tsx), which calls it for +outgoing calls from the same place `fulfillIncomingCallConnected` is called +for incoming ones. + +If the callee never answers, the native outgoing timeout (default 60 seconds, +configurable via `FishjamVoipOutgoingCallTimeout` / the `voip.outgoingCallTimeout` +plugin option — see [section 9.1](#91-enable-the-plugin-option)) ends the call +as `missed` on both platforms, so the caller never sees an indefinitely +"Calling…" screen. + --- ## 7. Expo caveat @@ -221,6 +345,13 @@ re-applied on every prebuild. the app is backgrounded or killed. 4. Tap **Answer** / **End** on the system UI and confirm the `answer` / `ended` events log in Metro. +5. With `includeCallsInRecents` enabled, complete a call and confirm that it + appears in Phone → Recents. Tap its entry while the app is backgrounded and + after it has been terminated; the app should reopen and invoke + `onCallIntent` exactly once. +6. During an active VoIP call, receive a cellular call and select **Hold & + Accept**. Verify that the VoIP microphone and camera stop, then resume when + the cellular call ends and the VoIP call is made active again. --- @@ -252,7 +383,8 @@ permissions, `IncomingCallActivity`, `EndCallNotificationReceiver`, and the "voip": { "incomingCallTimeout": 45, "outgoingCallTimeout": 60, - "fulfillAnswerCallTimeout": 10 + "fulfillAnswerCallTimeout": 10, + "includeCallsInRecents": true } } ] diff --git a/examples/mobile-client/voip-call/app/src/components/InCallButton.tsx b/examples/mobile-client/voip-call/app/src/components/InCallButton.tsx index 5082c8885..6035a6143 100644 --- a/examples/mobile-client/voip-call/app/src/components/InCallButton.tsx +++ b/examples/mobile-client/voip-call/app/src/components/InCallButton.tsx @@ -15,6 +15,7 @@ type InCallButtonProps = { onPress: (event: GestureResponderEvent) => void; iconName: keyof typeof MaterialCommunityIcons.glyphMap; accessibilityLabel?: string; + disabled?: boolean; }; export function InCallButton({ @@ -23,6 +24,7 @@ export function InCallButton({ onPress, iconName, accessibilityLabel, + disabled = false, }: InCallButtonProps) { const isDisconnect = type === 'disconnect'; const filled = isDisconnect || active; @@ -38,19 +40,25 @@ export function InCallButton({ return ( - + style={[ + styles.button, + { backgroundColor }, + !filled && styles.outline, + disabled && styles.disabled, + ]}> + ); } const styles = StyleSheet.create({ button: { - width: 60, - height: 60, - borderRadius: 30, + width: 52, + height: 52, + borderRadius: 26, justifyContent: 'center', alignItems: 'center', }, @@ -58,4 +66,5 @@ const styles = StyleSheet.create({ borderWidth: 1, borderColor: BrandColors.darkBlue80, }, + disabled: { opacity: 0.45 }, }); diff --git a/examples/mobile-client/voip-call/app/src/screens/InCallScreen.tsx b/examples/mobile-client/voip-call/app/src/screens/InCallScreen.tsx index c569c9d8e..603520b50 100644 --- a/examples/mobile-client/voip-call/app/src/screens/InCallScreen.tsx +++ b/examples/mobile-client/voip-call/app/src/screens/InCallScreen.tsx @@ -40,7 +40,7 @@ function useElapsed(startedAt: number | null): number { } export function InCallScreen() { - const { currentCall, endCall } = useVoip(); + const { currentCall, endCall, isOnHold, setCallHeld } = useVoip(); const { isMicrophoneOn, toggleMicrophone } = useMicrophone(); const { isCameraOn, toggleCamera } = useCamera(); @@ -74,6 +74,12 @@ export function InCallScreen() { } }; + const toggleHold = () => { + setCallHeld(!isOnHold).catch((err) => + console.warn('Failed to change held state:', err), + ); + }; + const controls = ( {isVideo && ( )} + Promise; + /** Requests that the native CallKit/Core-Telecom session be held or resumed. */ + setCallHeld: (onHold: boolean) => Promise; }; export const VoipContext = createContext(null); diff --git a/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx b/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx index a7ad3f058..ba6297b7f 100644 --- a/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx +++ b/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx @@ -3,6 +3,7 @@ import { failIncomingCallConnected, fulfillIncomingCallConnected, reportOutgoingCallConnected, + setCallHeld as setVoipCallHeld, useCallKit, useCamera, useConnection, @@ -81,14 +82,21 @@ export function VoipProvider({ const [currentCall, setCurrentCall] = useState(null); const [lastEndedReason, setLastEndedReason] = useState(null); + const [isOnHold, setIsOnHold] = useState(false); const currentCallRef = useRef(null); const pendingAnswerRequestIdRef = useRef(null); const activationInFlightRef = useRef(false); + const isCallOnHoldRef = useRef(false); + const heldMediaStateRef = useRef({ + microphoneEnabled: false, + cameraEnabled: false, + }); const pendingCallIntentRef = useRef(null); - const { startCamera, stopCamera } = useCamera(); - const { startMicrophone, stopMicrophone } = useMicrophone(); + const { isCameraOn, startCamera, stopCamera, toggleCamera } = useCamera(); + const { isMicrophoneOn, startMicrophone, stopMicrophone, toggleMicrophone } = + useMicrophone(); const { joinRoom, leaveRoom } = useConnection(); const { startCallKitSession, endCallKitSession } = useCallKit(); const { startCall: startTelecomSession, endCall: endTelecomSession } = @@ -111,6 +119,13 @@ export function VoipProvider({ [endCallKitSession, endTelecomSession], ); + const setCallHeld = useCallback(async (onHold: boolean) => { + if (!currentCallRef.current) { + return; + } + await setVoipCallHeld(onHold); + }, []); + const handleJoinRoom = useCallback( async (roomName: string) => { const token = await getPeerToken(roomName); @@ -134,6 +149,12 @@ export function VoipProvider({ if (!currentCallRef.current) return; currentCallRef.current = null; pendingAnswerRequestIdRef.current = null; + isCallOnHoldRef.current = false; + heldMediaStateRef.current = { + microphoneEnabled: false, + cameraEnabled: false, + }; + setIsOnHold(false); setCurrentCall(null); setStatus('available'); setLastEndedReason(reason); @@ -263,6 +284,36 @@ export function VoipProvider({ [endCall], ), + onHeldChanged: useCallback( + async (onHold: boolean) => { + const call = currentCallRef.current; + if (!call || isCallOnHoldRef.current === onHold) { + return; + } + + isCallOnHoldRef.current = onHold; + setIsOnHold(onHold); + try { + if (onHold) { + heldMediaStateRef.current = { + microphoneEnabled: isMicrophoneOn, + cameraEnabled: isCameraOn, + }; + if (isMicrophoneOn) await toggleMicrophone(); + if (isCameraOn) await toggleCamera(); + } else { + const { microphoneEnabled, cameraEnabled } = + heldMediaStateRef.current; + if (microphoneEnabled) await toggleMicrophone(); + if (cameraEnabled) await toggleCamera(); + } + } catch (err) { + console.error('Failed to update media for held call:', err); + } + }, + [isCameraOn, isMicrophoneOn, toggleCamera, toggleMicrophone], + ), + onCallIntent: useCallback( async (intent: VoipCallIntent) => { if (!canStartOutgoingCall) { @@ -337,18 +388,22 @@ export function VoipProvider({ status, currentCall, lastEndedReason, + isOnHold, startCall, answerCall, endCall, + setCallHeld, }), [ voipToken, status, currentCall, lastEndedReason, + isOnHold, startCall, answerCall, endCall, + setCallHeld, ], ); diff --git a/packages/mobile-client/src/index.ts b/packages/mobile-client/src/index.ts index 686cc153c..91851687b 100644 --- a/packages/mobile-client/src/index.ts +++ b/packages/mobile-client/src/index.ts @@ -30,6 +30,8 @@ export { failIncomingCallConnected, getPendingAnswerRequestId, reportOutgoingCallConnected, + setCallHeld, + isCallHeld, } from '@fishjam-cloud/react-native-webrtc'; export type { VoIPEventHandlers, VoipCallIntent, VoipIncomingPayload } from '@fishjam-cloud/react-native-webrtc'; diff --git a/packages/react-native-webrtc b/packages/react-native-webrtc index 2dfc09afa..89bf48e43 160000 --- a/packages/react-native-webrtc +++ b/packages/react-native-webrtc @@ -1 +1 @@ -Subproject commit 2dfc09afab3a8c2f402b865a28eee217f362779f +Subproject commit 89bf48e43b534b74404260ec2f10533598a83f12 From 5768bdfc9e8640a2bb7b4723986e584e576643d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Tue, 14 Jul 2026 17:20:50 +0200 Subject: [PATCH 33/52] Add fishjam branding icons and splash screen Co-authored-by: Cursor --- examples/mobile-client/voip-call/app/app.json | 17 + .../app/assets/images/adaptive-icon.png | Bin 0 -> 3648 bytes .../voip-call/app/assets/images/favicon.png | Bin 0 -> 1129 bytes .../app/assets/images/fishjam-logo.png | Bin 0 -> 3761 bytes .../voip-call/app/assets/images/icon.png | Bin 0 -> 6483 bytes .../voip-call/app/assets/images/splash.png | Bin 0 -> 6506 bytes .../mobile-client/voip-call/app/package.json | 1 + .../voip-call/app/src/screens/LoginScreen.tsx | 15 + yarn.lock | 370 ++++++++++++++++++ 9 files changed, 403 insertions(+) create mode 100644 examples/mobile-client/voip-call/app/assets/images/adaptive-icon.png create mode 100644 examples/mobile-client/voip-call/app/assets/images/favicon.png create mode 100644 examples/mobile-client/voip-call/app/assets/images/fishjam-logo.png create mode 100644 examples/mobile-client/voip-call/app/assets/images/icon.png create mode 100644 examples/mobile-client/voip-call/app/assets/images/splash.png diff --git a/examples/mobile-client/voip-call/app/app.json b/examples/mobile-client/voip-call/app/app.json index d39514542..e3a93a502 100644 --- a/examples/mobile-client/voip-call/app/app.json +++ b/examples/mobile-client/voip-call/app/app.json @@ -4,6 +4,7 @@ "slug": "voip-call", "version": "1.0.0", "orientation": "portrait", + "icon": "./assets/images/icon.png", "scheme": "voipcall", "userInterfaceStyle": "automatic", "newArchEnabled": true, @@ -19,6 +20,11 @@ "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", @@ -28,6 +34,9 @@ "android.permission.ACCESS_WIFI_STATE" ] }, + "web": { + "favicon": "./assets/images/favicon.png" + }, "plugins": [ [ "@fishjam-cloud/react-native-client", @@ -39,6 +48,14 @@ "enableVoIPBackgroundMode": true } } + ], + [ + "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 new file mode 100644 index 0000000000000000000000000000000000000000..4053315ea8cad2af412f94df1e5afe347afe93f8 GIT binary patch literal 3648 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 WwL{zXu4Xfr%d@pQY{}kFz3^Yz_x$Jp literal 0 HcmV?d00001 diff --git a/examples/mobile-client/voip-call/app/assets/images/favicon.png b/examples/mobile-client/voip-call/app/assets/images/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..408bd746615785760b09e6f23fa8378d3e614331 GIT binary patch literal 1129 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 b^vnMP06&BEV6zMx00000NkvXXu0mjf84yMR literal 0 HcmV?d00001 diff --git a/examples/mobile-client/voip-call/app/assets/images/icon.png b/examples/mobile-client/voip-call/app/assets/images/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..31be37b2c6c59b989806f6473b5be99b26dc4bb8 GIT binary patch literal 6483 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 RtA8#c%#AILN`Ixq{0oq!5zznu literal 0 HcmV?d00001 diff --git a/examples/mobile-client/voip-call/app/assets/images/splash.png b/examples/mobile-client/voip-call/app/assets/images/splash.png new file mode 100644 index 0000000000000000000000000000000000000000..f064caf2dc780547489ced022f68643b4145cb3e GIT binary patch literal 6506 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 + + =0.14.0 <1.0.0" + peerDependenciesMeta: + "@babel/runtime": + optional: true + expo: + optional: true + checksum: 10c0/50eae5df3cf2502b66ecee09850ab93f99ec41e412effede1aa2bebc1053b5d13102107d4cfacb89719ad4bcfd2bd09adeb916f9c25630db686f728ec635fbc5 + languageName: node + linkType: hard + "babel-preset-expo@npm:~54.0.9": version: 54.0.9 resolution: "babel-preset-expo@npm:54.0.9" @@ -10848,6 +11088,20 @@ __metadata: languageName: node linkType: hard +"expo-asset@npm:~12.0.13": + version: 12.0.13 + resolution: "expo-asset@npm:12.0.13" + dependencies: + "@expo/image-utils": "npm:^0.8.8" + expo-constants: "npm:~18.0.13" + peerDependencies: + expo: "*" + react: "*" + react-native: "*" + checksum: 10c0/5cc97d5e14042d43d6bbdaa6805da56e65ca91f0579df702f90c84e02100a305b1c1199492507b5e2db1cad641a04bcf87a324411dcdab394ca1ad62c2f98715 + languageName: node + linkType: hard + "expo-constants@npm:~18.0.12, expo-constants@npm:~18.0.13": version: 18.0.13 resolution: "expo-constants@npm:18.0.13" @@ -10871,6 +11125,16 @@ __metadata: languageName: node linkType: hard +"expo-file-system@npm:~19.0.23": + version: 19.0.23 + resolution: "expo-file-system@npm:19.0.23" + peerDependencies: + expo: "*" + react-native: "*" + checksum: 10c0/deeaf62e1adad2506eaaa877aafcbbadf989b3564a97758a22581b6140701810f4db57abd9678a10dc911df8ec7d71581acf224a386a98a8735817f2b1e82f32 + languageName: node + linkType: hard + "expo-font@npm:~14.0.10": version: 14.0.10 resolution: "expo-font@npm:14.0.10" @@ -10884,6 +11148,19 @@ __metadata: languageName: node linkType: hard +"expo-font@npm:~14.0.12": + version: 14.0.12 + resolution: "expo-font@npm:14.0.12" + dependencies: + fontfaceobserver: "npm:^2.1.0" + peerDependencies: + expo: "*" + react: "*" + react-native: "*" + checksum: 10c0/a010c42cfd472078dbd8a94df57df9b816bbd4d69ac46aa588cf9f91c8348ebd4b1e00dc2dc0757e4a0c56ddab32455aef230ba697590b021b5d237cc37c4db6 + languageName: node + linkType: hard + "expo-haptics@npm:~15.0.8": version: 15.0.8 resolution: "expo-haptics@npm:15.0.8" @@ -10976,6 +11253,21 @@ __metadata: languageName: node linkType: hard +"expo-modules-autolinking@npm:3.0.26": + version: 3.0.26 + resolution: "expo-modules-autolinking@npm:3.0.26" + dependencies: + "@expo/spawn-async": "npm:^1.7.2" + chalk: "npm:^4.1.0" + commander: "npm:^7.2.0" + require-from-string: "npm:^2.0.2" + resolve-from: "npm:^5.0.0" + bin: + expo-modules-autolinking: bin/expo-modules-autolinking.js + checksum: 10c0/3021dac257c625ada92a39ec7ad101151eafe61929cd7e4965a70358a6e0f1022ced70909de7b3e74ae35b2f839496b8a7a567e75a253f19aaf1134cbeb91de5 + languageName: node + linkType: hard + "expo-modules-core@npm:3.0.29": version: 3.0.29 resolution: "expo-modules-core@npm:3.0.29" @@ -10988,6 +11280,18 @@ __metadata: languageName: node linkType: hard +"expo-modules-core@npm:3.0.30": + version: 3.0.30 + resolution: "expo-modules-core@npm:3.0.30" + dependencies: + invariant: "npm:^2.2.4" + peerDependencies: + react: "*" + react-native: "*" + checksum: 10c0/eccc6ff44cc68a3e6a1b1a2ada921d17c569dc95c2dbf9e3ca37cf33c89ab649e4a1125982b3377bfae683dbd91547bd6bd43044cce0bdfd36b01dff65269026 + languageName: node + linkType: hard + "expo-router@npm:~6.0.21": version: 6.0.21 resolution: "expo-router@npm:6.0.21" @@ -11057,6 +11361,13 @@ __metadata: languageName: node linkType: hard +"expo-server@npm:^1.0.7": + version: 1.0.7 + resolution: "expo-server@npm:1.0.7" + checksum: 10c0/1baf6dfb07e8b6be737a10c898c25e7ad80575e2e371413cd6530e81d471575f9454b1e3751ed14a9a7d8671d3da1e2917e27ac5faebb50671e87b923f0b7b27 + languageName: node + linkType: hard + "expo-splash-screen@npm:~31.0.13": version: 31.0.13 resolution: "expo-splash-screen@npm:31.0.13" @@ -11165,6 +11476,52 @@ __metadata: languageName: node linkType: hard +"expo@npm:~54.0.31": + version: 54.0.35 + resolution: "expo@npm:54.0.35" + dependencies: + "@babel/runtime": "npm:^7.20.0" + "@expo/cli": "npm:54.0.25" + "@expo/config": "npm:~12.0.13" + "@expo/config-plugins": "npm:~54.0.4" + "@expo/devtools": "npm:0.1.8" + "@expo/fingerprint": "npm:0.15.5" + "@expo/metro": "npm:~54.2.0" + "@expo/metro-config": "npm:54.0.16" + "@expo/vector-icons": "npm:^15.0.3" + "@ungap/structured-clone": "npm:^1.3.0" + babel-preset-expo: "npm:~54.0.11" + expo-asset: "npm:~12.0.13" + expo-constants: "npm:~18.0.13" + expo-file-system: "npm:~19.0.23" + expo-font: "npm:~14.0.12" + expo-keep-awake: "npm:~15.0.8" + expo-modules-autolinking: "npm:3.0.26" + expo-modules-core: "npm:3.0.30" + pretty-format: "npm:^29.7.0" + react-refresh: "npm:^0.14.2" + whatwg-url-without-unicode: "npm:8.0.0-3" + peerDependencies: + "@expo/dom-webview": "*" + "@expo/metro-runtime": "*" + react: "*" + react-native: "*" + react-native-webview: "*" + peerDependenciesMeta: + "@expo/dom-webview": + optional: true + "@expo/metro-runtime": + optional: true + react-native-webview: + optional: true + bin: + expo: bin/cli + expo-modules-autolinking: bin/autolinking + fingerprint: bin/fingerprint + checksum: 10c0/0f3f540527dc5c2458a0622db8e86e29c20b676370f32360924034ddc06974e2f66fff30fdcad7267d9d0f15fefadc4a8aae626d81e60982dd18c0dcbc0f2bfc + languageName: node + linkType: hard + "exponential-backoff@npm:^3.1.1": version: 3.1.2 resolution: "exponential-backoff@npm:3.1.2" @@ -11453,10 +11810,13 @@ __metadata: eslint-plugin-react-hooks: "npm:^5.2.0" eslint-plugin-react-refresh: "npm:^0.4.19" eslint-plugin-simple-import-sort: "npm:^12.1.1" + expo: "npm:~54.0.31" husky: "npm:^9.1.7" lint-staged: "npm:^16.2.7" prettier: "npm:^3.5.3" prettier-plugin-tailwindcss: "npm:^0.6.11" + react: "npm:19.1.0" + react-native: "npm:0.81.5" typedoc: "npm:^0.28.2" typedoc-material-theme: "npm:^1.3.0" typescript: "npm:^5.8.3" @@ -13424,6 +13784,15 @@ __metadata: languageName: node linkType: hard +"lan-network@npm:^0.2.1": + version: 0.2.1 + resolution: "lan-network@npm:0.2.1" + bin: + lan-network: dist/lan-network-cli.js + checksum: 10c0/14995644bab174cde57e41c80ed828a52d6b788f8701b8a8347c536f3ecade3e71bd33b98091484b27a1df1e35b7f6f1921ac59521bf905b5dd06ab123e82e94 + languageName: node + linkType: hard + "lazystream@npm:^1.0.0": version: 1.0.1 resolution: "lazystream@npm:1.0.1" @@ -18948,6 +19317,7 @@ __metadata: "@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" From 40e21b175936b79a80e52888fc81f68b0d3de224 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Tue, 14 Jul 2026 17:34:43 +0200 Subject: [PATCH 34/52] Fix invisible status bar icons on light backgrounds Co-authored-by: Cursor --- examples/mobile-client/voip-call/app/App.tsx | 2 +- .../mobile-client/voip-call/app/src/screens/InCallScreen.tsx | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/examples/mobile-client/voip-call/app/App.tsx b/examples/mobile-client/voip-call/app/App.tsx index d753165b0..c1f7f7fe2 100644 --- a/examples/mobile-client/voip-call/app/App.tsx +++ b/examples/mobile-client/voip-call/app/App.tsx @@ -171,7 +171,7 @@ const App = () => ( - + diff --git a/examples/mobile-client/voip-call/app/src/screens/InCallScreen.tsx b/examples/mobile-client/voip-call/app/src/screens/InCallScreen.tsx index 603520b50..b47a2d840 100644 --- a/examples/mobile-client/voip-call/app/src/screens/InCallScreen.tsx +++ b/examples/mobile-client/voip-call/app/src/screens/InCallScreen.tsx @@ -6,6 +6,7 @@ import { useVAD, } from '@fishjam-cloud/react-native-client'; import { useEffect, useMemo, 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'; @@ -123,6 +124,8 @@ export function InCallScreen() { if (isVideo) { return ( + {/* Dark video background needs light status bar icons, unlike every other (light) screen. */} + Date: Tue, 14 Jul 2026 20:27:02 +0200 Subject: [PATCH 35/52] Align react-native-webrtc submodule pointer with checked-out commit The superproject's recorded submodule commit was 2 commits behind what was actually checked out on this branch (missing the Recents and call-hold work). Co-authored-by: Cursor --- packages/react-native-webrtc | 2 +- yarn.lock | 369 ----------------------------------- 2 files changed, 1 insertion(+), 370 deletions(-) diff --git a/packages/react-native-webrtc b/packages/react-native-webrtc index 89bf48e43..4d185e8ce 160000 --- a/packages/react-native-webrtc +++ b/packages/react-native-webrtc @@ -1 +1 @@ -Subproject commit 89bf48e43b534b74404260ec2f10533598a83f12 +Subproject commit 4d185e8ce66ecc6d60fbf36cb4fa202912304bfd diff --git a/yarn.lock b/yarn.lock index 47f5e2177..3cff70475 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3547,88 +3547,6 @@ __metadata: languageName: node linkType: hard -"@expo/cli@npm:54.0.25": - version: 54.0.25 - resolution: "@expo/cli@npm:54.0.25" - dependencies: - "@0no-co/graphql.web": "npm:^1.0.8" - "@expo/code-signing-certificates": "npm:^0.0.6" - "@expo/config": "npm:~12.0.13" - "@expo/config-plugins": "npm:~54.0.4" - "@expo/devcert": "npm:^1.2.1" - "@expo/env": "npm:~2.0.8" - "@expo/image-utils": "npm:^0.8.8" - "@expo/json-file": "npm:^10.0.16" - "@expo/metro": "npm:~54.2.0" - "@expo/metro-config": "npm:~54.0.16" - "@expo/osascript": "npm:^2.3.8" - "@expo/package-manager": "npm:^1.9.10" - "@expo/plist": "npm:^0.4.9" - "@expo/prebuild-config": "npm:^54.0.8" - "@expo/schema-utils": "npm:^0.1.8" - "@expo/spawn-async": "npm:^1.7.2" - "@expo/ws-tunnel": "npm:^1.0.1" - "@expo/xcpretty": "npm:^4.3.0" - "@react-native/dev-middleware": "npm:0.81.5" - "@urql/core": "npm:^5.0.6" - "@urql/exchange-retry": "npm:^1.3.0" - accepts: "npm:^1.3.8" - arg: "npm:^5.0.2" - better-opn: "npm:~3.0.2" - bplist-creator: "npm:0.1.0" - bplist-parser: "npm:^0.3.1" - chalk: "npm:^4.0.0" - ci-info: "npm:^3.3.0" - compression: "npm:^1.7.4" - connect: "npm:^3.7.0" - debug: "npm:^4.3.4" - env-editor: "npm:^0.4.1" - expo-server: "npm:^1.0.7" - freeport-async: "npm:^2.0.0" - getenv: "npm:^2.0.0" - glob: "npm:^13.0.0" - lan-network: "npm:^0.2.1" - minimatch: "npm:^9.0.0" - node-forge: "npm:^1.3.3" - npm-package-arg: "npm:^11.0.0" - ora: "npm:^3.4.0" - picomatch: "npm:^4.0.3" - pretty-bytes: "npm:^5.6.0" - pretty-format: "npm:^29.7.0" - progress: "npm:^2.0.3" - prompts: "npm:^2.3.2" - qrcode-terminal: "npm:0.11.0" - require-from-string: "npm:^2.0.2" - requireg: "npm:^0.2.2" - resolve: "npm:^1.22.2" - resolve-from: "npm:^5.0.0" - resolve.exports: "npm:^2.0.3" - semver: "npm:^7.6.0" - send: "npm:^0.19.0" - slugify: "npm:^1.3.4" - source-map-support: "npm:~0.5.21" - stacktrace-parser: "npm:^0.1.10" - structured-headers: "npm:^0.4.1" - tar: "npm:^7.5.2" - terminal-link: "npm:^2.1.1" - undici: "npm:^6.18.2" - wrap-ansi: "npm:^7.0.0" - ws: "npm:^8.12.1" - peerDependencies: - expo: "*" - expo-router: "*" - react-native: "*" - peerDependenciesMeta: - expo-router: - optional: true - react-native: - optional: true - bin: - expo-internal: build/bin/cli - checksum: 10c0/0b68841ddb5d9371829ebbbe41042a1372ebee93bbd2268282528e72639b8849b284dd1d558b3ecdb87248bd0b5400f77f0623b149f4eb8352fddc737cd079f0 - languageName: node - linkType: hard - "@expo/code-signing-certificates@npm:^0.0.6": version: 0.0.6 resolution: "@expo/code-signing-certificates@npm:0.0.6" @@ -3796,27 +3714,6 @@ __metadata: languageName: node linkType: hard -"@expo/fingerprint@npm:0.15.5": - version: 0.15.5 - resolution: "@expo/fingerprint@npm:0.15.5" - dependencies: - "@expo/spawn-async": "npm:^1.7.2" - arg: "npm:^5.0.2" - chalk: "npm:^4.1.2" - debug: "npm:^4.3.4" - getenv: "npm:^2.0.0" - glob: "npm:^13.0.0" - ignore: "npm:^5.3.1" - minimatch: "npm:^10.2.2" - p-limit: "npm:^3.1.0" - resolve-from: "npm:^5.0.0" - semver: "npm:^7.6.0" - bin: - fingerprint: bin/cli.js - checksum: 10c0/bcb9cada73145e9180768ad32198da72a51fb7fb58d19833087cff7139b44143bed3239c50a346c9a8fb5df08f48f13fff5aacbbf514b87dda9bb4f15b87f3c3 - languageName: node - linkType: hard - "@expo/image-utils@npm:^0.8.8": version: 0.8.8 resolution: "@expo/image-utils@npm:0.8.8" @@ -3845,16 +3742,6 @@ __metadata: languageName: node linkType: hard -"@expo/json-file@npm:^10.0.16": - version: 10.2.0 - resolution: "@expo/json-file@npm:10.2.0" - dependencies: - "@babel/code-frame": "npm:^7.20.0" - json5: "npm:^2.2.3" - checksum: 10c0/198058e18dea2f31083c2ae8a6831dddfc8fc01c4cb30020728da04f155a6b600b4219830b6df48195548fa29a450b5b775007ed8430fb8098fd9a1656188ea0 - languageName: node - linkType: hard - "@expo/json-file@npm:^10.0.8, @expo/json-file@npm:~10.0.8": version: 10.0.8 resolution: "@expo/json-file@npm:10.0.8" @@ -3865,26 +3752,6 @@ __metadata: languageName: node linkType: hard -"@expo/json-file@npm:^11.0.0": - version: 11.0.0 - resolution: "@expo/json-file@npm:11.0.0" - dependencies: - "@babel/code-frame": "npm:^7.20.0" - json5: "npm:^2.2.3" - checksum: 10c0/4e040a6763efea8a2589788a1cd2fbadc8818cd5b2197162a300249567f624fd1ad320493fd463cab5ac704fe2a1480b85eca477e7c3226415181e48bd3d6a88 - languageName: node - linkType: hard - -"@expo/json-file@npm:~10.0.16": - version: 10.0.16 - resolution: "@expo/json-file@npm:10.0.16" - dependencies: - "@babel/code-frame": "npm:~7.10.4" - json5: "npm:^2.2.3" - checksum: 10c0/b80fbd1534916358392b582d3fd0aa99d3b9afdc2a56b7c473b09fb8593db781c4a9695b4172b64a8f1636c895a00fd3ab41985ba5e7332ffeeae7950a562f41 - languageName: node - linkType: hard - "@expo/metro-config@npm:54.0.13, @expo/metro-config@npm:~54.0.13": version: 54.0.13 resolution: "@expo/metro-config@npm:54.0.13" @@ -3919,40 +3786,6 @@ __metadata: languageName: node linkType: hard -"@expo/metro-config@npm:54.0.16, @expo/metro-config@npm:~54.0.16": - version: 54.0.16 - resolution: "@expo/metro-config@npm:54.0.16" - dependencies: - "@babel/code-frame": "npm:^7.20.0" - "@babel/core": "npm:^7.20.0" - "@babel/generator": "npm:^7.20.5" - "@expo/config": "npm:~12.0.13" - "@expo/env": "npm:~2.0.8" - "@expo/json-file": "npm:~10.0.16" - "@expo/metro": "npm:~54.2.0" - "@expo/spawn-async": "npm:^1.7.2" - browserslist: "npm:^4.25.0" - chalk: "npm:^4.1.0" - debug: "npm:^4.3.2" - dotenv: "npm:~16.4.5" - dotenv-expand: "npm:~11.0.6" - getenv: "npm:^2.0.0" - glob: "npm:^13.0.0" - hermes-parser: "npm:^0.29.1" - jsc-safe-url: "npm:^0.2.4" - lightningcss: "npm:^1.30.1" - picomatch: "npm:^4.0.3" - postcss: "npm:~8.4.32" - resolve-from: "npm:^5.0.0" - peerDependencies: - expo: "*" - peerDependenciesMeta: - expo: - optional: true - checksum: 10c0/c38be0d25746059b22a7fe54cf8bcbe661f3a68382407c7a0049d98d11a87b55816d089f1e4fd79fc9859bd8a37076016d73560517c852e6bf278befabe57553 - languageName: node - linkType: hard - "@expo/metro-runtime@npm:^6.1.2": version: 6.1.2 resolution: "@expo/metro-runtime@npm:6.1.2" @@ -4016,20 +3849,6 @@ __metadata: languageName: node linkType: hard -"@expo/package-manager@npm:^1.9.10": - version: 1.13.0 - resolution: "@expo/package-manager@npm:1.13.0" - dependencies: - "@expo/json-file": "npm:^11.0.0" - "@expo/spawn-async": "npm:^1.8.0" - chalk: "npm:^4.0.0" - npm-package-arg: "npm:^11.0.0" - ora: "npm:^3.4.0" - resolve-workspace-root: "npm:^2.0.0" - checksum: 10c0/8a2256558a2a6c9053221e31246ad487a95d7addf53d05ba71e7437f75201e00ccbdf4a8e52d8e21ecce3aa7b5dfe19587244875dd93b1062ca42d9c26a90efa - languageName: node - linkType: hard - "@expo/package-manager@npm:^1.9.9": version: 1.9.9 resolution: "@expo/package-manager@npm:1.9.9" @@ -4055,17 +3874,6 @@ __metadata: languageName: node linkType: hard -"@expo/plist@npm:^0.4.9": - version: 0.4.9 - resolution: "@expo/plist@npm:0.4.9" - dependencies: - "@xmldom/xmldom": "npm:^0.8.8" - base64-js: "npm:^1.2.3" - xmlbuilder: "npm:^15.1.1" - checksum: 10c0/5a36bad0dbf363be1405b0e3fbb9b9c5c22327db42734487f2c79298310a8094f980ee33ce5a7004b4c2ea09a1a52a2c1f6aa4cefa2dc50b13a83fb84827f43d - languageName: node - linkType: hard - "@expo/plist@npm:^0.5.2": version: 0.5.2 resolution: "@expo/plist@npm:0.5.2" @@ -4136,15 +3944,6 @@ __metadata: languageName: node linkType: hard -"@expo/spawn-async@npm:^1.8.0": - version: 1.8.0 - resolution: "@expo/spawn-async@npm:1.8.0" - dependencies: - cross-spawn: "npm:^7.0.6" - checksum: 10c0/08d3c63f9cc097ce9c8cf6850ca482fd7999a6fddc4cb38a3a9915a1662cb674fe7353de2eb3c693728542bf57db732ae433e82b2d698be141d07cea3092ebf3 - languageName: node - linkType: hard - "@expo/sudo-prompt@npm:^9.3.1": version: 9.3.2 resolution: "@expo/sudo-prompt@npm:9.3.2" @@ -8089,45 +7888,6 @@ __metadata: languageName: node linkType: hard -"babel-preset-expo@npm:~54.0.11": - version: 54.0.11 - resolution: "babel-preset-expo@npm:54.0.11" - dependencies: - "@babel/helper-module-imports": "npm:^7.25.9" - "@babel/plugin-proposal-decorators": "npm:^7.12.9" - "@babel/plugin-proposal-export-default-from": "npm:^7.24.7" - "@babel/plugin-syntax-export-default-from": "npm:^7.24.7" - "@babel/plugin-transform-class-static-block": "npm:^7.27.1" - "@babel/plugin-transform-export-namespace-from": "npm:^7.25.9" - "@babel/plugin-transform-flow-strip-types": "npm:^7.25.2" - "@babel/plugin-transform-modules-commonjs": "npm:^7.24.8" - "@babel/plugin-transform-object-rest-spread": "npm:^7.24.7" - "@babel/plugin-transform-parameters": "npm:^7.24.7" - "@babel/plugin-transform-private-methods": "npm:^7.24.7" - "@babel/plugin-transform-private-property-in-object": "npm:^7.24.7" - "@babel/plugin-transform-runtime": "npm:^7.24.7" - "@babel/preset-react": "npm:^7.22.15" - "@babel/preset-typescript": "npm:^7.23.0" - "@react-native/babel-preset": "npm:0.81.5" - babel-plugin-react-compiler: "npm:^1.0.0" - babel-plugin-react-native-web: "npm:~0.21.0" - babel-plugin-syntax-hermes-parser: "npm:^0.29.1" - babel-plugin-transform-flow-enums: "npm:^0.0.2" - debug: "npm:^4.3.4" - resolve-from: "npm:^5.0.0" - peerDependencies: - "@babel/runtime": ^7.20.0 - expo: "*" - react-refresh: ">=0.14.0 <1.0.0" - peerDependenciesMeta: - "@babel/runtime": - optional: true - expo: - optional: true - checksum: 10c0/50eae5df3cf2502b66ecee09850ab93f99ec41e412effede1aa2bebc1053b5d13102107d4cfacb89719ad4bcfd2bd09adeb916f9c25630db686f728ec635fbc5 - languageName: node - linkType: hard - "babel-preset-expo@npm:~54.0.9": version: 54.0.9 resolution: "babel-preset-expo@npm:54.0.9" @@ -11088,20 +10848,6 @@ __metadata: languageName: node linkType: hard -"expo-asset@npm:~12.0.13": - version: 12.0.13 - resolution: "expo-asset@npm:12.0.13" - dependencies: - "@expo/image-utils": "npm:^0.8.8" - expo-constants: "npm:~18.0.13" - peerDependencies: - expo: "*" - react: "*" - react-native: "*" - checksum: 10c0/5cc97d5e14042d43d6bbdaa6805da56e65ca91f0579df702f90c84e02100a305b1c1199492507b5e2db1cad641a04bcf87a324411dcdab394ca1ad62c2f98715 - languageName: node - linkType: hard - "expo-constants@npm:~18.0.12, expo-constants@npm:~18.0.13": version: 18.0.13 resolution: "expo-constants@npm:18.0.13" @@ -11125,16 +10871,6 @@ __metadata: languageName: node linkType: hard -"expo-file-system@npm:~19.0.23": - version: 19.0.23 - resolution: "expo-file-system@npm:19.0.23" - peerDependencies: - expo: "*" - react-native: "*" - checksum: 10c0/deeaf62e1adad2506eaaa877aafcbbadf989b3564a97758a22581b6140701810f4db57abd9678a10dc911df8ec7d71581acf224a386a98a8735817f2b1e82f32 - languageName: node - linkType: hard - "expo-font@npm:~14.0.10": version: 14.0.10 resolution: "expo-font@npm:14.0.10" @@ -11148,19 +10884,6 @@ __metadata: languageName: node linkType: hard -"expo-font@npm:~14.0.12": - version: 14.0.12 - resolution: "expo-font@npm:14.0.12" - dependencies: - fontfaceobserver: "npm:^2.1.0" - peerDependencies: - expo: "*" - react: "*" - react-native: "*" - checksum: 10c0/a010c42cfd472078dbd8a94df57df9b816bbd4d69ac46aa588cf9f91c8348ebd4b1e00dc2dc0757e4a0c56ddab32455aef230ba697590b021b5d237cc37c4db6 - languageName: node - linkType: hard - "expo-haptics@npm:~15.0.8": version: 15.0.8 resolution: "expo-haptics@npm:15.0.8" @@ -11253,21 +10976,6 @@ __metadata: languageName: node linkType: hard -"expo-modules-autolinking@npm:3.0.26": - version: 3.0.26 - resolution: "expo-modules-autolinking@npm:3.0.26" - dependencies: - "@expo/spawn-async": "npm:^1.7.2" - chalk: "npm:^4.1.0" - commander: "npm:^7.2.0" - require-from-string: "npm:^2.0.2" - resolve-from: "npm:^5.0.0" - bin: - expo-modules-autolinking: bin/expo-modules-autolinking.js - checksum: 10c0/3021dac257c625ada92a39ec7ad101151eafe61929cd7e4965a70358a6e0f1022ced70909de7b3e74ae35b2f839496b8a7a567e75a253f19aaf1134cbeb91de5 - languageName: node - linkType: hard - "expo-modules-core@npm:3.0.29": version: 3.0.29 resolution: "expo-modules-core@npm:3.0.29" @@ -11280,18 +10988,6 @@ __metadata: languageName: node linkType: hard -"expo-modules-core@npm:3.0.30": - version: 3.0.30 - resolution: "expo-modules-core@npm:3.0.30" - dependencies: - invariant: "npm:^2.2.4" - peerDependencies: - react: "*" - react-native: "*" - checksum: 10c0/eccc6ff44cc68a3e6a1b1a2ada921d17c569dc95c2dbf9e3ca37cf33c89ab649e4a1125982b3377bfae683dbd91547bd6bd43044cce0bdfd36b01dff65269026 - languageName: node - linkType: hard - "expo-router@npm:~6.0.21": version: 6.0.21 resolution: "expo-router@npm:6.0.21" @@ -11361,13 +11057,6 @@ __metadata: languageName: node linkType: hard -"expo-server@npm:^1.0.7": - version: 1.0.7 - resolution: "expo-server@npm:1.0.7" - checksum: 10c0/1baf6dfb07e8b6be737a10c898c25e7ad80575e2e371413cd6530e81d471575f9454b1e3751ed14a9a7d8671d3da1e2917e27ac5faebb50671e87b923f0b7b27 - languageName: node - linkType: hard - "expo-splash-screen@npm:~31.0.13": version: 31.0.13 resolution: "expo-splash-screen@npm:31.0.13" @@ -11476,52 +11165,6 @@ __metadata: languageName: node linkType: hard -"expo@npm:~54.0.31": - version: 54.0.35 - resolution: "expo@npm:54.0.35" - dependencies: - "@babel/runtime": "npm:^7.20.0" - "@expo/cli": "npm:54.0.25" - "@expo/config": "npm:~12.0.13" - "@expo/config-plugins": "npm:~54.0.4" - "@expo/devtools": "npm:0.1.8" - "@expo/fingerprint": "npm:0.15.5" - "@expo/metro": "npm:~54.2.0" - "@expo/metro-config": "npm:54.0.16" - "@expo/vector-icons": "npm:^15.0.3" - "@ungap/structured-clone": "npm:^1.3.0" - babel-preset-expo: "npm:~54.0.11" - expo-asset: "npm:~12.0.13" - expo-constants: "npm:~18.0.13" - expo-file-system: "npm:~19.0.23" - expo-font: "npm:~14.0.12" - expo-keep-awake: "npm:~15.0.8" - expo-modules-autolinking: "npm:3.0.26" - expo-modules-core: "npm:3.0.30" - pretty-format: "npm:^29.7.0" - react-refresh: "npm:^0.14.2" - whatwg-url-without-unicode: "npm:8.0.0-3" - peerDependencies: - "@expo/dom-webview": "*" - "@expo/metro-runtime": "*" - react: "*" - react-native: "*" - react-native-webview: "*" - peerDependenciesMeta: - "@expo/dom-webview": - optional: true - "@expo/metro-runtime": - optional: true - react-native-webview: - optional: true - bin: - expo: bin/cli - expo-modules-autolinking: bin/autolinking - fingerprint: bin/fingerprint - checksum: 10c0/0f3f540527dc5c2458a0622db8e86e29c20b676370f32360924034ddc06974e2f66fff30fdcad7267d9d0f15fefadc4a8aae626d81e60982dd18c0dcbc0f2bfc - languageName: node - linkType: hard - "exponential-backoff@npm:^3.1.1": version: 3.1.2 resolution: "exponential-backoff@npm:3.1.2" @@ -11810,13 +11453,10 @@ __metadata: eslint-plugin-react-hooks: "npm:^5.2.0" eslint-plugin-react-refresh: "npm:^0.4.19" eslint-plugin-simple-import-sort: "npm:^12.1.1" - expo: "npm:~54.0.31" husky: "npm:^9.1.7" lint-staged: "npm:^16.2.7" prettier: "npm:^3.5.3" prettier-plugin-tailwindcss: "npm:^0.6.11" - react: "npm:19.1.0" - react-native: "npm:0.81.5" typedoc: "npm:^0.28.2" typedoc-material-theme: "npm:^1.3.0" typescript: "npm:^5.8.3" @@ -13784,15 +13424,6 @@ __metadata: languageName: node linkType: hard -"lan-network@npm:^0.2.1": - version: 0.2.1 - resolution: "lan-network@npm:0.2.1" - bin: - lan-network: dist/lan-network-cli.js - checksum: 10c0/14995644bab174cde57e41c80ed828a52d6b788f8701b8a8347c536f3ecade3e71bd33b98091484b27a1df1e35b7f6f1921ac59521bf905b5dd06ab123e82e94 - languageName: node - linkType: hard - "lazystream@npm:^1.0.0": version: 1.0.1 resolution: "lazystream@npm:1.0.1" From d9f907f880d3f769c630991b67800ff6ba839a38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Tue, 14 Jul 2026 20:57:23 +0200 Subject: [PATCH 36/52] Bump react-native-webrtc submodule for call waiting support Co-authored-by: Cursor --- packages/react-native-webrtc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native-webrtc b/packages/react-native-webrtc index 4d185e8ce..2c20c9a22 160000 --- a/packages/react-native-webrtc +++ b/packages/react-native-webrtc @@ -1 +1 @@ -Subproject commit 4d185e8ce66ecc6d60fbf36cb4fa202912304bfd +Subproject commit 2c20c9a227c5e733f566d57618a0300d571d3148 From 70fe712d699dfd30e885afc32e9a8845444dbbe4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gadomski?= Date: Wed, 15 Jul 2026 21:15:21 +0200 Subject: [PATCH 37/52] Handle incoming waiting calls --- examples/mobile-client/voip-call/app/App.tsx | 29 ++- .../app/src/signaling/useCallSignaling.ts | 16 +- .../voip-call/app/src/voip/VoipProvider.tsx | 178 ++++++++++++++---- .../plugin/src/withFishjamIos.ts | 6 +- .../plugin/src/withFishjamVoipAndroid.ts | 6 +- packages/react-native-webrtc | 2 +- 6 files changed, 185 insertions(+), 52 deletions(-) diff --git a/examples/mobile-client/voip-call/app/App.tsx b/examples/mobile-client/voip-call/app/App.tsx index c1f7f7fe2..8ab9d0aa0 100644 --- a/examples/mobile-client/voip-call/app/App.tsx +++ b/examples/mobile-client/voip-call/app/App.tsx @@ -5,7 +5,7 @@ import { useSandbox, } from '@fishjam-cloud/react-native-client'; import { StatusBar } from 'expo-status-bar'; -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useRef, type MutableRefObject } from 'react'; import { ActivityIndicator, PermissionsAndroid, @@ -16,6 +16,7 @@ import { import { SafeAreaProvider } from 'react-native-safe-area-context'; import type { PropsWithChildren } from 'react'; +import type { VoipIncomingPayload } from '@fishjam-cloud/react-native-client'; import { InCallScreen } from './src/screens/InCallScreen'; import { LoginScreen } from './src/screens/LoginScreen'; import { OutgoingCallScreen } from './src/screens/OutgoingCallScreen'; @@ -31,13 +32,32 @@ const SANDBOX_API_URL = process.env.EXPO_PUBLIC_SANDBOX_API_URL ?? ''; // Thin wrapper that calls the signaling hook. // Must be inside VoipProvider so useCallSignaling can access useVoip(). -function CallSignaling({ username }: { username: string | null }) { - useCallSignaling({ serverUrl: SERVER_URL, username }); +function CallSignaling({ + username, + sendSignalRef, +}: { + username: string | null; + sendSignalRef: MutableRefObject< + ((msg: Record) => void) | undefined + >; +}) { + useCallSignaling({ serverUrl: SERVER_URL, username, sendSignalRef }); return null; } function VoipWrapper({ children }: PropsWithChildren) { const { username } = useUser(); + const sendSignalRef = useRef< + ((msg: Record) => void) | undefined + >(undefined); + + const onWaitingCallDeclined = useCallback((payload: VoipIncomingPayload) => { + sendSignalRef.current?.({ + type: 'call-rejected', + to: payload.displayName, + roomName: payload.roomName, + }); + }, []); const { getSandboxPeerToken } = useSandbox({ sandboxApiUrl: SANDBOX_API_URL, @@ -73,11 +93,12 @@ function VoipWrapper({ children }: PropsWithChildren) { - + {children} ); diff --git a/examples/mobile-client/voip-call/app/src/signaling/useCallSignaling.ts b/examples/mobile-client/voip-call/app/src/signaling/useCallSignaling.ts index 77fcfe0f5..36c88382f 100644 --- a/examples/mobile-client/voip-call/app/src/signaling/useCallSignaling.ts +++ b/examples/mobile-client/voip-call/app/src/signaling/useCallSignaling.ts @@ -1,13 +1,21 @@ -import { useCallback, useEffect, useRef } from 'react'; +import { useCallback, useEffect, useRef, type MutableRefObject } from 'react'; import { type CurrentCall, type VoipCallStatus, useVoip } from '../voip'; type Params = { serverUrl: string; username: string | null; + /** Filled by this hook so the parent can wire {@link VoipProvider.onWaitingCallDeclined}. */ + sendSignalRef: MutableRefObject< + ((msg: Record) => void) | undefined + >; }; -export function useCallSignaling({ serverUrl, username }: Params): void { +export function useCallSignaling({ + serverUrl, + username, + sendSignalRef, +}: Params): void { const { endCall, currentCall, status } = useVoip(); const socketRef = useRef(null); @@ -24,6 +32,10 @@ export function useCallSignaling({ serverUrl, username }: Params): void { } }, []); + useEffect(() => { + sendSignalRef.current = sendSignal; + }, [sendSignal, sendSignalRef]); + useEffect(() => { if (!username) return; diff --git a/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx b/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx index ba6297b7f..a73569c1a 100644 --- a/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx +++ b/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx @@ -46,6 +46,11 @@ type VoipProviderProps = PropsWithChildren & { roomName: string; isVideo: boolean; }) => Promise; + /** + * A waiting or overflow incoming call was declined from native UI. Does not + * change local call state - use for signaling (e.g. `call-rejected` to the caller). + */ + onWaitingCallDeclined?: (payload: VoipIncomingPayload) => void; /** * 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). @@ -73,6 +78,7 @@ function makeRoomName() { export function VoipProvider({ getPeerToken, requestCall, + onWaitingCallDeclined, isVideo = false, canStartOutgoingCall = true, children, @@ -93,6 +99,20 @@ export function VoipProvider({ cameraEnabled: false, }); const pendingCallIntentRef = useRef(null); + /** Fishjam room the client is currently joined to, if any. */ + const connectedRoomRef = useRef(null); + /** True while leaving one Fishjam room to join another (call-waiting swap). */ + const swapInProgressRef = useRef(false); + /** Serializes native call events so End & Accept cannot interleave teardown and join. */ + const callTransitionRef = useRef(Promise.resolve()); + /** Set when `onAnswered` arrives before the waiting-call `onIncoming` payload. */ + const pendingWaitingAnswerRef = useRef(null); + + const enqueueCallTransition = useCallback((op: () => Promise) => { + const run = callTransitionRef.current.then(op); + callTransitionRef.current = run.catch(() => {}); + return run; + }, []); const { isCameraOn, startCamera, stopCamera, toggleCamera } = useCamera(); const { isMicrophoneOn, startMicrophone, stopMicrophone, toggleMicrophone } = @@ -134,42 +154,60 @@ export function VoipProvider({ } await startMicrophone(); await joinRoom({ peerToken: token }); + connectedRoomRef.current = roomName; }, [getPeerToken, joinRoom, startMicrophone, startCamera, isVideo], ); const handleLeaveRoom = useCallback(async () => { + connectedRoomRef.current = null; await stopCamera(); await stopMicrophone(); await leaveRoom(); }, [leaveRoom, stopCamera, stopMicrophone]); const resetCallState = useCallback( - async (reason: CallEndedReason = 'local') => { - if (!currentCallRef.current) return; - currentCallRef.current = null; - pendingAnswerRequestIdRef.current = null; - isCallOnHoldRef.current = false; - heldMediaStateRef.current = { - microphoneEnabled: false, - cameraEnabled: false, - }; - setIsOnHold(false); - setCurrentCall(null); - setStatus('available'); - setLastEndedReason(reason); + async (reason: CallEndedReason = 'local', endedRoomName?: string) => { + const roomName = endedRoomName ?? currentCallRef.current?.roomName; + if (!roomName) return; + + if (currentCallRef.current?.roomName === roomName) { + currentCallRef.current = null; + pendingAnswerRequestIdRef.current = null; + pendingWaitingAnswerRef.current = null; + isCallOnHoldRef.current = false; + heldMediaStateRef.current = { + microphoneEnabled: false, + cameraEnabled: false, + }; + setIsOnHold(false); + setCurrentCall(null); + setStatus('available'); + setLastEndedReason(reason); + } - await handleLeaveRoom(); + if (connectedRoomRef.current === roomName) { + await handleLeaveRoom(); + } }, [handleLeaveRoom], ); const endCall = useCallback( - async (reason: CallEndedReason = 'local') => { - if (!currentCallRef.current) return; - const resetPromise = resetCallState(reason); + async ( + reason: CallEndedReason = 'local', + options?: { fromNative?: boolean }, + ) => { + const endedCall = currentCallRef.current; + if (!endedCall) return; + + const resetPromise = resetCallState(reason, endedCall.roomName); try { - await endNativeCallSession(reason); + // Native already ended the CallKit session before `onEnded` fired — calling + // endNativeCallSession again during call-waiting swap would end the new call. + if (!options?.fromNative) { + await endNativeCallSession(reason); + } } finally { await resetPromise; } @@ -255,37 +293,93 @@ export function VoipProvider({ setVoipToken(token); }, []), - onIncoming: useCallback((payload: VoipIncomingPayload) => { - const call: CurrentCall = { - roomName: payload.roomName, - displayName: payload.displayName, - handle: payload.handle, - isVideo: payload.isVideo, - startedAt: null, - isOutgoing: false, - }; - currentCallRef.current = call; - setCurrentCall(call); - setStatus('incoming'); - setLastEndedReason(null); - }, []), + onIncoming: useCallback( + (payload: VoipIncomingPayload) => { + enqueueCallTransition(async () => { + const connectedRoom = connectedRoomRef.current; + const isSwap = + connectedRoom != null && + connectedRoom !== payload.roomName && + currentCallRef.current != null; + + if (isSwap) { + swapInProgressRef.current = true; + } + + try { + if (isSwap) { + setStatus('incoming'); + await handleLeaveRoom(); + isCallOnHoldRef.current = false; + heldMediaStateRef.current = { + microphoneEnabled: false, + cameraEnabled: false, + }; + setIsOnHold(false); + } + + const call: CurrentCall = { + roomName: payload.roomName, + displayName: payload.displayName, + handle: payload.handle, + isVideo: payload.isVideo, + startedAt: null, + isOutgoing: false, + }; + currentCallRef.current = call; + setCurrentCall(call); + setStatus('incoming'); + setLastEndedReason(null); + + const pendingAnswer = pendingWaitingAnswerRef.current; + if (pendingAnswer) { + pendingWaitingAnswerRef.current = null; + await answerCall(pendingAnswer); + } + } finally { + if (isSwap) { + swapInProgressRef.current = false; + } + } + }); + }, + [answerCall, enqueueCallTransition, handleLeaveRoom], + ), onAnswered: useCallback( - async (requestId: string) => { - await answerCall(requestId); + (requestId: string) => { + enqueueCallTransition(async () => { + if (pendingAnswerRequestIdRef.current) { + return; + } + + const call = currentCallRef.current; + if (!call || call.startedAt != null) { + pendingWaitingAnswerRef.current = requestId; + return; + } + + await answerCall(requestId); + }); }, - [answerCall], + [answerCall, enqueueCallTransition], ), onEnded: useCallback( - async (reason?: CallEndedReason) => { - await endCall(reason ?? 'remote'); + (reason?: CallEndedReason) => { + enqueueCallTransition(async () => { + await endCall(reason ?? 'remote', { fromNative: true }); + }); }, - [endCall], + [endCall, enqueueCallTransition], ), onHeldChanged: useCallback( async (onHold: boolean) => { + if (!currentCallRef.current?.startedAt) { + return; + } + const call = currentCallRef.current; if (!call || isCallOnHoldRef.current === onHold) { return; @@ -324,6 +418,8 @@ export function VoipProvider({ }, [canStartOutgoingCall, startCallFromIntent], ), + + onWaitingCallDeclined, }); useEffect(() => { @@ -375,7 +471,11 @@ export function VoipProvider({ .finally(() => { activationInFlightRef.current = false; }); - } else if (status === 'active' && remotePeers.length === 0) { + } else if ( + status === 'active' && + remotePeers.length === 0 && + !swapInProgressRef.current + ) { endCall('remote').catch((err) => console.error('Failed to end call:', err), ); diff --git a/packages/mobile-client/plugin/src/withFishjamIos.ts b/packages/mobile-client/plugin/src/withFishjamIos.ts index a7bbfaaeb..e7cd0bf71 100644 --- a/packages/mobile-client/plugin/src/withFishjamIos.ts +++ b/packages/mobile-client/plugin/src/withFishjamIos.ts @@ -316,9 +316,9 @@ const withFishjamVoIPBackgroundMode: ConfigPlugin = (confi const withFishjamVoipTimeouts: ConfigPlugin = (config, props) => withInfoPlist(config, (configuration) => { const timeouts = [ - ['FishjamVoipIncomingCallTimeout', 'incomingCallTimeout'], - ['FishjamVoipOutgoingCallTimeout', 'outgoingCallTimeout'], - ['FishjamVoipFulfillAnswerTimeout', 'fulfillAnswerCallTimeout'], + ['VoipIncomingCallTimeout', 'incomingCallTimeout'], + ['VoipOutgoingCallTimeout', 'outgoingCallTimeout'], + ['VoipFulfillAnswerTimeout', 'fulfillAnswerCallTimeout'], ] as const; timeouts.forEach(([key, option]) => { diff --git a/packages/mobile-client/plugin/src/withFishjamVoipAndroid.ts b/packages/mobile-client/plugin/src/withFishjamVoipAndroid.ts index b4932cf72..b4395c7e8 100644 --- a/packages/mobile-client/plugin/src/withFishjamVoipAndroid.ts +++ b/packages/mobile-client/plugin/src/withFishjamVoipAndroid.ts @@ -63,9 +63,9 @@ const INSTALLATION_ID_META = { }; const VOIP_TIMEOUTS = [ - ['FishjamVoipIncomingCallTimeout', 'incomingCallTimeout'], - ['FishjamVoipOutgoingCallTimeout', 'outgoingCallTimeout'], - ['FishjamVoipFulfillAnswerTimeout', 'fulfillAnswerCallTimeout'], + ['VoipIncomingCallTimeout', 'incomingCallTimeout'], + ['VoipOutgoingCallTimeout', 'outgoingCallTimeout'], + ['VoipFulfillAnswerTimeout', 'fulfillAnswerCallTimeout'], ] as const; function validateTimeout(name: string, seconds: number): void { diff --git a/packages/react-native-webrtc b/packages/react-native-webrtc index 2c20c9a22..d1a5b28bc 160000 --- a/packages/react-native-webrtc +++ b/packages/react-native-webrtc @@ -1 +1 @@ -Subproject commit 2c20c9a227c5e733f566d57618a0300d571d3148 +Subproject commit d1a5b28bca0b90a45944665f1a7a6a84ce50de21 From 9e50a614b2983667a70ba7801680aae6c303f9c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gadomski?= Date: Wed, 15 Jul 2026 21:54:47 +0200 Subject: [PATCH 38/52] Better docs --- .../voip-call/app/src/voip/VoipProvider.tsx | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx b/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx index a73569c1a..c5df72f7c 100644 --- a/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx +++ b/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx @@ -101,11 +101,18 @@ export function VoipProvider({ const pendingCallIntentRef = useRef(null); /** Fishjam room the client is currently joined to, if any. */ const connectedRoomRef = useRef(null); - /** True while leaving one Fishjam room to join another (call-waiting swap). */ + /** + * True while an accepted waiting call is taking over: we leave the old room and + * join the new one. Guards the "no remote peers" watchdog so the brief peerless + * window during the room swap is not mistaken for the remote hanging up. + */ const swapInProgressRef = useRef(false); /** Serializes native call events so End & Accept cannot interleave teardown and join. */ const callTransitionRef = useRef(Promise.resolve()); - /** Set when `onAnswered` arrives before the waiting-call `onIncoming` payload. */ + /** + * Set when the accept (`onAnswered`) is processed before the promoted waiting + * call's `onIncoming` payload, so `onIncoming` can replay the answer. + */ const pendingWaitingAnswerRef = useRef(null); const enqueueCallTransition = useCallback((op: () => Promise) => { @@ -293,21 +300,28 @@ export function VoipProvider({ setVoipToken(token); }, []), + // Native only delivers `onIncoming` for a *first* call, or for a waiting call + // the moment the user picks "End & Accept" — never while a waiting call merely + // rings (that stays entirely inside CallKit/Telecom). So a payload for a + // different room while we're still in one means an accepted waiting call is + // taking over: native has already ended the old call, and we must swap rooms. onIncoming: useCallback( (payload: VoipIncomingPayload) => { enqueueCallTransition(async () => { const connectedRoom = connectedRoomRef.current; - const isSwap = + const isAcceptedWaitingCall = connectedRoom != null && connectedRoom !== payload.roomName && currentCallRef.current != null; - if (isSwap) { + if (isAcceptedWaitingCall) { swapInProgressRef.current = true; } try { - if (isSwap) { + if (isAcceptedWaitingCall) { + // The old call's `onEnded` may not have been processed yet, so tear + // its room down here to guarantee we don't stay joined to two rooms. setStatus('incoming'); await handleLeaveRoom(); isCallOnHoldRef.current = false; @@ -337,7 +351,7 @@ export function VoipProvider({ await answerCall(pendingAnswer); } } finally { - if (isSwap) { + if (isAcceptedWaitingCall) { swapInProgressRef.current = false; } } @@ -353,6 +367,9 @@ export function VoipProvider({ return; } + // No ringing call to answer yet: either the accepted waiting call's + // `onIncoming` hasn't arrived, or `call` is still the old (already + // `active`) call that native is ending. Stash so `onIncoming` replays it. const call = currentCallRef.current; if (!call || call.startedAt != null) { pendingWaitingAnswerRef.current = requestId; From afe0c88b3a3f24aca1dcfa8771de6ce6414267f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gadomski?= Date: Thu, 16 Jul 2026 14:01:58 +0200 Subject: [PATCH 39/52] Add fish avatars and notification/call icons --- examples/mobile-client/voip-call/README.md | 6 ++ examples/mobile-client/voip-call/app/App.tsx | 2 +- .../voip-call/app/src/components/Avatar.tsx | 21 ++++- .../app/src/components/VideoCallView.tsx | 13 ++- .../app/src/screens/InCallScreen.tsx | 22 ++++- .../app/src/screens/OutgoingCallScreen.tsx | 8 +- .../voip-call/app/src/screens/UsersScreen.tsx | 8 +- .../voip-call/app/src/user/UserContext.ts | 8 +- .../voip-call/app/src/user/UserProvider.tsx | 47 ++++++---- .../voip-call/app/src/user/index.ts | 2 +- .../mobile-client/voip-call/server/README.md | 15 +++- .../voip-call/server/avatars/coral.png | Bin 0 -> 10686 bytes .../voip-call/server/avatars/mint.png | Bin 0 -> 10943 bytes .../voip-call/server/avatars/ocean.png | Bin 0 -> 10811 bytes .../voip-call/server/avatars/orchid.png | Bin 0 -> 10888 bytes .../voip-call/server/avatars/sunny.png | Bin 0 -> 10109 bytes .../mobile-client/voip-call/server/main.ts | 81 ++++++++++++++++-- packages/mobile-client/plugin/src/types.ts | 1 + .../plugin/src/withFishjamVoipAndroid.ts | 20 +++++ packages/react-native-webrtc | 2 +- 20 files changed, 214 insertions(+), 42 deletions(-) create mode 100644 examples/mobile-client/voip-call/server/avatars/coral.png create mode 100644 examples/mobile-client/voip-call/server/avatars/mint.png create mode 100644 examples/mobile-client/voip-call/server/avatars/ocean.png create mode 100644 examples/mobile-client/voip-call/server/avatars/orchid.png create mode 100644 examples/mobile-client/voip-call/server/avatars/sunny.png diff --git a/examples/mobile-client/voip-call/README.md b/examples/mobile-client/voip-call/README.md index 28233a620..5e5c2877f 100644 --- a/examples/mobile-client/voip-call/README.md +++ b/examples/mobile-client/voip-call/README.md @@ -530,6 +530,12 @@ Second, the manifest entries. Add to `android/app/src/main/AndroidManifest.xml`: + + + ``` diff --git a/examples/mobile-client/voip-call/app/App.tsx b/examples/mobile-client/voip-call/app/App.tsx index 8ab9d0aa0..94b1f51f0 100644 --- a/examples/mobile-client/voip-call/app/App.tsx +++ b/examples/mobile-client/voip-call/app/App.tsx @@ -15,8 +15,8 @@ import { } from 'react-native'; import { SafeAreaProvider } from 'react-native-safe-area-context'; -import type { PropsWithChildren } from 'react'; import type { VoipIncomingPayload } from '@fishjam-cloud/react-native-client'; +import type { PropsWithChildren } from 'react'; import { InCallScreen } from './src/screens/InCallScreen'; import { LoginScreen } from './src/screens/LoginScreen'; import { OutgoingCallScreen } from './src/screens/OutgoingCallScreen'; diff --git a/examples/mobile-client/voip-call/app/src/components/Avatar.tsx b/examples/mobile-client/voip-call/app/src/components/Avatar.tsx index ccdcbadb5..1e6e08692 100644 --- a/examples/mobile-client/voip-call/app/src/components/Avatar.tsx +++ b/examples/mobile-client/voip-call/app/src/components/Avatar.tsx @@ -1,15 +1,22 @@ -import { StyleSheet, Text, View } from 'react-native'; +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, size = 96, speaking = false }: AvatarProps) { +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 + /> + )} ); } @@ -32,6 +48,7 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', borderColor: BrandColors.seaBlue80, + overflow: 'hidden', }, text: { color: TextColors.white, diff --git a/examples/mobile-client/voip-call/app/src/components/VideoCallView.tsx b/examples/mobile-client/voip-call/app/src/components/VideoCallView.tsx index ce06e2005..bb48d77d3 100644 --- a/examples/mobile-client/voip-call/app/src/components/VideoCallView.tsx +++ b/examples/mobile-client/voip-call/app/src/components/VideoCallView.tsx @@ -16,10 +16,17 @@ function streamOf(track: Track | null | undefined) { type VideoCallViewProps = { remoteName: string; + remoteAvatarUrl?: string | null; localName: string; + localAvatarUrl?: string | null; }; -export function VideoCallView({ remoteName, localName }: VideoCallViewProps) { +export function VideoCallView({ + remoteName, + remoteAvatarUrl, + localName, + localAvatarUrl, +}: VideoCallViewProps) { const insets = useSafeAreaInsets(); const { remotePeers } = usePeers(); const { cameraStream } = useCamera(); @@ -39,7 +46,7 @@ export function VideoCallView({ remoteName, localName }: VideoCallViewProps) { /> ) : ( - + )} @@ -54,7 +61,7 @@ export function VideoCallView({ remoteName, localName }: VideoCallViewProps) { /> ) : ( - + )} diff --git a/examples/mobile-client/voip-call/app/src/screens/InCallScreen.tsx b/examples/mobile-client/voip-call/app/src/screens/InCallScreen.tsx index b47a2d840..7d87c229f 100644 --- a/examples/mobile-client/voip-call/app/src/screens/InCallScreen.tsx +++ b/examples/mobile-client/voip-call/app/src/screens/InCallScreen.tsx @@ -12,6 +12,7 @@ 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'; import { useVoip } from '../voip'; type PeerMeta = { displayName?: string }; @@ -42,6 +43,7 @@ function useElapsed(startedAt: number | null): number { export function InCallScreen() { const { currentCall, endCall, isOnHold, setCallHeld } = useVoip(); + const { username, avatarUrlFor } = useUser(); const { isMicrophoneOn, toggleMicrophone } = useMicrophone(); const { isCameraOn, toggleCamera } = useCamera(); @@ -126,7 +128,12 @@ export function InCallScreen() { {/* Dark video background needs light status bar icons, unlike every other (light) screen. */} - + On call · {formatDuration(elapsed)} {remotePeers.length === 0 ? ( - + {displayName} ) : ( @@ -157,7 +168,12 @@ export function InCallScreen() { const isTalking = speaking[peer.id] ?? false; return ( - + {name} ); diff --git a/examples/mobile-client/voip-call/app/src/screens/OutgoingCallScreen.tsx b/examples/mobile-client/voip-call/app/src/screens/OutgoingCallScreen.tsx index 1268a6a6b..a770c8ab9 100644 --- a/examples/mobile-client/voip-call/app/src/screens/OutgoingCallScreen.tsx +++ b/examples/mobile-client/voip-call/app/src/screens/OutgoingCallScreen.tsx @@ -11,10 +11,12 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { Avatar, InCallButton } from '../components'; import { BrandColors, TextColors } from '../theme/colors'; +import { useUser } from '../user'; import { useVoip } from '../voip'; export function OutgoingCallScreen() { const { currentCall, endCall } = useVoip(); + const { avatarUrlFor } = useUser(); const scale = useSharedValue(1); useEffect(() => { @@ -42,7 +44,11 @@ export function OutgoingCallScreen() { Calling - + {displayName} diff --git a/examples/mobile-client/voip-call/app/src/screens/UsersScreen.tsx b/examples/mobile-client/voip-call/app/src/screens/UsersScreen.tsx index c24a25966..106f55a48 100644 --- a/examples/mobile-client/voip-call/app/src/screens/UsersScreen.tsx +++ b/examples/mobile-client/voip-call/app/src/screens/UsersScreen.tsx @@ -62,7 +62,7 @@ export function UsersScreen() { item} + keyExtractor={(item) => item.username} contentContainerStyle={styles.list} refreshControl={ ( handleCall(item)} + onPress={() => handleCall(item.username)} disabled={isCalling} activeOpacity={0.7}> - - {item} + + {item.username} {isCalling ? ( ) : ( diff --git a/examples/mobile-client/voip-call/app/src/user/UserContext.ts b/examples/mobile-client/voip-call/app/src/user/UserContext.ts index ed5f06a38..513dfcf73 100644 --- a/examples/mobile-client/voip-call/app/src/user/UserContext.ts +++ b/examples/mobile-client/voip-call/app/src/user/UserContext.ts @@ -1,12 +1,18 @@ import { createContext, useContext } from 'react'; +export type UserSummary = { + username: string; + avatarUrl: string | null; +}; + export type UserContextValue = { username: string | null; - users: string[]; + users: UserSummary[]; isLoading: boolean; register: (name: string) => Promise; refreshUsers: () => Promise; logout: () => Promise; + avatarUrlFor: (name: string) => string | null; }; export const UserContext = createContext(null); diff --git a/examples/mobile-client/voip-call/app/src/user/UserProvider.tsx b/examples/mobile-client/voip-call/app/src/user/UserProvider.tsx index fa679323d..c2e834d4d 100644 --- a/examples/mobile-client/voip-call/app/src/user/UserProvider.tsx +++ b/examples/mobile-client/voip-call/app/src/user/UserProvider.tsx @@ -1,5 +1,5 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; -import React, { +import { type PropsWithChildren, useCallback, useEffect, @@ -7,7 +7,7 @@ import React, { useState, } from 'react'; -import { UserContext } from './UserContext'; +import { UserContext, type UserSummary } from './UserContext'; const SERVER_URL = process.env.EXPO_PUBLIC_VOIP_SERVER_URL ?? 'http://localhost:4400'; @@ -15,19 +15,17 @@ const USERNAME_STORAGE_KEY = 'voip.username'; export function UserProvider({ children }: PropsWithChildren) { const [username, setUsername] = useState(null); - const [users, setUsers] = useState([]); + 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 (exclude: string) => { + const fetchUsers = useCallback(async () => { try { - const res = await fetch( - `${SERVER_URL}/users?exclude=${encodeURIComponent(exclude)}`, - ); + const res = await fetch(`${SERVER_URL}/users?exclude=`); if (!res.ok) return; - const list: string[] = await res.json(); - setUsers(list); + const list: UserSummary[] = await res.json(); + setAllUsers(list); } catch { // network error — ignore } @@ -37,18 +35,18 @@ export function UserProvider({ children }: PropsWithChildren) { async (name: string) => { setUsername(name); await AsyncStorage.setItem(USERNAME_STORAGE_KEY, name); - await fetchUsers(name); + await fetchUsers(); }, [fetchUsers], ); const refreshUsers = useCallback(async () => { - if (username) await fetchUsers(username); + if (username) await fetchUsers(); }, [username, fetchUsers]); const logout = useCallback(async () => { setUsername(null); - setUsers([]); + setAllUsers([]); await AsyncStorage.removeItem(USERNAME_STORAGE_KEY); }, []); @@ -59,7 +57,7 @@ export function UserProvider({ children }: PropsWithChildren) { const saved = await AsyncStorage.getItem(USERNAME_STORAGE_KEY); if (saved) { setUsername(saved); - await fetchUsers(saved); + await fetchUsers(); } } finally { setIsLoading(false); @@ -67,9 +65,28 @@ export function UserProvider({ children }: PropsWithChildren) { })(); }, [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 }), - [username, users, isLoading, register, refreshUsers, logout], + () => ({ + 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/src/user/index.ts b/examples/mobile-client/voip-call/app/src/user/index.ts index dec7164e8..d9832c22f 100644 --- a/examples/mobile-client/voip-call/app/src/user/index.ts +++ b/examples/mobile-client/voip-call/app/src/user/index.ts @@ -1,3 +1,3 @@ export { UserProvider } from './UserProvider'; export { useUser } from './UserContext'; -export type { UserContextValue } from './UserContext'; +export type { UserContextValue, UserSummary } from './UserContext'; diff --git a/examples/mobile-client/voip-call/server/README.md b/examples/mobile-client/voip-call/server/README.md index 25b4cd990..3d734d120 100644 --- a/examples/mobile-client/voip-call/server/README.md +++ b/examples/mobile-client/voip-call/server/README.md @@ -54,7 +54,7 @@ Android callee goes out over FCM. | --------- | --------------------- | ----------------------------------- | ---------------------------------------- | | 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 | +| POST | `/call` | `{ from, to, roomName, isVideo, avatarUrl? }` | Send a VoIP push to the callee | | WebSocket | `/ws?username=` | | Bidirectional signaling socket | ## Signaling (WebSocket) @@ -101,10 +101,18 @@ The push payload forwarded to the callee's device: { "roomName": "", "displayName": "", - "isVideo": false + "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 example client sends a +[picsum](https://picsum.photos) image seeded by the caller name, and the server +falls back to the same when the request omits `avatarUrl`. + 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 @@ -119,7 +127,8 @@ FCM values must be strings, so the same fields are sent as a high-priority "data": { "roomName": "", "displayName": "", - "isVideo": "false" + "isVideo": "false", + "avatarUrl": "https://…/caller.jpg" }, "android": { "priority": "high" } } diff --git a/examples/mobile-client/voip-call/server/avatars/coral.png b/examples/mobile-client/voip-call/server/avatars/coral.png new file mode 100644 index 0000000000000000000000000000000000000000..f3fb417e653ff297b7d373b472fe9bf518cdf790 GIT binary patch literal 10686 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 literal 0 HcmV?d00001 diff --git a/examples/mobile-client/voip-call/server/avatars/mint.png b/examples/mobile-client/voip-call/server/avatars/mint.png new file mode 100644 index 0000000000000000000000000000000000000000..3fce0bcca721dbbdc3650b286c10c0db731f6236 GIT binary patch literal 10943 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 h00006Nkl literal 0 HcmV?d00001 diff --git a/examples/mobile-client/voip-call/server/avatars/ocean.png b/examples/mobile-client/voip-call/server/avatars/ocean.png new file mode 100644 index 0000000000000000000000000000000000000000..835b7c5d4d1a2e815a3ace5e85c6aaa2fbbefa84 GIT binary patch literal 10811 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 literal 0 HcmV?d00001 diff --git a/examples/mobile-client/voip-call/server/avatars/sunny.png b/examples/mobile-client/voip-call/server/avatars/sunny.png new file mode 100644 index 0000000000000000000000000000000000000000..be807d25112c4878dabe205a957656813181102e GIT binary patch literal 10109 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 literal 0 HcmV?d00001 diff --git a/examples/mobile-client/voip-call/server/main.ts b/examples/mobile-client/voip-call/server/main.ts index dca47dd0e..eb92db39c 100644 --- a/examples/mobile-client/voip-call/server/main.ts +++ b/examples/mobile-client/voip-call/server/main.ts @@ -13,15 +13,47 @@ db.exec(` 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; }; // --- FCM push (Android) --- @@ -66,6 +98,7 @@ async function sendFcmPush(params: PushParams): Promise { roomName: params.roomName, displayName: params.displayName, isVideo: String(params.isVideo), + ...(params.avatarUrl ? { avatarUrl: params.avatarUrl } : {}), }, android: { priority: "high" }, }, @@ -100,6 +133,7 @@ async function sendApnsPush(params: PushParams): Promise { roomName: params.roomName, displayName: params.displayName, isVideo: params.isVideo, + ...(params.avatarUrl ? { avatarUrl: params.avatarUrl } : {}), }), }); @@ -144,21 +178,29 @@ Deno.serve({ port: 4400 }, async (req) => { 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, updated_at) VALUES (?, ?, ?, ?)`, - [username, voipToken, platform, Date.now()], + `INSERT OR REPLACE INTO users (username, voip_token, platform, avatar, updated_at) VALUES (?, ?, ?, ?, ?)`, + [username, voipToken, platform, avatar, Date.now()], ); - return json({ ok: true }); + return json({ ok: true, avatarUrl: avatarUrl(req, avatar) }); } // 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 + 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) => r.username)); + return json( + rows.map((r) => ({ + username: r.username, + avatarUrl: r.avatar ? avatarUrl(req, r.avatar) : null, + })), + ); } // POST /call { from, to, roomName } @@ -182,12 +224,18 @@ Deno.serve({ port: 4400 }, async (req) => { 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); @@ -227,5 +275,24 @@ Deno.serve({ port: 4400 }, async (req) => { 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/packages/mobile-client/plugin/src/types.ts b/packages/mobile-client/plugin/src/types.ts index b7d258de8..3964b74a4 100644 --- a/packages/mobile-client/plugin/src/types.ts +++ b/packages/mobile-client/plugin/src/types.ts @@ -21,6 +21,7 @@ export type FishjamPluginOptions = outgoingCallTimeout?: number; fulfillAnswerCallTimeout?: number; enableCallIntents?: boolean; + notificationIcon?: string; }; } | undefined; diff --git a/packages/mobile-client/plugin/src/withFishjamVoipAndroid.ts b/packages/mobile-client/plugin/src/withFishjamVoipAndroid.ts index b4395c7e8..49db68587 100644 --- a/packages/mobile-client/plugin/src/withFishjamVoipAndroid.ts +++ b/packages/mobile-client/plugin/src/withFishjamVoipAndroid.ts @@ -68,6 +68,10 @@ const VOIP_TIMEOUTS = [ ['VoipFulfillAnswerTimeout', 'fulfillAnswerCallTimeout'], ] as const; +const NOTIFICATION_ICON_META_NAME = 'VoipNotificationIcon'; +// The CallStyle notification's small icon defaults to the app's launcher icon. +const DEFAULT_NOTIFICATION_ICON = '@mipmap/ic_launcher'; + function validateTimeout(name: string, seconds: number): void { if (!Number.isFinite(seconds) || seconds <= 0) { throw new Error(`Fishjam VoIP ${name} must be a positive finite number of seconds.`); @@ -158,5 +162,21 @@ export const withFishjamVoipAndroid: ConfigPlugin = (confi } }); + // CallStyle notification small icon; defaults to the app's launcher icon. + const notificationIconMetadata = { + $: { + 'android:name': NOTIFICATION_ICON_META_NAME, + 'android:resource': props?.voip?.notificationIcon ?? DEFAULT_NOTIFICATION_ICON, + }, + }; + const existingIconIndex = metadataEntries.findIndex( + (meta) => meta.$['android:name'] === NOTIFICATION_ICON_META_NAME, + ); + if (existingIconIndex !== -1) { + metadataEntries[existingIconIndex] = notificationIconMetadata; + } else { + metadataEntries.push(notificationIconMetadata); + } + return configuration; }); diff --git a/packages/react-native-webrtc b/packages/react-native-webrtc index d1a5b28bc..6fe374721 160000 --- a/packages/react-native-webrtc +++ b/packages/react-native-webrtc @@ -1 +1 @@ -Subproject commit d1a5b28bca0b90a45944665f1a7a6a84ce50de21 +Subproject commit 6fe374721e1850684afe4a72b2328e2ca08ca339 From 48bc74df80d41dc8becc4d12326a5b2b87206b12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gadomski?= Date: Thu, 16 Jul 2026 14:45:16 +0200 Subject: [PATCH 40/52] Surface mute to JS --- .../voip-call/app/src/voip/VoipProvider.tsx | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx b/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx index 7bbf0b9bf..cc0002e8d 100644 --- a/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx +++ b/examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx @@ -222,19 +222,6 @@ export function VoipProvider({ [endNativeCallSession, resetCallState], ); - const endCall = useCallback( - async (reason: CallEndedReason = 'local') => { - if (!currentCallRef.current) return; - const resetPromise = resetCallState(reason); - try { - await endNativeCallSession(reason); - } finally { - await resetPromise; - } - }, - [endNativeCallSession, resetCallState], - ); - const startCall = useCallback( async (to: string, roomName: string) => { const call: CurrentCall = { @@ -438,6 +425,24 @@ export function VoipProvider({ [isCameraOn, isMicrophoneOn, toggleCamera, toggleMicrophone], ), + onMuteChanged: useCallback( + async (muted: boolean) => { + if (!currentCallRef.current?.startedAt) { + return; + } + try { + if (muted && isMicrophoneOn) { + await toggleMicrophone(); + } else if (!muted && !isMicrophoneOn) { + await toggleMicrophone(); + } + } catch (err) { + console.error('Failed to sync mute state:', err); + } + }, + [isMicrophoneOn, toggleMicrophone], + ), + onCallIntent: useCallback( async (intent: VoipCallIntent) => { if (!canStartOutgoingCall) { From 3401f54bc94d9f7493a8fdbbd73ec197d544006e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gadomski?= Date: Thu, 16 Jul 2026 15:00:48 +0200 Subject: [PATCH 41/52] Add plans --- .../voip-call/ANSWER-HANDSHAKE-PLAN.md | 542 ++++++++++++++++++ .../voip-call/CALL-TIMEOUTS-PLAN.md | 525 +++++++++++++++++ .../voip-call/CALL-TIMEOUTS-RESEARCH.md | 280 +++++++++ .../voip-call/FCM-COEXISTENCE-PLAN.md | 287 ++++++---- .../mobile-client/voip-call/FEATURE-IDEAS.md | 32 +- .../voip-call/HOLD-MULTICALL-RECENTS-PLAN.md | 482 ++++++++++++++++ .../IOS-VOIP-REGISTRATION-PLUGIN-PLAN.md | 147 +++++ .../voip-call/KILLED-APP-DECLINE-PLAN.md | 258 +++++++++ .../voip-call/OUTGOING-CONNECT-PLAN.md | 447 +++++++++++++++ 9 files changed, 2881 insertions(+), 119 deletions(-) create mode 100644 examples/mobile-client/voip-call/ANSWER-HANDSHAKE-PLAN.md create mode 100644 examples/mobile-client/voip-call/CALL-TIMEOUTS-PLAN.md create mode 100644 examples/mobile-client/voip-call/CALL-TIMEOUTS-RESEARCH.md create mode 100644 examples/mobile-client/voip-call/HOLD-MULTICALL-RECENTS-PLAN.md create mode 100644 examples/mobile-client/voip-call/IOS-VOIP-REGISTRATION-PLUGIN-PLAN.md create mode 100644 examples/mobile-client/voip-call/KILLED-APP-DECLINE-PLAN.md create mode 100644 examples/mobile-client/voip-call/OUTGOING-CONNECT-PLAN.md diff --git a/examples/mobile-client/voip-call/ANSWER-HANDSHAKE-PLAN.md b/examples/mobile-client/voip-call/ANSWER-HANDSHAKE-PLAN.md new file mode 100644 index 000000000..5905205cb --- /dev/null +++ b/examples/mobile-client/voip-call/ANSWER-HANDSHAKE-PLAN.md @@ -0,0 +1,542 @@ +# Plan: proper answer/connect handshake (fulfill/fail instead of instant-fulfill) + +> Companion to [FEATURE-IDEAS.md](./FEATURE-IDEAS.md) item #1 (P0 🔴). That item states the +> gap in two sentences; this file is the full implementation plan, ported from how +> `~/Desktop/expo-callkit-telecom` does it (verified against their source on 2026-07-10). + +## Problem + +When the user answers an incoming call, our iOS delegate fulfills the `CXAnswerCallAction` +**immediately**: + +```objc +// packages/react-native-webrtc/ios/RCTWebRTC/CallKitManager.m:160-166 +- (void)provider:(CXProvider *)provider performAnswerCallAction:(CXAnswerCallAction *)action { + self.isCallAnswered = YES; + if (self.onCallAnswered) { self.onCallAnswered(); } + [action fulfill]; // ← "connected" before a single RTP packet exists +} +``` + +Two consequences: + +1. **The system UI lies.** `action.fulfill()` is what tells CallKit the call is up, so the + dynamic island / lock screen starts its call timer while we are still fetching a peer + token and joining the Fishjam room. Call duration in the UI is wrong by however long the + join takes. +2. **A failed join is unreportable.** If `getPeerToken()` 401s or `joinRoom()` throws, the + `CXAnswerCallAction` is long gone. There is no way to tell CallKit "that answer didn't + work" — the call sits in the system UI as connected and silent. Today + `VoipProvider.answerCall()` catches the error and calls `endCall()` + (`examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx:144-155`), which reads to + the user as *"the call connected and then instantly dropped"* rather than *"the call + failed"*. + +Android has the mirror problem in a different shape: `handleAnswered()` posts the ongoing +(chronometer) notification the moment the user answers +(`packages/react-native-webrtc/android/src/main/java/com/oney/WebRTCModule/voip/CallManager.kt:253-259`), +and nothing ever calls `setActive()` unless JS happens to call `setTelecomCallActive()` +— which the example only does from a `remotePeers.length > 0` effect, with no timeout and no +failure path. + +Neither platform has a timeout: if JS never finishes connecting, the call hangs forever. + +## How expo-callkit-telecom does it + +The answer action is **parked**, not fulfilled. A `FulfillRequestManager` keyed by a +generated `requestId` holds it; JS is handed the `requestId` in the answer event and later +resolves it. + +### iOS (`ios/Managers/FulfillRequestManager.swift`, `CallManager+CXProviderDelegate.swift:58-95`) + +`FulfillRequestManager` is a Swift `actor` mapping `requestId → PendingRequest { callId, +continuation, timeoutTask }`. `createRequest(callId:timeout:)` returns +`(requestId, Task)` where `Result` is `.fulfilled(callId:) | .cancelled | .timedOut`. +The delegate literally awaits it: + +```swift +func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) { + cancelCallTimeout(for: action.callUUID) + Task { + await store.updateStatus(for: action.callUUID, status: .connecting) + let (requestId, resultTask) = await FulfillRequestManager.shared.createRequest( + callId: action.callUUID, timeout: Self.answerCallTimeout) + await MainActor.run { + CallEventEmitter.shared.send(CallAnsweredEvent(id: action.callUUID, requestId: requestId)) + } + switch await resultTask.value { + case .fulfilled: action.fulfill() + case .cancelled: action.fail() + case .timedOut: action.fail() + } + } +} +``` + +- `fulfill(requestId:)` cancels the timeout task and resumes `.fulfilled`; returns `nil` if + the request already timed out (so a late JS call is a safe no-op). +- `cancel(requestId:)` resumes `.cancelled` → `action.fail()`. +- `cancelAll()` runs from `providerDidReset` alongside `DialtonePlayer.stop()`, + `cancelAllCallTimeouts()` and `store.removeAll()`. +- Timeout is read from the Info.plist key `ExpoCallKitTelecomFulfillAnswerCallTimeout` + (seconds, default 30), written by their config plugin. + +### Android (`managers/FulfillRequestManager.kt`, `managers/CallManager.kt:812-848`) + +Same shape, expressed with coroutines: a `mutableMapOf` (requestId → callId) +plus `timeoutJobs`, both guarded by `synchronized(lock)`. `createRequest` takes an +`onTimeout: (UUID) -> Unit` callback rather than returning a result — `CallManager` passes +`{ reportCallEnded(it, CallEndedReason.FAILED) }`. + +`onCallAnswered(id)` — the shared path for both the system answer callback and the in-app +`answerCall(id)` — does: early-return if already `CONNECTED`, cancel the incoming-call +timeout, set status `CONNECTING`, activate audio, request the speaker endpoint for video +calls, create the fulfill request, then emit `CALL_ANSWERED` with `{ id, requestId }`. + +`fulfillIncomingCallConnected(requestId)` is where the call actually goes live: + +```kotlin +fun fulfillIncomingCallConnected(requestId: UUID): Boolean { + val callId = FulfillRequestManager.fulfill(requestId) ?: return false + val now = Instant.now() + CallStore.update(callId) { it.copy(status = CONNECTED, connectedAt = now) } + activeCalls[callId]?.actions?.setActive?.trySend(Unit) // ← core-telecom goes active here + CallNotificationManager.showOngoingCall(context, callId, callerName, now.toEpochMilli()) + return true +} +``` + +There is **no** `failIncomingCallConnected` native function on Android. Their JS shim +(`src/Calls.ts:552-561`) branches instead: + +```ts +export async function failIncomingCallConnected(id: string, requestId: string) { + if (Platform.OS === "ios") { + await ExpoCallKitTelecomModule.failIncomingCallConnected(requestId); + } else { + await ExpoCallKitTelecomModule.reportCallEnded(id, "failed"); + } +} +``` + +…because on Android "failing" just means disconnecting; `reportCallEnded` internally calls +`FulfillRequestManager.cancelForCall(id)` to reap the orphaned request. Their `endCall` path +does the same. The Android timeout is read from an application `meta-data` int via +`PackageManager.GET_META_DATA` (`readTimeoutMs`, `CallManager.kt:149-161`). + +**The asymmetry is inherent, not an accident:** iOS has a first-class `CXAction` object with +`fulfill()`/`fail()` semantics that CallKit itself acts on; Android core-telecom has no +"answer failed" concept, only `setActive()` vs `disconnect()`. + +## Design for our repo + +Our VoIP layer is **single-call** (`currentCallUUID` is private to `CallKitManager`; the +Android `CallManager` is an `object` with one `hasActiveCall` flag; JS events are payload-keyed +objects with no call id). So we do **not** need to port `CallSession`/`CallStore` (that is +item #14) — the `requestId` is the only correlation handle JS needs, and it doubles as a +generation counter that makes stale fulfills safe. + +Three states become distinguishable where today there are two: + +| | today | after | +| --- | --- | --- | +| user tapped answer | `isCallAnswered = true`, CallKit says *connected* | `isCallAnswered = true`, CallKit says *connecting* | +| media connected | (same state) | `action.fulfill()` → CallKit says *connected*, timer starts | +| media failed | `endCall()` → looks like a drop | `action.fail()` → CallKit ends it as a failure | + +### JS API (new) + +Added to the unified layer in `packages/react-native-webrtc/src/VoIP.ts` (which already hosts +the cross-platform push glue), re-exported from `src/index.ts` and +`packages/mobile-client/src/index.ts`: + +```ts +/** Resolves the parked answer action; the OS now shows the call as connected. + * Returns false if the request already timed out (the call is gone — bail out). */ +export function fulfillIncomingCallConnected(requestId: string): Promise; + +/** Aborts the parked answer action. iOS fails the CXAnswerCallAction (CallKit tears the + * call down); Android disconnects. Safe to call after a timeout — no-ops. */ +export function failIncomingCallConnected(requestId: string): Promise; + +/** The requestId of an answer that is still awaiting fulfillment, or null. + * Needed on cold start: the user can answer before the JS bundle is up. */ +export function getPendingAnswerRequestId(): string | null; +``` + +`useVoIPEvents`' handler signature gains the id — additive for existing call sites, which +simply ignore the argument: + +```ts +export type VoIPEventHandlers = { + onIncoming?: (payload: VoipIncomingPayload) => void; + onAnswered?: (requestId: string) => void; // was: () => void + onEnded?: () => void; + onRegistered?: (token: string) => void; +}; +``` + +Returning `boolean` from `fulfillIncomingCallConnected` (rather than `void` like theirs) is a +deliberate improvement: both native sides already compute "did this request still exist", and +without it JS cannot distinguish *"we connected"* from *"we connected 2 s after the OS gave +up and killed the call"*. + +### iOS implementation + +**New `ios/RCTWebRTC/FulfillRequestManager.h/.m`** — the ObjC equivalent of their actor. No +Swift concurrency available, so: a serial `dispatch_queue_t` guards the dictionary, and the +timeout is a `dispatch_after` on that same queue whose handler checks whether the request is +still present (exactly the reference's `removeRequest`-returns-nil trick — a `dispatch_after` +cannot be cancelled, so the dictionary *is* the cancellation token). + +```objc +typedef NS_ENUM(NSInteger, FulfillResult) { + FulfillResultFulfilled, + FulfillResultCancelled, + FulfillResultTimedOut, +}; + +@interface FulfillRequestManager : NSObject ++ (instancetype)shared; +/// completion runs exactly once, on the main queue. +- (NSString *)createRequestWithTimeout:(NSTimeInterval)timeout + completion:(void (^)(FulfillResult result))completion; +- (BOOL)fulfill:(NSString *)requestId; // NO if unknown/timed out +- (BOOL)cancel:(NSString *)requestId; +- (void)cancelAll; +@end +``` + +`requestId` is `NSUUID.UUID.UUIDString`. The completion hops to the main queue because +`CXProvider` was constructed with `[_provider setDelegate:self queue:nil]` +(`CallKitManager.m:36`) — delegate callbacks and action fulfillment belong on the main queue. + +**`CallKitManager.h`** — `onCallAnswered` changes from `CallKitVoidCallback` to +`CallKitStringCallback` (carries the `requestId`); add +`@property(nonatomic, readonly, nullable) NSString *pendingAnswerRequestId;` and + +```objc +- (BOOL)fulfillIncomingCallConnected:(NSString *)requestId; +- (void)failIncomingCallConnected:(NSString *)requestId; +``` + +**`CallKitManager.m`** — the delegate parks the action: + +```objc +- (void)provider:(CXProvider *)provider performAnswerCallAction:(CXAnswerCallAction *)action { + self.isCallAnswered = YES; // semantics unchanged: "the user accepted", not "media is up" + + __weak typeof(self) weakSelf = self; + NSString *requestId = [[FulfillRequestManager shared] + createRequestWithTimeout:kFulfillAnswerTimeout + completion:^(FulfillResult result) { + weakSelf.pendingAnswerRequestId = nil; + if (result == FulfillResultFulfilled) { + [action fulfill]; + } else { + [action fail]; + [weakSelf reportAnswerFailureForCall:action.callUUID]; + } + }]; + + self.pendingAnswerRequestId = requestId; + if (self.onCallAnswered) { self.onCallAnswered(requestId); } +} +``` + +- The fulfill timeout is a fixed **10 seconds** on both platforms. It is intentionally not + configurable so every integration has the same answer-to-media deadline. +- `reportAnswerFailureForCall:` is defensive cleanup: `[self.provider reportCallWithUUID:uuid + endedAtDate:[NSDate date] reason:CXCallEndedReasonFailed]` then `[self cleanup]`, guarded by + `[uuid isEqual:self.currentCallUUID]` so it is a no-op when the call already ended. The + reference asserts that `action.fail()` alone makes CallKit issue a `CXEndCallAction`; that + is not stated anywhere in Apple's docs, so we belt-and-brace it. **Verify on device**: if + `performEndCallAction:` does fire, the guard makes the second report harmless; if it does + not, this line is what prevents a stuck call. +- `fulfillIncomingCallConnected:` → `[[FulfillRequestManager shared] fulfill:requestId]`. +- `failIncomingCallConnected:` → `[[FulfillRequestManager shared] cancel:requestId]`. +- `cleanup`, `providerDidReset:` and `performEndCallAction:` call `[[FulfillRequestManager + shared] cancelAll]` — matching their `providerDidReset`. **Re-entrancy hazard:** `cancelAll` + synchronously drives the completion → `action.fail()` → `reportAnswerFailureForCall:` → + `cleanup` → `cancelAll`. The `currentCallUUID` guard above breaks the cycle; null + `currentCallUUID` *before* calling `cancelAll` in `cleanup`. + +**New: `provider:timedOutPerformingAction:`.** CallKit gives every `CXAction` a +[`timeoutDate`](https://developer.apple.com/documentation/callkit/cxaction/timeoutdate) and +calls +[`provider(_:timedOutPerforming:)`](https://developer.apple.com/documentation/callkit/cxproviderdelegate/provider(_:timedoutperforming:)) +when it elapses. Apple does not document the duration, and the reference implementation does +not implement this delegate method at all — a latent bug in *their* code that we should not +copy, because the system's own action timeout is undocumented. Implement +it: if the action is a `CXAnswerCallAction`, cancel the pending request (which fails the +action) and report the call ended. + +**`WebRTCModule+CallKit.m`** — bridge surface: + +```objc +RCT_EXPORT_METHOD(fulfillIncomingCallConnected:(NSString *)requestId + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) // resolves @(BOOL) +RCT_EXPORT_METHOD(failIncomingCallConnected:(NSString *)requestId ...) +RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(getPendingAnswerRequestId) // NSString or nil +``` + +and the `onCallAnswered` block becomes +`^(NSString *requestId) { [weakSelf sendEventWithName:kEventCallKitActionPerformed body:@{@"answer": requestId}]; }`. +`CallKitAction.answer` therefore changes type from `undefined` to `string` — `useCallKitEvent` +already forwards `payload[action]` untouched (`src/useCallKit.ts:74-76`), so no change there. + +### Android implementation + +**New `voip/FulfillRequestManager.kt`** — port theirs nearly verbatim (`synchronized(lock)` +map + `timeoutJobs`, `createRequest(timeoutMs, onTimeout)`, `fulfill`, `cancel`, `cancelAll`). +Ours is single-call, so key it by `requestId: String` alone and drop `cancelForCall`. + +**`voip/CallManager.kt`** + +- Add `@Volatile private var pendingAnswerRequestId: String? = null` and + `fun pendingAnswerRequestId(): String?` for the bridge. +- `handleAnswered()` (line 253) stops posting the ongoing notification. Instead: + +```kotlin +private fun handleAnswered() { + answered = true + callNotificationManager.stopVibration() + appContext?.let { LockScreenController.onCallAnswered(it) } + showConnectingNotification() // ← see FGS note below + val requestId = FulfillRequestManager.createRequest(FULFILL_ANSWER_TIMEOUT_MS) { + pendingAnswerRequestId = null + listener?.onFailed("answer fulfill timed out") + endCall() // Disconnect(DisconnectCause.LOCAL) + } + pendingAnswerRequestId = requestId + listener?.onAnswered(requestId) +} +``` + +- New `fun fulfillAnswered(requestId: String): Boolean` — the Android analogue of + `action.fulfill()`: + +```kotlin +fun fulfillAnswered(requestId: String): Boolean { + if (!FulfillRequestManager.fulfill(requestId)) return false + pendingAnswerRequestId = null + setCallActive() // actions.trySend(CallAction.Activate) + showOngoingNotification() // chronometer starts here + return true +} +``` + +- New `fun failAnswered(requestId: String)` → `FulfillRequestManager.cancel(requestId)` then + `endCall()` with `DisconnectCause(DisconnectCause.ERROR)`. +- The `finally` block of `register()` (line 200) gains `FulfillRequestManager.cancelAll()` and + `pendingAnswerRequestId = null`, next to the existing `VoipPushRegistry.clearPending()`. +- `FULFILL_ANSWER_TIMEOUT_MS` is fixed at `10_000L`, matching iOS. + +> ⚠️ **Foreground-service start window — do not naively move `showOngoingNotification()` to +> fulfill.** Ours is not just a notification: `showOngoingNotification()` calls +> `ForegroundServiceController.onCallStarted(...)` +> (`CallManager.kt:261-263`), which starts an FGS with `microphone`/`mediaProjection` types. +> Android only permits an FGS start from the background inside a short window after the +> user-visible event that authorised it; deferring the start by up to 10 s risks +> [`ForegroundServiceStartNotAllowedException`](https://developer.android.com/develop/background-work/services/fgs/restrictions-bg-start). +> Hence `showConnectingNotification()` **at answer time** (starts the FGS immediately, content +> text "Connecting…", no chronometer) and an `notify()`-level update to the chronometer +> notification on fulfill. This is item #12's "dialing/ended states" arriving early, and it is +> a place where a verbatim port of the reference would be wrong for our repo. + +The user-facing side already cooperates: `IncomingCallActivity` shows `buildConnectingUi(...)` +after the swipe (`IncomingCallActivity.kt:111`) and finishes when the host activity covers it, +so the "connecting" phase has a UI on Android without new work. + +**`TelecomController.java`** — `CallEventsListener.onAnswered()` becomes +`onAnswered(String requestId)`; the emitted body gains `body.putString("requestId", requestId)`. +`onFailed(reason)` already exists and is reused for the timeout. + +**`WebRTCModule.java`** (near the existing telecom block at lines 1707-1733) — add +`fulfillTelecomCallAnswered(String requestId, Promise)` (resolves the `boolean`), +`failTelecomCallAnswered(String requestId, Promise)`, and a blocking-synchronous +`getPendingAnswerRequestId()`. + +**Note on `answered`:** `onSetActive = { answered = true }` (line 168) and `handleAnswered()` +both set it, so `answered` means *"the user accepted"*. Keep that meaning — `useVoIPEvents`' +cold-start replay depends on it. "Connected" is now expressed by the fulfill having landed, +not by a flag. + +### Cold start — the case the reference under-serves + +The user can answer from the lock screen while the JS bundle is still loading. The fulfill +request is created in native at that instant and its clock is already running; the +`onAnswered` event has nobody to reach. expo-callkit-telecom solves this with per-event +queues flushed on `startObserving` (item #15); we already have a narrower version of that +mechanism, and it needs one addition. + +`useVoIPEvents`' cold-start branch (`src/useVoIPEvents.ts:81-98`, and the Android twin at +150-167) currently does: + +```ts +const pendingCall = getPendingIncomingCall(); +if (pendingCall && hasActiveCallKitSession()) { + assertRoomName(pendingCall); + handlersRef.current.onIncoming?.(pendingCall as VoipIncomingPayload); + if (isCallAnswered()) { handlersRef.current.onAnswered?.(); } // ← no requestId to give +} +``` + +Change the last line to pull the parked id from native: + +```ts +const requestId = getPendingAnswerRequestId(); +if (requestId) { handlersRef.current.onAnswered?.(requestId); } +``` + +`isCallAnswered()` / `isTelecomCallAnswered()` stay as they are (still the honest answer to +"did the user accept?"), but the *replay* is now driven by the presence of an unfulfilled +request, which is strictly more precise: it cannot re-fire for a call that already connected. + +Consequences to document: + +- The fixed 10 s deadline has to cover **bundle startup + token fetch + room join** on a cold + start from a killed app, not just the join. +- On iOS the clock starts inside `performAnswerCallAction:`, which the OS may deliver before + React Native's `startObserving` runs (`WebRTCModule+CallKit.m:46-50` is where the manager is + first constructed). + +### Example app (`examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx`) + +The provider already has the exact shape this handshake wants: it treats +`remotePeers.length > 0` as "media is live" and, on Android only, calls `setTelecomCallActive()` +there (lines 184-201). That effect becomes the fulfill point, and it stops being +platform-conditional — `fulfillIncomingCallConnected` *is* `setActive()` on Android and +`action.fulfill()` on iOS. + +```tsx +const pendingRequestIdRef = useRef(null); + +onAnswered: useCallback(async (requestId: string) => { + pendingRequestIdRef.current = requestId; + setStatus('connecting'); + try { + await handleJoinRoom(call.roomName); + } catch (err) { + console.error('Failed to join room on answer:', err); + await failIncomingCallConnected(requestId); // ← was: endCall() + pendingRequestIdRef.current = null; + resetCallState(); // endCall() minus the native teardown + } +}, [handleJoinRoom]), +``` + +and in the `remotePeers` effect: + +```tsx +if (status === 'connecting' && remotePeers.length > 0) { + const requestId = pendingRequestIdRef.current; + if (requestId) { + pendingRequestIdRef.current = null; + const connected = await fulfillIncomingCallConnected(requestId); + if (!connected) { await endCall(); return; } // native already timed out and killed it + } + setStatus('active'); + // ...startedAt bookkeeping unchanged +} +``` + +Two things to get right here: + +- **The fail path must not call `endNativeCallSession()`.** `failIncomingCallConnected` already + ends the native call (iOS via `action.fail()`, Android via `disconnect`). Calling `endCall()` + on top of it double-reports. Split the current `endCall()` into `resetCallState()` (JS state + + `handleLeaveRoom()`) and `endCall()` (`resetCallState()` + `endNativeCallSession()`). +- **Outgoing calls have no `requestId`** — `pendingRequestIdRef` is null and the effect skips + the fulfill, preserving today's behaviour. Making the *outgoing* leg honest is + FEATURE-IDEAS #2 (`reportOutgoingCallConnected`), which slots into the same effect and shares + this timeout infrastructure. Land #1 first; #2 is a two-line follow-up on this foundation. + +## Edge cases the implementation must handle + +| Case | Expected behaviour | +| --- | --- | +| JS fulfills after the native timeout fired | `fulfill` returns `false`; call is already ended; JS bails out via the `!connected` branch | +| JS fulfills twice | second call returns `false` — the map lookup already removed the entry | +| Remote hangs up while we're connecting | `CXEndCallAction` / `onDisconnect` → `cancelAll()` → the parked action fails; the `currentCallUUID` guard suppresses the duplicate end report | +| User answers, then kills the app | `providerDidReset` (iOS) / `finally` block (Android) → `cancelAll()` | +| `fail` called after the request timed out | `cancel` no-ops (returns `false`); the call is already gone | +| Answer arrives before JS is ready | request parked; replayed via `getPendingAnswerRequestId()` on `startObserving` | +| Malformed push → no `roomName` | today `assertRoomName` throws in the cold-start `try` and the answer is swallowed. With this change the parked request now times out and cleanly fails the call instead of hanging (partial credit toward FEATURE-IDEAS #19) | +| System `CXAction` timeout fires first | `provider:timedOutPerformingAction:` cancels the request and ends the call | + +## Implementation checklist + +- [ ] `ios/RCTWebRTC/FulfillRequestManager.h/.m` — serial-queue map, `dispatch_after` timeout, + `createRequestWithTimeout:completion:` / `fulfill:` / `cancel:` / `cancelAll` +- [ ] `ios/RCTWebRTC/CallKitManager.h/.m` — park the action in `performAnswerCallAction:`; + `onCallAnswered` → `CallKitStringCallback`; `fulfillIncomingCallConnected:` / + `failIncomingCallConnected:` / `pendingAnswerRequestId`; `reportAnswerFailureForCall:` + with the `currentCallUUID` guard; `cancelAll` in `cleanup` / `providerDidReset:` / + `performEndCallAction:`; **new** `provider:timedOutPerformingAction:` +- [ ] `ios/RCTWebRTC/WebRTCModule+CallKit.m` — 3 bridge methods; `answer` event body carries the + `requestId` +- [ ] Fixed 10-second timeout on iOS and Android +- [ ] `android/.../voip/FulfillRequestManager.kt` — new, ported from theirs, single-call keyed +- [ ] `android/.../voip/CallManager.kt` — `handleAnswered()` creates the request + + `showConnectingNotification()`; `fulfillAnswered()` does `setActive()` + + `showOngoingNotification()`; `failAnswered()`; `cancelAll()` in the `finally` block +- [ ] `android/.../voip/CallNotificationManager.kt` — "Connecting…" variant + in-place update to + the chronometer notification (keeps the FGS start inside its allowed window) +- [ ] `android/.../TelecomController.java` — `onAnswered(String requestId)`; `requestId` in the + `telecomActionPerformed` body +- [ ] `android/.../WebRTCModule.java` — `fulfillTelecomCallAnswered` / `failTelecomCallAnswered` + / `getPendingAnswerRequestId` +- [ ] `src/CallKit.ts` — `CallKitAction.answer: string`; 3 new wrappers +- [ ] `src/Telecom.ts` — `TelecomEvent.requestId?: string`; 3 new wrappers +- [ ] `src/VoIP.ts` — cross-platform `fulfillIncomingCallConnected` / `failIncomingCallConnected` + / `getPendingAnswerRequestId` +- [ ] `src/useVoIPEvents.ts` — `onAnswered: (requestId: string) => void`; both cold-start + branches use `getPendingAnswerRequestId()` instead of `isCallAnswered()` +- [ ] `src/index.ts` + `packages/mobile-client/src/index.ts` — re-export the new functions +- [ ] `examples/.../app/src/voip/VoipProvider.tsx` — `pendingRequestIdRef`; fulfill in the + `remotePeers` effect; `failIncomingCallConnected` on the join-failure path; split + `endCall()` into `resetCallState()` + `endCall()` +- [ ] `examples/mobile-client/voip-call/README.md` §6 — document the handshake, the two + fulfillers, and the fixed timeout +- [ ] `FEATURE-IDEAS.md` #1 — link here; mark done when it lands + +### Verification + +- Compile: `JAVA_HOME=~/.sdkman/candidates/java/17.0.18-zulu ./gradlew + :fishjam-cloud_react-native-webrtc:compileDebugKotlin` (per project memory — the default + sdkman JDK 26 breaks gradle), and an iOS build of `examples/mobile-client/voip-call/app`. +- Device matrix, both platforms, **warm and cold (killed-app) start** for each: + 1. **Happy path** — answer; confirm the system call timer starts only when the remote peer's + media arrives, not at answer time. This is the whole point of the change and the one thing + a unit test cannot show you. + 2. **Join failure** — point `getPeerToken` at a 401; confirm the OS ends the call as *failed* + (iOS: Recents shows a missed/failed entry, not a 0-second connected call) rather than + showing a connect-then-drop. + 3. **Timeout** — stub `handleJoinRoom` to never resolve; confirm the call self-terminates + after ~10 s on both platforms and that a late `fulfillIncomingCallConnected` returns + `false` instead of resurrecting anything. + 4. **Remote hangup during connect** — hang up from the caller while the callee is joining; + confirm exactly one `ended` event and no duplicate end report. + 5. **Android FGS** — answer from a killed app with the screen locked; confirm no + `ForegroundServiceStartNotAllowedException` in logcat (this is what the + `showConnectingNotification()` split is defending). + +## Open questions + +- **Does `action.fail()` on a `CXAnswerCallAction` really trigger `CXEndCallAction`?** The + reference's doc comment says so; Apple's docs don't. The plan ships the defensive + `reportCallWithUUID:endedAtDate:reason:` either way — but the answer determines whether + `onCallEnded` fires once or twice, so measure it before wiring JS-side cleanup to `onEnded`. +- **What *is* the system `CXAction` timeout?** Measure with instrumentation in + `provider:timedOutPerformingAction:` and confirm it does not undercut the fixed 10 s deadline. +- **Should `failIncomingCallConnected` carry a reason?** Item #6 (`CallEndedReason`) would let + the fail path distinguish `failed` from `remoteEnded`. Not required for #1 — but if #6 lands + first, thread the reason through instead of hardcoding `CXCallEndedReasonFailed` / + `DisconnectCause.ERROR`. +- **Should the in-app answer button drive a real `CXAnswerCallAction`?** Today + `VoipProvider.answerCall()` joins the room without telling CallKit, so an in-app answer never + creates a fulfill request and the iOS system UI stays out of sync. That is item #16's + "programmatic answer"; it is the natural next step after this one and would let the + `requestId` flow through a single path on both platforms. diff --git a/examples/mobile-client/voip-call/CALL-TIMEOUTS-PLAN.md b/examples/mobile-client/voip-call/CALL-TIMEOUTS-PLAN.md new file mode 100644 index 000000000..c63ee519b --- /dev/null +++ b/examples/mobile-client/voip-call/CALL-TIMEOUTS-PLAN.md @@ -0,0 +1,525 @@ +# Call timeouts — implementation plan + +> Implements FEATURE-IDEAS.md item **#3** (incoming / outgoing / fulfill-answer timeouts), +> based on [CALL-TIMEOUTS-RESEARCH.md](./CALL-TIMEOUTS-RESEARCH.md) (verified against +> `~/Desktop/expo-callkit-telecom` source 2026-07-13) and audited against our tree the +> same day. All `file:line` references below were checked against the current code. + +## Current state (what exists, what's missing) + +| Timeout | iOS | Android | +|---|---|---| +| Fulfill-answer | ✅ hardcoded 10 s — `kFulfillAnswerTimeout`, `CallKitManager.m:8`, used at `:217` | ✅ hardcoded 10 s — `FULFILL_ANSWER_TIMEOUT_MS`, `voip/CallManager.kt:41`, used at `:312` | +| Incoming ring | ❌ rings forever | ❌ rings forever | +| Outgoing dial | ❌ (moot today: `connected` is reported instantly, `CallKitManager.m:80-81`) | ❌ (implementable now: "connected" moment = `setTelecomCallActive()` from JS) | + +Everything below keeps the **native-first rule**: ring timers must live in native code +because on both platforms the call can be ringing while no JS exists (iOS PushKit +cold-launch reports the call from `VoipManager.m:79`; Android's +`PushNotificationService.kt:32` reports it from the FCM process before React loads). +A JS `setTimeout` would silently not exist in exactly the scenario the timeout is for. + +## Design decisions (with reasoning) + +1. **End reason for ring expiry = existing `missed`, no new TS type.** + Our `missed` already maps to + [`CXCallEndedReason.unanswered`](https://developer.apple.com/documentation/callkit/cxcallendedreason/unanswered) + (`CallKitManager.m:123-124`) and + [`DisconnectCause.MISSED`](https://developer.android.com/reference/android/telecom/DisconnectCause#MISSED) + (`voip/CallManager.kt:117`) — byte-identical to what expo-callkit-telecom's + `UNANSWERED` produces natively (their `disconnectCauseFor`, `CallManager.kt:578-582`). + JS receives `onEnded('missed')` through the existing pipeline + (`useVoIPEvents.ts:57-60` iOS, `:129-131` Android) with **zero** TS changes. + Outgoing expiry also uses `missed` (matching their MISSED mapping); if product later + wants to distinguish, that belongs in FEATURE-IDEAS item #6 (end reasons), not here. + +2. **Expiry reuses the normal end-call paths, no bespoke teardown.** + iOS expiry calls the existing `endCallWithReason:@"missed"` (`CallKitManager.m:135`), + which already does `reportCallWithUUID:endedAtDate:reason:`, fires `onCallEnded`, + and runs `cleanup` (cancels fulfill requests, clears the buffered push payload). + Android expiry calls the existing `endCall(DisconnectCause(MISSED))` + (`voip/CallManager.kt:108`) → `Disconnect` action → `listener.onEnded("missed")` + (`:290-292`) → the `finally` block (`:250-267`) which already cancels notifications, + stops the foreground service, **and broadcasts `ACTION_CALL_ENDED` to dismiss + `IncomingCallActivity`** — the comment at `:262-263` even anticipates "timeout". + Nothing new to invent on the teardown side. + +3. **Single ring timer, not a per-UUID map.** Both our managers enforce one call at a + time (`currentCallUUID` + `maximumCallGroups = 1` on iOS `CallKitManager.m:35-36`; + `if (hasActiveCall) return` on Android `voip/CallManager.kt:177`). Their per-UUID + `[UUID: Task]` map exists because they support call stores; we'd be adding dead + generality. One cancellable timer + a call-identity guard at expiry is equivalent + and simpler. `startRingTimeout` cancels any previous timer first (restart-safe, + like theirs). Known future upgrade: if multi-call lands + ([HOLD-MULTICALL-RECENTS-PLAN.md](./HOLD-MULTICALL-RECENTS-PLAN.md) Phase 3), the + single timer becomes a per-UUID map — that phase owns the migration; don't + pre-build it here. + +4. **One-shot discipline.** The winner between "expire" and "answer/end" must be + decided once. On iOS everything relevant runs on the main queue (the provider + delegate uses `queue:nil` → main, `CallKitManager.m:40`; we schedule the expiry + block on main), so cancel-before-fire is ordered by the queue itself, plus a + `uuid == currentCallUUID && !isCallAnswered` guard inside the block. On Android the + answer paths aren't serialized with the timer coroutine, so expiry re-checks the + `@Volatile` `answered`/`hasActiveCall` flags after `delay()` (same belt-and-braces + as their `status == CONNECTED` check, `CallManager.kt:173-176`); the residual + window is closed by the fact that expiry only `trySend`s a `Disconnect` action into + the same serial `processActions` channel every other transition uses. + +5. **Config = build-time metadata, native defaults, exactly like theirs.** + Info.plist keys on iOS, `` on Android, written by our existing Expo + config plugin (`packages/mobile-client/plugin`), read once natively with fallbacks + so bare-RN apps and plugin-less setups keep working. Keys (namespaced to us): + - `FishjamVoipIncomingCallTimeout` — seconds, default **45** + - `FishjamVoipOutgoingCallTimeout` — seconds, default **60** + - `FishjamVoipFulfillAnswerTimeout` — seconds, default **10** (keeps our current + behavior; theirs defaults to 30 — do *not* silently change an existing deadline) + Values must be > 0; anything else (missing, 0, negative, non-numeric) falls back to + the default. No runtime API — matching theirs, and it avoids "timeout changed + mid-ring" states. + +6. **iOS timer primitive: `dispatch_block_t` + `dispatch_after`, wall clock.** + The ObjC equivalent of their cancellable `Task.sleep`: + [`dispatch_block_create`](https://developer.apple.com/documentation/dispatch/1431052-dispatch_block_create) + gives a handle that [`dispatch_block_cancel`](https://developer.apple.com/documentation/dispatch/1431058-dispatch_block_cancel) + can revoke. Schedule with [`dispatch_walltime`](https://developer.apple.com/documentation/dispatch/1420512-dispatch_walltime) + rather than `dispatch_time(DISPATCH_TIME_NOW, …)`: the ringing app is typically + backgrounded (PushKit launch), and the uptime clock stops if the process is + briefly suspended — wall time fires on schedule or immediately on resume. (The + existing `FulfillRequestManager.m:36` uses the uptime clock; acceptable there + because the app is in an active CallKit answer transaction, but don't copy it for + the ring timer.) + +7. **Android timer primitive: a coroutine `Job` with `delay`,** identical to theirs + and to our existing `FulfillRequestManager.kt` — launched on the existing + `CallManager.scope` (`Dispatchers.Default`, `voip/CallManager.kt:43`), cancelled via + [`Job.cancel()`](https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-job/cancel.html) + which aborts the suspended + [`delay`](https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/delay.html). + +8. **iOS outgoing timeout is gated on OUTGOING-CONNECT-PLAN.md** (FEATURE-IDEAS #2). + Today `startCallWithDisplayName` reports `connected` in the same completion block + that would start the timer (`CallKitManager.m:80-81`), so the timer would be + cancelled microseconds after starting — dead code at best, a live-call killer at + worst if the cancel is misplaced. Steps 1–5 below are independent of #2; Step 6 + lands with it. **Android outgoing is implementable now** because the "connected" + moment already exists as a distinct signal (`setTelecomCallActive()` / + `onSetActive`). + +--- + +## Step 1 — iOS: ring-timer infrastructure + incoming timeout + +**File: `packages/react-native-webrtc/ios/RCTWebRTC/CallKitManager.m`** + +1a. Replace the single constant at line 8 with three ivars + a read helper (defaults +only in this step; the Info.plist read is Step 3 — keep the diff reviewable): + +```objc +static const NSTimeInterval kDefaultIncomingCallTimeout = 45; +static const NSTimeInterval kDefaultOutgoingCallTimeout = 60; +static const NSTimeInterval kDefaultFulfillAnswerTimeout = 10; +``` + +Add to the class extension (next to `pendingAnswerRequestId`, line 15): + +```objc +@property(nonatomic, copy, nullable) dispatch_block_t ringTimeoutBlock; +@property(nonatomic, assign) NSTimeInterval incomingCallTimeout; // set in init +@property(nonatomic, assign) NSTimeInterval outgoingCallTimeout; +@property(nonatomic, assign) NSTimeInterval fulfillAnswerTimeout; +``` + +1b. New methods (place next to `cleanup`): + +```objc +- (void)startRingTimeoutForCall:(NSUUID *)uuid timeout:(NSTimeInterval)timeout { + [self cancelRingTimeout]; + + __weak typeof(self) weakSelf = self; + dispatch_block_t block = dispatch_block_create(0, ^{ + typeof(self) strongSelf = weakSelf; + if (strongSelf == nil) { + return; + } + strongSelf.ringTimeoutBlock = nil; + // The call this timer was armed for may already be gone or answered. + if (![uuid isEqual:strongSelf.currentCallUUID] || strongSelf.isCallAnswered) { + return; + } + [strongSelf endCallWithReason:@"missed"]; + }); + self.ringTimeoutBlock = block; + dispatch_after(dispatch_walltime(NULL, (int64_t)(timeout * NSEC_PER_SEC)), + dispatch_get_main_queue(), block); +} + +- (void)cancelRingTimeout { + if (self.ringTimeoutBlock != nil) { + dispatch_block_cancel(self.ringTimeoutBlock); + self.ringTimeoutBlock = nil; + } +} +``` + +Why this is safe: +- Expiry runs on main; `performAnswerCallAction` / `performEndCallAction` / + `providerDidReset` also run on main (delegate queue is nil, `CallKitManager.m:40`), + so a cancel that happens before the block dequeues always wins — no lock needed. +- Expiry → `endCallWithReason:@"missed"` → the reported-reason branch + (`CallKitManager.m:159-165`): `reportCallWithUUID:endedAtDate:reason:` with + `CXCallEndedReasonUnanswered` (via `cxEndedReasonForReason`, `:123-124`) — this is + the documented way to dismiss a ringing CallKit UI for an unanswered call + ([`reportCall(with:endedAt:reason:)`](https://developer.apple.com/documentation/callkit/cxprovider/reportcall(with:endedat:reason:))) + — then `onCallEnded(@"missed")` → JS `onEnded('missed')` → the example's + `endCall('missed')` cleanup, and `cleanup` clears the buffered push payload. +- `endCallWithReason` → `cleanup` → `cancelRingTimeout` calls + `dispatch_block_cancel` on the *currently executing* block — documented no-op for a + block that already started; harmless. + +1c. Wire the sites: +- **Start**: in `reportIncomingCallWithDisplayName:` (`:103-114`), add a success + branch to the [`reportNewIncomingCall`](https://developer.apple.com/documentation/callkit/cxprovider/reportnewincomingcall(with:update:completion:)) + completion (it runs on the delegate queue = main): + + ```objc + completion:^(NSError *_Nullable error) { + if (error) { + /* existing failure handling */ + return; + } + [weakSelf startRingTimeoutForCall:uuid timeout:weakSelf.incomingCallTimeout]; + }]; + ``` + + Only after success, matching theirs — if CallKit refused the call there is nothing + ringing to time out, and `currentCallUUID` was already reset. +- **Cancel on answer**: first line of `performAnswerCallAction:` + (`:211`, before `isCallAnswered = YES`): `[self cancelRingTimeout];`. + This is the *only* answer surface on iOS today — there is no in-app answer bridge + method (`WebRTCModule+CallKit.m` exports none), the example's `answerCall` is + triggered *by* this delegate's `onCallAnswered` event. If an in-app answer API is + ever added (their `answerCall(for:)` via `CXCallController`), it funnels through + this same delegate anyway, so the cancel site stays correct. +- **Cancel on every teardown**: add `[self cancelRingTimeout];` as the first line of + `cleanup` (`:185`). That covers `endCallWithReason` (both branches end in cleanup), + `performEndCallAction` (`:208`), answer-failure teardown + (`reportAnswerFailureForCall`, `:182`), and `providerDidReset` (`:196`). + +**Sanity check (no config yet):** temporarily set the default to ~15 s, ring the +device from the example server, don't answer → CallKit UI dismisses at 15 s, example +shows `lastEndedReason: missed`. Also verify a call answered at ~10 s is *not* ended +at 15 s. + +## Step 2 — Android: ring-timer infrastructure + incoming timeout + +**File: `packages/react-native-webrtc/android/src/main/java/com/oney/WebRTCModule/voip/CallManager.kt`** + +2a. Constants and state (next to `FULFILL_ANSWER_TIMEOUT_MS`, line 41): + +```kotlin +private const val DEFAULT_INCOMING_CALL_TIMEOUT_MS = 45_000L +private const val DEFAULT_OUTGOING_CALL_TIMEOUT_MS = 60_000L +private const val DEFAULT_FULFILL_ANSWER_TIMEOUT_MS = 10_000L + +private var incomingCallTimeoutMs = DEFAULT_INCOMING_CALL_TIMEOUT_MS +private var outgoingCallTimeoutMs = DEFAULT_OUTGOING_CALL_TIMEOUT_MS +private var fulfillAnswerTimeoutMs = DEFAULT_FULFILL_ANSWER_TIMEOUT_MS +private var ringTimeoutJob: Job? = null +``` + +(Replace the use of `FULFILL_ANSWER_TIMEOUT_MS` at `:312` with +`fulfillAnswerTimeoutMs`; delete the old constant.) + +2b. Timer helpers: + +```kotlin +private fun startRingTimeout(timeoutMs: Long) { + cancelRingTimeout() + ringTimeoutJob = scope.launch { + delay(timeoutMs) + // Re-check after waking: the call may have been answered or torn down + // between the last cancel site and this dispatch. + if (!hasActiveCall || answered) return@launch + endCall(DisconnectCause(DisconnectCause.MISSED)) + } +} + +private fun cancelRingTimeout() { + ringTimeoutJob?.cancel() + ringTimeoutJob = null +} +``` + +Why this is safe: +- Expiry funnels through the **existing** `endCall` → `CallAction.Disconnect` channel + (`:108-110`), so it is serialized with every other call transition by + `processActions` (`:275-294`); the `finally` block then handles notification + cancel, foreground-service stop, `IncomingCallActivity` dismissal broadcast — all + already written for exactly this case (`:250-267`). +- `causeToReason(MISSED)` → `"missed"` (`:127`) → `listener.onEnded("missed")` → + `telecomActionPerformed {event:'ended', reason:'missed'}` (`TelecomController.java:109-114`) + → JS `onEnded('missed')` (`useVoIPEvents.ts:129-131`). No TS changes. +- The `@Volatile answered` re-check mirrors their `status == CONNECTED` guard; the + worst-case race (answer lands in the same instant as expiry's `trySend`) resolves + in the serial action channel, and `handleAnswered`'s cancel (below) shuts the + window on the common path. +- `scope` is `Dispatchers.Default` — `endCall` only does `actions?.trySend`, which is + thread-safe ([`Channel.trySend`](https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.channels/-send-channel/try-send.html)). + +2c. Wire the sites: +- **Start (incoming)**: in the `addCall` control-scope body, next to the incoming + branch (`:222`): + + ```kotlin + if (isIncoming) { + callNotificationManager.showIncoming(ctx.applicationContext, displayName, isVideo) + startRingTimeout(incomingCallTimeoutMs) + } else { + showOngoingNotification() + } + ``` + + Inside the scope body — i.e. only once + [`CallsManager.addCall`](https://developer.android.com/reference/androidx/core/telecom/CallsManager#addCall(androidx.core.telecom.CallAttributesCompat,kotlin.coroutines.SuspendFunction1,kotlin.coroutines.SuspendFunction1,kotlin.coroutines.SuspendFunction1,kotlin.coroutines.SuspendFunction1)) + has actually registered the call — matching theirs (`CallManager.kt:387-394`), and + crucially it also runs on the FCM cold-start path since + `PushNotificationService.kt:32` → `reportIncomingCall` → `register` needs no JS. +- **Cancel on answer**: first line of `handleAnswered()` (`:305`), *before* the + `pendingAnswerRequestId != null` early-return, so a re-entrant answer can't skip + the cancel: + + ```kotlin + private fun handleAnswered() { + cancelRingTimeout() + if (pendingAnswerRequestId != null) return + ... + ``` + + Both answer paths funnel here: external answers via the `onAnswer` lambda (`:209-212`) + and app-initiated answers via `CallAction.Answer` success (`:286-289`) — + `IncomingCallActivity`'s swipe/notification answer also goes through + `CallManager.answer()` (`IncomingCallActivity.kt:103`). +- **Cancel on every teardown**: add `cancelRingTimeout()` to the `finally` block + (`:250`, alongside `FulfillRequestManager.cancelAll()`). Every end path — + disconnect action, remote `onDisconnect`, `addCall` failure — flows through + `finally`, so no timer survives a dead call. + +**Sanity check:** same as Step 1, on the emulator, including the killed-app case: +force-stop the app, send the FCM push, don't answer → full-screen incoming UI and +notification disappear at the deadline with no JS ever running. + +## Step 3 — native config reads (both platforms, incl. fulfill timeout) + +3a. **iOS** — in `CallKitManager.m` `init` (`:29`), after the provider setup: + +```objc +static NSTimeInterval timeoutFromInfoPlist(NSString *key, NSTimeInterval fallback) { + id value = [NSBundle.mainBundle objectForInfoDictionaryKey:key]; + if ([value respondsToSelector:@selector(doubleValue)]) { + double seconds = [value doubleValue]; + if (seconds > 0) { + return seconds; + } + } + return fallback; +} +``` + +```objc +_incomingCallTimeout = timeoutFromInfoPlist(@"FishjamVoipIncomingCallTimeout", kDefaultIncomingCallTimeout); +_outgoingCallTimeout = timeoutFromInfoPlist(@"FishjamVoipOutgoingCallTimeout", kDefaultOutgoingCallTimeout); +_fulfillAnswerTimeout = timeoutFromInfoPlist(@"FishjamVoipFulfillAnswerTimeout", kDefaultFulfillAnswerTimeout); +``` + +`respondsToSelector:@selector(doubleValue)` accepts both `NSNumber` (plist +``) and `NSString` — more forgiving than their `as? Int`, same fallback +semantics ([`object(forInfoDictionaryKey:)`](https://developer.apple.com/documentation/foundation/bundle/1408696-object)). +Then replace `kFulfillAnswerTimeout` at `:217` with `self.fulfillAnswerTimeout`. + +3b. **Android** — in `ensureRegistered` (`voip/CallManager.kt:159`), which is the +first point with a `Context` on both the JS and FCM cold-start paths: + +```kotlin +private var timeoutsLoaded = false + +private fun loadTimeouts(context: Context) { + if (timeoutsLoaded) return + timeoutsLoaded = true + incomingCallTimeoutMs = readTimeoutMs(context, "FishjamVoipIncomingCallTimeout", DEFAULT_INCOMING_CALL_TIMEOUT_MS) + outgoingCallTimeoutMs = readTimeoutMs(context, "FishjamVoipOutgoingCallTimeout", DEFAULT_OUTGOING_CALL_TIMEOUT_MS) + fulfillAnswerTimeoutMs = readTimeoutMs(context, "FishjamVoipFulfillAnswerTimeout", DEFAULT_FULFILL_ANSWER_TIMEOUT_MS) +} + +/** Reads a manifest meta-data value in seconds; returns milliseconds. */ +private fun readTimeoutMs(context: Context, key: String, defaultMs: Long): Long = try { + val appInfo = context.packageManager.getApplicationInfo( + context.packageName, PackageManager.GET_META_DATA) + val seconds = appInfo.metaData?.getInt(key, (defaultMs / 1000).toInt()) + ?: (defaultMs / 1000).toInt() + if (seconds > 0) seconds * 1000L else defaultMs +} catch (_: Exception) { + defaultMs +} +``` + +Call `loadTimeouts(context)` at the top of `ensureRegistered`. Notes: +- [`getApplicationInfo(…, GET_META_DATA)`](https://developer.android.com/reference/android/content/pm/PackageManager#getApplicationInfo(java.lang.String,%20int)) + + [`Bundle.getInt`](https://developer.android.com/reference/android/os/Bundle#getInt(java.lang.String,%20int)) + works because the manifest parser stores a numeric `android:value` as an integer + [`TypedValue`](https://developer.android.com/guide/topics/manifest/meta-data-element#val) + — this is exactly the read expo-callkit-telecom does (`CallManager.kt:149-161`), + so the write format in Step 4 must remain a plain integer string. +- Imports needed: `android.content.pm.PackageManager`. + +## Step 4 — config plugin + docs + +**Files: `packages/mobile-client/plugin/src/types.ts`, `withFishjamVoip.ts`, new +`withFishjamVoipIos` mod (or extend `withFishjamIos.ts`), and README/docs.** + +4a. `types.ts` — one cross-platform knob (the values mean the same thing on both +platforms, so don't split them under `android`/`ios`): + +```ts +voip?: { + /** Seconds an unanswered incoming call rings before auto-ending as `missed`. Default 45. */ + incomingCallTimeout?: number; + /** Seconds an outgoing call may stay unconnected before auto-ending as `missed`. Default 60. */ + outgoingCallTimeout?: number; + /** Seconds JS has to fulfill an answered call before it fails. Default 10. */ + fulfillAnswerCallTimeout?: number; +}; +``` + +4b. Android — extend `withFishjamVoipAndroid` (`withFishjamVoip.ts:65`), inside the +existing `enableVoip` gate, next to the `INSTALLATION_ID_META` handling (`:119-126`): +for each provided prop, upsert a meta-data entry +`{ $: { 'android:name': key, 'android:value': String(Math.floor(seconds)) } }` using +the same find-index-or-push pattern. **Write only the props the user set** — omitted +props fall back to native defaults, keeping the manifest clean. Validate +`Number.isFinite(v) && v > 0` and throw a descriptive config error otherwise (fail at +prebuild, not silently at runtime). + +4c. iOS — add a small mod using +[`withInfoPlist`](https://docs.expo.dev/config-plugins/plugins-and-mods/#ios-mods): + +```ts +config.modResults.FishjamVoipIncomingCallTimeout = Math.floor(props.voip.incomingCallTimeout); // etc. +``` + +Wire it into the plugin composition in `withFishjam.ts` next to the existing VoIP +mods. Same validation, same only-when-provided rule. + +4d. Docs: +- `Telecom.ts:25` — extend the `missed` doc comment: "an incoming call rang and was + never answered — including the native ring timeout (default 45 s)". +- Example app `README.md` + package docs: document the three plugin props, the native + defaults, and the bare-RN escape hatch (add the Info.plist keys / manifest + `` entries by hand). +- Flip FEATURE-IDEAS.md item #3 status when done. + +## Step 5 — Android outgoing timeout + +Small, isolated, and implementable today because Android already has a real +"connected" signal: the example calls `setTelecomCallActive()` when the first remote +peer joins (`VoipProvider.tsx:254-258`) → `CallAction.Activate`, and external +surfaces (watch/Auto) arrive via the `onSetActive` lambda. + +In `voip/CallManager.kt`: +- **Start**: the outgoing branch from Step 2c: + + ```kotlin + } else { + showOngoingNotification() + startRingTimeout(outgoingCallTimeoutMs) + } + ``` +- **Cancel**: two sites, covering both activation paths: + - `onSetActive = { answered = true }` (`:218`) → `onSetActive = { answered = true; cancelRingTimeout() }` + - in `processActions`, on successful `Activate` (`:284-289` area) add an + `else if (action == CallAction.Activate) { cancelRingTimeout() }` branch — + [`CallControlScope.setActive()`](https://developer.android.com/reference/androidx/core/telecom/CallControlScope#setActive()) + does **not** invoke the `onSetActive` lambda (that callback is for + externally-initiated transitions), so both sites are required. +- The Step 2b guard `if (!hasActiveCall || answered)` already protects an active + outgoing call, because `onSetActive`/successful activate set `answered = true` + (`:218`, and `fulfillAnswered` → `setCallActive`). +- Hold-plan interplay: [HOLD-MULTICALL-RECENTS-PLAN.md](./HOLD-MULTICALL-RECENTS-PLAN.md) + Phase 1 reuses `CallAction.Activate` as "un-hold". An un-hold Activate hitting these + cancel sites is harmless (holding is only possible post-connect, when the ring timer + is already cancelled), but if Activate is ever split into separate connected/un-hold + actions, both cancel sites here must move with the *connected* variant. + +Semantics note: "connected" for outgoing means *first remote peer joined the room*. +In the example that is precisely when ringing conceptually ends, matching theirs +(`reportOutgoingCallConnected` cancels their timer, `CallManager.kt:437-440`). + +## Step 6 — iOS outgoing timeout (⏸ gated on OUTGOING-CONNECT-PLAN.md) + +Do **not** implement before FEATURE-IDEAS #2 lands (see decision 8). When it does: +- **Start**: `startRingTimeoutForCall:uuid timeout:self.outgoingCallTimeout` in the + `requestTransaction` completion of `startCallWithDisplayName:` (`CallKitManager.m:79-85`), + immediately after `reportOutgoingCallWithUUID:startedConnectingAtDate:` — and the + instant `connectedAtDate` report at `:81` must already be gone per that plan. +- **Cancel**: in the new `reportOutgoingCallConnected` method that plan introduces + (mirroring theirs, `CallManager.swift:502-507`), plus the existing `cleanup` site + from Step 1 already covers all end paths. +- The expiry guard from Step 1 needs one adjustment for outgoing: `isCallAnswered` + never becomes true for outgoing calls today, so the guard reduces to the UUID check — + correct, because for outgoing "still ringing" *is* "not torn down and not reported + connected", and the cancel in `reportOutgoingCallConnected` encodes the latter. +- Add a line to OUTGOING-CONNECT-PLAN.md referencing this step so neither plan ships + half of the pair (an outgoing timeout with instant-connected reporting can never + fire *after* the OUTGOING plan if the cancel is wired; an outgoing-connect change + without the timeout resurrects the "dials forever" gap). + +--- + +## Edge cases audited + +| Case | Behavior | +|---|---| +| App suspended mid-ring (iOS) | `dispatch_walltime` fires on schedule or immediately on resume; uptime-clock `dispatch_after` would silently extend the ring (decision 6). | +| Cold start / killed app | Both start sites run without JS (PushKit path `VoipManager.m:79`; FCM path `PushNotificationService.kt:32` — `register` needs no React context). Expiry paths are JS-free too. | +| Metro reload mid-ring | Timers are native singletons; `TelecomController.detach()` only clears the listener, and the iOS manager is a process singleton. The end event after reload reaches JS via the normal re-subscription. | +| Answer at T-0 ms race | iOS: serialized on main queue + UUID/answered guard. Android: `handleAnswered` cancel + post-`delay` volatile re-check + serial action channel (decision 4). | +| Expiry vs. already-ended call | iOS: `endCallWithReason` no-ops on nil `currentCallUUID` (`:136-139`) and the block's UUID guard catches a *new* call reusing the slot. Android: `finally` cancels the timer; the guard re-checks `hasActiveCall`. | +| Second call reusing the timer | `startRingTimeout` cancels any previous timer first (restart-safe, like theirs `CallManager.kt:167`). | +| `IncomingCallActivity` left on screen | Already dismissed by the `ACTION_CALL_ENDED` broadcast in `finally` (`CallManager.kt:262-266`). | +| CallKit's own `timedOutPerformingAction` | Unrelated: it concerns un-fulfilled `CXAction`s post-answer and is already handled (`CallKitManager.m:241-253`); the ring timer only lives pre-answer. | +| Config value invalid | Plugin throws at prebuild; native clamps `<= 0` / non-numeric to defaults (Step 3). | +| Fulfill default change | None — stays 10 s unless configured (decision 5). | + +## Verification / QA plan + +Configure the example app (`examples/mobile-client/voip-call/app/app.json`) with +short values — `"voip": { "incomingCallTimeout": 15, "outgoingCallTimeout": 20 }` — +prebuild, then run the matrix on both platforms (drive with argent where scripted): + +1. **Foreground ring-out**: incoming call, don't answer → auto-end at 15 s, native UI + gone, example shows `lastEndedReason: missed`, caller side gets the room-leave. +2. **Locked-screen ring-out** (Android full-screen intent, iOS lock-screen CallKit). +3. **Cold-start ring-out**: force-stop the app first; no JS may run before expiry. +4. **Answer at ~10 s** → call connects and is *not* ended at 15 s (ride it past 60 s). +5. **Android outgoing ring-out**: call a room nobody joins → ends at 20 s, `missed`. +6. **Android outgoing answered**: callee joins at ~10 s → no end at 20 s. +7. **Fulfill timeout regression**: answer while Metro is stopped (JS can't fulfill) → + call fails at 10 s exactly as today. +8. **Defaults path**: remove the `voip` config, prebuild → behavior unchanged except + 45/60 s auto-end. + +## Files touched (summary) + +| File | Change | +|---|---| +| `packages/react-native-webrtc/ios/RCTWebRTC/CallKitManager.m` | ring timer, start/cancel sites, Info.plist reads, fulfill timeout ivar | +| `packages/react-native-webrtc/android/.../voip/CallManager.kt` | ring timer, start/cancel sites, meta-data reads, fulfill timeout var | +| `packages/mobile-client/plugin/src/types.ts` | `voip` timeout props | +| `packages/mobile-client/plugin/src/withFishjamVoip.ts` | Android meta-data entries | +| `packages/mobile-client/plugin/src/withFishjamIos.ts` (or new mod) | Info.plist keys | +| `packages/react-native-webrtc/src/Telecom.ts` | `missed` doc comment only | +| READMEs / FEATURE-IDEAS.md | config docs, status flip | + +No changes to: `FulfillRequestManager` (either platform), `useVoIPEvents.ts`, +`CallKit.ts` API surface, `TelecomController.java`, the example's `VoipProvider.tsx` +(it already handles `onEnded('missed')`). diff --git a/examples/mobile-client/voip-call/CALL-TIMEOUTS-RESEARCH.md b/examples/mobile-client/voip-call/CALL-TIMEOUTS-RESEARCH.md new file mode 100644 index 000000000..9d7ee7fdd --- /dev/null +++ b/examples/mobile-client/voip-call/CALL-TIMEOUTS-RESEARCH.md @@ -0,0 +1,280 @@ +# Call timeouts — how expo-callkit-telecom does it + +> Deep-dive into how `~/Desktop/expo-callkit-telecom` implements the three call timeouts +> (incoming / outgoing / fulfill-answer), verified against their source on 2026-07-13. +> Companion to [FEATURE-IDEAS.md](./FEATURE-IDEAS.md) item **#3** and +> [ANSWER-HANDSHAKE-PLAN.md](./ANSWER-HANDSHAKE-PLAN.md) (their fulfill-answer timeout is +> the timeout leg of the handshake we already ported with a hardcoded 10 s deadline). + +## The three timeouts at a glance + +| Timeout | Default | Starts when… | Cancelled when… | On expiry | +|---|---|---|---|---| +| **Incoming** | 45 s | the incoming call is reported to CallKit / Core-Telecom | user answers, call ends, provider resets | end call, reason `unanswered` | +| **Outgoing** | 60 s | the outgoing call starts connecting (`CXStartCallAction` / Telecom scope up) | app calls `reportOutgoingCallConnected`, call ends, provider resets | stop dialtone, end call, reason `unanswered` | +| **Fulfill-answer** | 30 s | the answer event (with `requestId`) is emitted to JS | JS fulfills or fails the request, call ends | iOS: `CXAnswerCallAction.fail()` → CallKit tears the call down; Android: end call, reason `failed` | + +Key structural point: the incoming/outgoing **ring timeouts** and the **fulfill-answer +timeout** are two separate mechanisms. Ring timeouts live in `CallManager` as per-call +cancellable timers keyed by call UUID. The fulfill timeout lives inside +`FulfillRequestManager` and is the third resolution path of a pending answer request +(fulfilled / cancelled / timed out) — it is never tracked in the ring-timeout map. + +--- + +## Configuration pipeline (config plugin → build metadata → native) + +Values are **build-time config, not runtime API**. The Expo +[config plugin](https://docs.expo.dev/config-plugins/introduction/) accepts three props +and bakes them into platform metadata; the native side reads them once at startup. + +1. **Props + defaults** — `plugin/src/withExpoCallKitTelecom.ts:19-38` declares + `incomingCallTimeout`, `outgoingCallTimeout`, `fulfillAnswerCallTimeout` (seconds). + Defaults in `plugin/src/constants.ts:2-4`: **45 / 60 / 30**. + +2. **iOS** — `withTimeouts` (`plugin/src/withExpoCallKitTelecomIos.ts:106-121`) writes + three Info.plist keys via [`withInfoPlist`](https://docs.expo.dev/config-plugins/plugins-and-mods/#ios-mods): + - `ExpoCallKitTelecomIncomingCallTimeout` + - `ExpoCallKitTelecomOutgoingCallTimeout` + - `ExpoCallKitTelecomFulfillAnswerCallTimeout` + +3. **Android** — `withTimeouts` (`plugin/src/withExpoCallKitTelecomAndroid.ts:57-87`) + writes the same three keys as `` entries in `AndroidManifest.xml` via + [`AndroidConfig.Manifest.addMetaDataItemToMainApplication`](https://docs.expo.dev/config-plugins/plugins-and-mods/#android-mods) + (values stringified seconds). + +4. **Native read**: + - iOS reads lazily via [`Bundle.main.object(forInfoDictionaryKey:)`](https://developer.apple.com/documentation/foundation/bundle/1408696-object) + into `static let` [`Duration`](https://developer.apple.com/documentation/swift/duration)s + with the same hardcoded fallbacks (`ios/Managers/CallManager.swift:32-48` for + incoming/outgoing, `ios/Managers/CallManager+CXProviderDelegate.swift:7-14` for + fulfill-answer). + - Android reads in `CallManager.initialize()` via + [`PackageManager.getApplicationInfo(…, GET_META_DATA)`](https://developer.android.com/reference/android/content/pm/PackageManager#getApplicationInfo(java.lang.String,%20int)) + → `readTimeoutMs()` seconds→ms conversion, `try/catch` falling back to defaults + (`android/.../managers/CallManager.kt:126-131, 148-161`). + +Because both platforms fall back to the same defaults when the key is missing, the +feature works even without the config plugin step. + +--- + +## iOS implementation + +### Ring timeouts (incoming + outgoing) — `CallManager` + +State: `callTimeoutTasks: [UUID: Task]` guarded by an `NSLock` +(`CallManager.swift:50-54`). One structured-concurrency +[`Task`](https://developer.apple.com/documentation/swift/task) per call — no `Timer`, +no `DispatchQueue.asyncAfter`. + +`startCallTimeout(for:timeout:)` (`CallManager.swift:91-116`): + +```swift +let task = Task { + try? await Task.sleep(for: timeout) + guard !Task.isCancelled else { return } + self.removeTimeoutTask(for: id) // claim ownership of expiry + DialtonePlayer.shared.stop() // outgoing dialtone, no-op otherwise + await reportCallEnded(for: id, reason: .unanswered) +} +callTimeoutTasks[id] = task // under lock +``` + +- Cancellation is cooperative: `cancelCallTimeout` removes the task under the lock and + calls [`Task.cancel()`](https://developer.apple.com/documentation/swift/task/cancel()), + which makes the in-flight [`Task.sleep`](https://developer.apple.com/documentation/swift/task/sleep(for:tolerance:clock:)) + throw; the `guard !Task.isCancelled` then bails (`CallManager.swift:97, 129-137`). +- Expiry funnels into the same `reportCallEnded(for:reason:)` used for remote hangups + (`CallManager.swift:560-579`): stops dialtone, cancels any timeout (idempotent), + reports to CallKit via [`CXProvider.reportCall(with:endedAt:reason:)`](https://developer.apple.com/documentation/callkit/cxprovider/reportcall(with:endedat:reason:)) + with [`CXCallEndedReason`](https://developer.apple.com/documentation/callkit/cxcallendedreason)`.unanswered`, + emits `CallReportedEnded` to JS with the ended session snapshot, and removes the session. + +**Start sites:** +- Incoming: after [`reportNewIncomingCall`](https://developer.apple.com/documentation/callkit/cxprovider/reportnewincomingcall(with:update:completion:)) + succeeds — both the async path (`CallManager.swift:340`) and the callback path used for + VoIP pushes from a terminated state (`CallManager.swift:417`, inside the completion, + in a detached `Task` after the session is stored). +- Outgoing: in the [`CXStartCallAction`](https://developer.apple.com/documentation/callkit/cxstartcallaction) + delegate, right after [`reportOutgoingCall(with:startedConnectingAt:)`](https://developer.apple.com/documentation/callkit/cxprovider/reportoutgoingcall(with:startedconnectingat:)) + (`CallManager+CXProviderDelegate.swift:30-35`). + +**Cancel sites:** +- [`CXAnswerCallAction`](https://developer.apple.com/documentation/callkit/cxanswercallaction) + delegate — first line, before the fulfill handshake starts + (`CallManager+CXProviderDelegate.swift:58-62`). The incoming ring timeout is *replaced* + by the fulfill-answer timeout at this moment. +- `reportOutgoingCallConnected` — stops dialtone + cancels (`CallManager.swift:502-507`). +- [`CXEndCallAction`](https://developer.apple.com/documentation/callkit/cxendcallaction) + delegate — user/system hangup (`CallManager+CXProviderDelegate.swift:99-104`). +- `reportCallEnded` — any external end (`CallManager.swift:563-565`). +- [`providerDidReset`](https://developer.apple.com/documentation/callkit/cxproviderdelegate/providerdidreset(_:)) + — `cancelAllCallTimeouts()` drains the whole map atomically, plus + `FulfillRequestManager.cancelAll()` (`CallManager+CXProviderDelegate.swift:18-26`, + `CallManager.swift:139-161`). + +### Fulfill-answer timeout — `FulfillRequestManager` (actor) + +`ios/Managers/FulfillRequestManager.swift` is an [`actor`](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/#Actors) +holding `pendingRequests: [UUID: PendingRequest]`, where each request bundles the call +ID, a [`CheckedContinuation`](https://developer.apple.com/documentation/swift/checkedcontinuation) +and its own timeout `Task` (`FulfillRequestManager.swift:30-36`). + +The delegate literally **suspends the `CXAnswerCallAction` on the request's outcome** +(`CallManager+CXProviderDelegate.swift:64-94`): + +```swift +let (requestId, resultTask) = await FulfillRequestManager.shared.createRequest( + callId: action.callUUID, timeout: Self.answerCallTimeout) +CallEventEmitter.shared.send(CallAnsweredEvent(id: action.callUUID, requestId: requestId)) +switch await resultTask.value { +case .fulfilled: action.fulfill() +case .cancelled: action.fail() // JS called failIncomingCallConnected +case .timedOut: action.fail() // 30 s elapsed with no JS response +} +``` + +- `createRequest` (`FulfillRequestManager.swift:50-93`) wraps a + [`withCheckedContinuation`](https://developer.apple.com/documentation/swift/withcheckedcontinuation(isolation:function:_:)) + in a `Task` and spawns a sibling timeout task + (`Task.sleep(for: timeout)`); whichever side wins **removes the request from the map + first**, so the continuation is resumed exactly once — the loser finds the map empty + and no-ops. +- `fulfill(requestId:)` (`:102-114`) — removes the entry, cancels the timeout task, + resumes `.fulfilled(callId:)`. Returns `nil` if the request already timed out; the JS + API surfaces that as `fulfillIncomingCallAnswered` resolving `false` + (`ExpoCallKitTelecomModule.swift:365-373`). +- `cancel(requestId:)` (`:121-129`) — same, resumes `.cancelled`. This is + `failIncomingCallConnected` from JS. +- On timeout/cancel the delegate calls [`action.fail()`](https://developer.apple.com/documentation/callkit/cxaction/fail()); + per their docs (`CallManager.swift:481-487`) CallKit then ends the call via + `CXEndCallAction`, which runs the normal cleanup path — so a fulfill timeout does + **not** emit `unanswered`; it surfaces as a failed answer → ended call. + +There is deliberately **no ring timeout restart** after answering: once answered, the +call is either connected (JS fulfilled) or dead (fail/timeout) — never back to ringing. + +--- + +## Android implementation + +### Ring timeouts — `CallManager` coroutines + +State: each call's `CallController` carries a `timeoutJob: Job?` +(`android/.../managers/CallManager.kt:84-90`), launched on a +`CoroutineScope(Dispatchers.Main + SupervisorJob())`. + +`startCallTimeout(id, timeoutMs)` (`CallManager.kt:166-183`): + +```kotlin +cancelCallTimeout(id) // restart-safe +val job = scope.launch { + delay(timeoutMs) // cancellable suspend + val session = CallStore.session(id) ?: return@launch + if (session.status == CallSessionStatus.CONNECTED) return@launch // race guard + DialtonePlayer.stop() + reportCallEnded(id, CallEndedReason.UNANSWERED) +} +activeCalls[id]?.timeoutJob = job +``` + +Two guards the iOS side doesn't need: the session-existence check and the +`status == CONNECTED` check after [`delay`](https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/delay.html) +resumes — belt-and-braces against a connect racing the expiry on the main dispatcher. +Cancellation is [`Job.cancel()`](https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-job/cancel.html) +(`CallManager.kt:186-191`), which aborts the suspended `delay`. + +**Start sites:** +- Outgoing: last step of the Core-Telecom call scope body, after + [`CallsManager.addCall`](https://developer.android.com/reference/androidx/core/telecom/CallsManager#addCall(androidx.core.telecom.CallAttributesCompat,kotlin.coroutines.SuspendFunction1,kotlin.coroutines.SuspendFunction1,kotlin.coroutines.SuspendFunction1,kotlin.coroutines.SuspendFunction1,kotlin.coroutines.SuspendFunction2)) + is up, dialing notification shown, dialtone playing (`CallManager.kt:296`). +- Incoming: inside the incoming call scope, right after `INCOMING_CALL_REPORTED` is + emitted (`CallManager.kt:387-394`). + +**Cancel sites:** +- `onCallAnswered` — shared answer entrypoint for both the system Telecom `onAnswer` + lambda and the in-app `answerCall()` (`CallManager.kt:825`); like iOS, the ring timeout + is replaced by the fulfill request. +- `reportOutgoingCallConnected` — with `DialtonePlayer.stop()` (`CallManager.kt:437-440`). +- `finishCall` — the shared finalizer behind both `endCall` and `reportCallEnded`, which + also runs `FulfillRequestManager.cancelForCall(id)` so **every** end path clears both + mechanisms at once (`CallManager.kt:483-486`). + +Expiry funnels into `reportCallEnded(id, UNANSWERED)` → `finishCall` → Telecom +disconnect via the action channel with a mapped +[`DisconnectCause`](https://developer.android.com/reference/android/telecom/DisconnectCause), +`CALL_REPORTED_ENDED` event to JS with `reason: "unanswered"`, session removal +(`CallManager.kt:464-518`). + +### Fulfill-answer timeout — `FulfillRequestManager` (object) + +`android/.../managers/FulfillRequestManager.kt` is a singleton with two maps +(`requests: requestId→callId`, `timeoutJobs: requestId→Job`) guarded by +`synchronized(lock)` (`:38-44`). Unlike iOS there is no parked action to resume +(Core-Telecom answers via a suspend lambda, not a CXAction), so the request resolves +through an **`onTimeout` callback** instead of a continuation: + +- `onCallAnswered` creates the request and wires expiry straight to + `reportCallEnded(callId, CallEndedReason.FAILED)` (`CallManager.kt:838-841`) — note: + **`failed`, not `unanswered`**, mirroring iOS where a fulfill timeout fails the answer + action rather than reporting an unanswered ring. +- `createRequest` (`FulfillRequestManager.kt:53-81`): launch `delay(timeoutMs)`; on wake, + atomically remove both map entries and only invoke `onTimeout` if the request was still + present — same "remove-first wins" one-shot discipline as the iOS actor. +- `fulfill(requestId)` (`:88-107`): atomically remove + cancel the timeout job; returns + the `callId` or `null` if already timed out (JS `fulfillIncomingCallConnected` then + returns `false`, `CallManager.kt:417-434`). +- `cancel(requestId)` (`:114-124`) and `cancelForCall(callId)` (`:132-149`) cover the + JS-fail path and the call-ended path respectively. + +--- + +## Design details worth copying + +1. **Two mechanisms, not one.** Ring timeouts are per-call cancellable timers owned by + the call manager; the fulfill timeout is a resolution path of the pending answer + request. Keeping them separate keeps every cancel site trivial. +2. **One-shot by construction.** Both platforms make "expire" and "resolve" race safely + by *removing the map entry first* under a lock/actor; whoever removes it owns the + outcome, the other side no-ops. This is exactly the double-settlement problem our + `pendingAnswerRequestIdRef` clear-before-await in `VoipProvider.tsx` solves at the JS + layer — they solve it natively. +3. **Expiry reuses the normal end-call path** (`reportCallEnded`) instead of a bespoke + teardown, so notifications, audio, JS events, and store cleanup can't drift. +4. **Every end path cancels both mechanisms** (Android `finishCall` cancels the ring + timer *and* `cancelForCall`; iOS `providerDidReset` drains both). No leaked timers + after hangup. +5. **Reason semantics:** ring expiry → `unanswered`; fulfill expiry → failed answer + (`CXAnswerCallAction.fail()` on iOS, `CallEndedReason.FAILED` on Android). Callers can + distinguish "nobody picked up" from "picked up but media never connected". +6. **Restart-safe start:** Android's `startCallTimeout` cancels any existing timer for + the id before launching a new one. +7. **Build-time config with native defaults**, so the module behaves sanely even when + the plugin props are omitted. + +## Porting notes for our `packages/react-native-webrtc` + +- We already have the fulfill-answer timeout from the handshake port + (ANSWER-HANDSHAKE-PLAN.md), but hardcoded at 10 s. Making it configurable means: + an Info.plist key read in `ios/RCTWebRTC` (CallKit/Voip managers) and a manifest + `` read in `android/.../voip/` — we don't have an Expo config plugin, so + the app sets these directly (or via `expo.android.manifest`-style config if the + example app adopts one). +- The missing pieces are the **incoming** and **outgoing** ring timeouts: + - iOS: a `[UUID: Task]` map in `CallKitManager.m`'s Swift-adjacent layer is awkward + from ObjC — `NSMapTable` of `dispatch_block_t` created with + [`dispatch_block_create`](https://developer.apple.com/documentation/dispatch/1431052-dispatch_block_create) + + [`dispatch_after`](https://developer.apple.com/documentation/dispatch/1452876-dispatch_after) + and cancelled with [`dispatch_block_cancel`](https://developer.apple.com/documentation/dispatch/1431058-dispatch_block_cancel) + is the ObjC-native equivalent of their cancellable `Task.sleep`. + - Android: our `voip/` controllers already use coroutines around Core-Telecom, so the + `timeoutJob`-per-call pattern maps over almost verbatim. + - Expiry should call our existing native end-call/report-ended path with the + `unanswered` reason we added in the endCall-reasons work, so JS receives it through + the same `onEnded` event `VoipProvider.tsx` already handles. +- Watch the interaction with OUTGOING-CONNECT-PLAN.md: an outgoing ring timeout is only + correct once we stop reporting `connected` immediately (their timeout works *because* + connect reporting is deferred until real media connect — otherwise every outgoing call + would be "connected" instantly and the timeout would never matter, or worse, fire on + a live call if the connected-status guard is missing). diff --git a/examples/mobile-client/voip-call/FCM-COEXISTENCE-PLAN.md b/examples/mobile-client/voip-call/FCM-COEXISTENCE-PLAN.md index 10aa8f92a..633636f36 100644 --- a/examples/mobile-client/voip-call/FCM-COEXISTENCE-PLAN.md +++ b/examples/mobile-client/voip-call/FCM-COEXISTENCE-PLAN.md @@ -1,9 +1,11 @@ # Plan: FCM messaging-service coexistence (Android) > Companion to [FEATURE-IDEAS.md](./FEATURE-IDEAS.md) item #7 (⏸ DEFERRED). That item holds -> the original single-forwarder design; this file extends it with research into how other -> VoIP SDKs solve the problem and a layered plan that supports **multiple** push-notification -> libraries and lets the host app **intercept or forward** payloads. +> the original single-forwarder design; this file adds research into how other VoIP/chat +> SDKs solve the problem (verified 2026-07-10, extended 2026-07-15 with Agora) and a +> phased plan whose primary mechanism is **zero-config on both Expo and bare RN** +> (runtime forwarding), with a dispatcher escape hatch for multiple notification +> libraries and payload interception. ## Problem (recap) @@ -15,96 +17,142 @@ also uses expo-notifications / `@react-native-firebase/messaging` / Notifee / On loses either its notifications or our calls, plus `onNewToken` on the losing side. iOS is unaffected — VoIP pushes ride PushKit's separate channel (see FEATURE-IDEAS #7). -## Research: how other SDKs solve it (verified 2026-07-10) +## Research: how other SDKs solve it -### expo-callkit-telecom — inheritance, single hard-coded partner -`ExpoCallKitTelecomMessagingService extends ExpoFirebaseMessagingService` -(expo-notifications' service); non-call messages go to `super.onMessageReceived()`, and the -config plugin strips expo-notifications' own service with -[`tools:node="remove"`](https://developer.android.com/build/manage-manifests#node_markers). -- ✅ Zero config for Expo apps; token callbacks chain via `super.onNewToken()`. -- ❌ Compile-time dependency on expo-notifications; coexists with **only** that library; - requires Expo prebuild. Not transferable to bare RN. - -### Twilio Voice React Native SDK — built-in service + opt-out + JS handler API -Contrary to first impressions, Twilio **does** ship a React Native SDK -([@twilio/voice-react-native-sdk](https://github.com/twilio/twilio-voice-react-native)), and -since v1.2.1 it documents this exact problem in +### Twilio Voice React Native SDK — built-in service + opt-out flag + handler API +[@twilio/voice-react-native-sdk](https://github.com/twilio/twilio-voice-react-native) +documents this exact problem since v1.2.1 in [out-of-band-firebase-messaging-service.md](https://github.com/twilio/twilio-voice-react-native/blob/main/docs/out-of-band-firebase-messaging-service.md): - Built-in `FirebaseMessagingService` is ON by default (zero-config path). -- Opt-out via a **boolean resource** — the app adds `android/app/src/main/res/values/config.xml`: - ```xml - false - ``` - (no manifest surgery needed — the service checks the flag at runtime). -- Escape hatch is a **JS API**: `voice.handleFirebaseMessage(remoteMessage.data)` returns - `Promise` (`true` = it was a Twilio call push). The user wires it inside +- Opt-out via a **boolean resource** in the app + (`false` in + `android/app/src/main/res/values/config.xml`) — needed because their service lives in the + **library** manifest and can't simply be un-declared. +- Escape hatch is a JS API — `voice.handleFirebaseMessage(remoteMessage.data)` returns + `Promise` (`true` = it was a Twilio call push) — wired inside `@react-native-firebase/messaging`'s [`onMessage`](https://rnfirebase.io/reference/messaging#onMessage) / [`setBackgroundMessageHandler`](https://rnfirebase.io/reference/messaging#setBackgroundMessageHandler). -- ⚠️ Known weakness: the out-of-band path depends on RNFB's **headless JS** in killed - state — see [issue #445](https://github.com/twilio/twilio-voice-react-native/issues/445) - (crash answering from killed state via out-of-band service) and - [issue #370](https://github.com/twilio/twilio-voice-react-native/issues/370) - (adding RNFB breaks incoming calls until you go out-of-band). - -### Stream (GetStream) Video React Native SDK — no native service at all -Stream ships **no** `FirebaseMessagingService`. It mandates -`@react-native-firebase/messaging` and exposes JS helpers — a payload discriminator -`isFirebaseStreamVideoMessage(msg)` and a processor `firebaseDataHandler(msg.data)` — that -the user calls inside `onMessage` / `setBackgroundMessageHandler` -([push notification docs](https://getstream.io/video/docs/react-native/incoming-calls/other-than-ringing-setup/react-native/)). -- ✅ Perfect coexistence by construction: the app owns the single handler and chains any - number of SDKs; full payload interception. -- ❌ Forces an RNFB dependency; killed-state handling rides RNFB's headless JS task — - slower to post the CallStyle notification than a native service, and subject to OEM - battery-optimization kills. +- ⚠️ The out-of-band path depends on RNFB **headless JS** in killed state — see + [issue #445](https://github.com/twilio/twilio-voice-react-native/issues/445) (crash + answering from killed state) and + [issue #370](https://github.com/twilio/twilio-voice-react-native/issues/370). + +### Agora — no service at all; developer-owned service + SDK helpers (docs-only) +Agora's RTC/video SDK has **no push layer whatsoever** — for call invitations they point +you at your own signaling + FCM and (for RN) community patterns around +[react-native-callkeep](https://github.com/react-native-webrtc/react-native-callkeep) +with RNFB background messaging ([Agora FAQ](https://docs.agora.io/en/faq/call_invite_notification)). +Agora **Chat** likewise ships no service: the developer writes their own +`FirebaseMessagingService`, overrides `onNewToken`, and calls SDK helpers such as +`ChatClient.getInstance().sendFCMTokenToServer(token)` +([Integrate offline push](https://docs.agora.io/en/agora-chat/develop/offline-push/integrate-test)). +Coexistence is trivially the developer's job — the SDK only provides token/parse +entry points. Sendbird's chat SDK follows the identical pattern +(`isSendbirdMessage(remoteMessage)` + `markAsDelivered`, +[Sendbird FCM docs](https://sendbird.com/docs/chat/sdk/v4/android/push-notifications/multi-device-support/set-up-push-notifications-for-fcm)). + +### Stream (GetStream) Video React Native SDK — no native service, JS helpers +Ships **no** `FirebaseMessagingService`; mandates `@react-native-firebase/messaging` and +exposes JS helpers — discriminator `isFirebaseStreamVideoMessage(msg)` + processor +`firebaseDataHandler(msg.data)` — called inside `onMessage` / `setBackgroundMessageHandler` +([push docs](https://getstream.io/video/docs/react-native/incoming-calls/other-than-ringing-setup/react-native/)). +Perfect coexistence by construction, but killed-state handling rides RNFB's headless JS +task — slower to post the CallStyle notification and subject to OEM battery kills. + +### expo-callkit-telecom — inheritance, single hard-coded partner +`ExpoCallKitTelecomMessagingService extends ExpoFirebaseMessagingService` +(expo-notifications' service); non-call messages go to `super.onMessageReceived()`; config +plugin strips expo-notifications' own service with +[`tools:node="remove"`](https://developer.android.com/build/manage-manifests#node_markers). +Zero config for Expo apps, but compile-time-coupled to expo-notifications only; not +transferable to bare RN. ### Pattern summary | | Native service? | Multiple PN libs? | Payload interception? | Killed-state robustness | |---|---|---|---|---| -| expo-callkit-telecom | yes (subclass) | ❌ expo-notifications only | ❌ | ✅ native | | Twilio Voice RN | yes + opt-out flag | ✅ via JS dispatcher | ✅ JS | ✅ native default / ⚠️ JS out-of-band | +| Agora (RTC & Chat) | no — developer-owned | ✅ by construction | ✅ native (yours) | ✅ native (yours) | | Stream Video RN | no | ✅ via JS dispatcher | ✅ JS | ⚠️ headless JS only | -| **Ours (planned)** | yes + opt-out | ✅ native fwd + dispatcher | ✅ native + JS | ✅ native in every mode | - -Industry consensus: **a payload discriminator + a public `handleMessage(data): Boolean` -helper + user-owned dispatcher service** is the standard escape hatch. Nobody else offers -automatic *native* forwarding to an unknown next service — that part of our design is a -genuine differentiator (better zero-config story than any of the above). - -## Plan — three layers, all compatible - -### Layer 1 (default, zero config): gated native service with runtime forwarding -The deferred design from FEATURE-IDEAS #7, unchanged: -1. Server adds `voip: "true"` to the FCM data payload; `PushNotificationService` only - handles messages carrying it. -2. Everything else (messages **and** `onNewToken`) is forwarded to the next - `MESSAGING_EVENT` service found via - [`queryIntentServices`](https://developer.android.com/reference/android/content/pm/PackageManager#queryIntentServices(android.content.Intent,%20int)), - instantiated reflectively with the app context attached. -3. `android:priority="1"` on our intent-filter (app manifest, via `withFishjamVoip.ts` and - the bare-RN docs snippet) makes us deterministically first. - -Covers: no other PN library, or exactly one — the 90% case — with native killed-state -handling and no work from the user. - -### Layer 2 (multiple libraries / interception): public native helpers + host-owned dispatcher -Promote the companion helpers to documented public API: +| expo-callkit-telecom | yes (subclass) | ❌ expo-notifications only | ❌ | ✅ native | +| **Ours (planned)** | yes + trivial opt-out | ✅ native dispatcher (+ opt. fwd) | ✅ native | ✅ native in every mode | + +**Industry consensus:** a payload discriminator + a public `handleMessage(...): Boolean` +helper + a **developer-owned dispatcher service** is the standard answer (Twilio, Agora, +Sendbird, Stream all converge on it — differing only in whether the helper is JS or +native and whether a default service ships at all). + +## What we already have (checked 2026-07-15) + +Two facts make a **docs-first** solution unusually cheap for us: + +1. **Our service is declared in the *app* manifest** (bare-RN snippet + `withFishjamVoip.ts`), + not the library manifest. So "opting out" is just *not declaring it* (or a plugin prop) — + we don't need Twilio's boolean-resource trick at all. +2. **The native entry points are already public**: `CallManager.reportIncomingCall(...)` + (`CallManager.kt:122`, public `object`) and `VoipPushRegistry.updateToken` / + `reportIncoming` / `bufferWaitingIncoming` (`VoipPushRegistry.kt`, public `object`). + A host-owned service *could* already call them — but the dispatch logic in + `PushNotificationService.onMessageReceived` is no longer trivial (parses + `roomName`/`displayName`/`handle`/`isVideo`/`avatarUrl`, handles the + `IncomingCallSlot.CURRENT/WAITING/REJECTED` call-waiting outcomes, private + `warmUpReact()`), so a copy-paste recipe would rot. **One small helper fixes that.** + +Note the payload already has a natural discriminator: a message is "ours" iff it carries +`roomName`. An explicit `voip: "true"` flag stays optional polish, not a prerequisite. + +## Plan — phased + +> **Constraint that sets the order (2026-07-15): must be zero-friction on BOTH Expo and +> bare RN.** The dispatcher pattern requires the user to write a Kotlin service — fine in +> bare RN, but Expo (CNG/prebuild) apps have no `android/` folder; they'd need a local +> module or custom plugin (Twilio's Expo support is an open issue, +> [#496](https://github.com/twilio/twilio-voice-react-native/issues/496)). Runtime +> forwarding is the only mechanism with zero user code on both, so it is **Phase 1**; +> the dispatcher helpers become the documented escape hatch for bare-RN power users. + +### Phase 1 (primary ship): discriminator gate + runtime forwarding + public helpers +The original FEATURE-IDEAS #7 design, now the core mechanism because it is the only +Expo-and-bare-compatible zero-config option: +- `PushNotificationService` handles only messages carrying the VoIP discriminator + (`roomName` today; explicit `voip: "true"` optional polish). +- Non-VoIP messages **and** token callbacks are forwarded to the next `MESSAGING_EVENT` + service found via + [`queryIntentServices`](https://developer.android.com/reference/android/content/pm/PackageManager#queryIntentServices(android.content.Intent,%20int)) + (reflective instantiation + `attachBaseContext`) — works with expo-notifications, RNFB, + Notifee, OneSignal… with no compile-time coupling and no user code. Forward to the + *next* service only (fan-out risks duplicate handling). The forwarded library keeps its + own native killed-state behavior — its real service code runs, just invoked by us. +- Expo: `withFishjamVoip` already injects our service → coexistence with + expo-notifications works with **no new config**. Bare RN: the README manifest snippet, + same result. Add `android:priority="1"` in both. + +**Also in Phase 1 (~20-line refactor the forwarding needs anyway):** extract the public +companion helpers on `PushNotificationService`: + ```kotlin -// in PushNotificationService.companion -fun handleVoipMessage(context: Context, message: RemoteMessage): Boolean // true = was a VoIP push, call reported -fun handleNewToken(token: String) +companion object { + /** Returns true iff the message was a Fishjam VoIP push and was handled. */ + fun handleVoipMessage(context: Context, message: RemoteMessage): Boolean + fun handleNewToken(token: String) +} ``` -Documented recipe (mirrors Twilio/Stream, but the handler chain runs in **native**, so -killed-state timing is not compromised): + +The service's own overrides become one-liners delegating to the helpers, so example-app +behavior is unchanged and there is nothing new to test beyond compilation. + +**Documentation:** a "Using other push-notification libraries" section in the SDK README / +example README §9. The zero-config forwarding covers the common case; this documents the +**escape hatch** for bare-RN apps with several push SDKs or payload-interception needs: + ```kotlin +// The app's single messaging service — replaces the SDK's service in the manifest. class MyMessagingService : FirebaseMessagingService() { override fun onMessageReceived(message: RemoteMessage) { if (PushNotificationService.handleVoipMessage(this, message)) return - // full interception point: inspect/mutate/route message.data here - MyChatSdk.handle(message) // any number of other SDKs, user-chosen order + // full interception point — inspect/route message.data however you like + MyChatSdk.handle(message) // chain any number of other SDKs } override fun onNewToken(token: String) { PushNotificationService.handleNewToken(token) @@ -112,47 +160,56 @@ class MyMessagingService : FirebaseMessagingService() { } } ``` -The user's service lives in the **app manifest**, so it outranks ours automatically -([merge priorities](https://developer.android.com/build/manage-manifests#merge_priorities)); -with an explicit disable (Layer 3) there's no ambiguity at all. - -### Layer 3: clean opt-out of the built-in service -Adopt Twilio's **boolean-resource** mechanism — better than manifest surgery because it -needs no `tools:` namespace and works identically in bare RN and Expo: -- Our service reads `R.bool.fishjam_voip_messaging_service_enabled` (default `true`, - defined in the library's `res/values`) at the top of `onMessageReceived`/`onNewToken` - and immediately delegates/forwards when `false`. -- Bare RN: app overrides it in `android/app/src/main/res/values/config.xml`. -- Expo: `withFishjamVoip` prop `android.voipMessagingService: false` (plugin then also - skips injecting our service into the manifest — cleaner than the runtime check alone). - -### Optional Layer 4 (later, if asked): JS-level handler for RNFB users -A JS `handleVoipMessage(data): Promise` wrapper (Twilio-style) so RNFB-centric -apps can keep a pure-JS dispatcher. **Explicitly second-class**: document that killed-state -delivery then depends on RNFB headless JS (Twilio #445-class issues), and recommend the -Layer 2 native dispatcher for production. - -## Implementation checklist (when un-deferred) - -- [ ] `PushNotificationService.kt`: `voip:"true"` gate; runtime forwarding - (`nextMessagingService()` via `queryIntentServices`, reflective `attachBaseContext`); - public companion helpers; `fishjam_voip_messaging_service_enabled` check -- [ ] library `res/values/config.xml`: `fishjam_voip_messaging_service_enabled = true` -- [ ] `server/main.ts`: add `voip: "true"` to FCM data; update payload docs in both READMEs -- [ ] example `AndroidManifest.xml` + README §9 snippet: `android:priority="1"` -- [ ] `withFishjamVoip.ts`: priority on injected intent-filter; `voipMessagingService` prop -- [ ] Docs: coexistence section — Layer 1 default, Layer 2 dispatcher recipe, Layer 3 opt-out -- [ ] iOS: **no change** (PushKit isolation verified — see FEATURE-IDEAS #7; a payload gate - on iOS would violate the report-a-call rule, - [pushRegistry(_:didReceiveIncomingPushWith:for:completion:)](https://developer.apple.com/documentation/pushkit/pkpushregistrydelegate/pushregistry(_:didreceiveincomingpushwith:for:completion:))) -- [ ] Verify: compile (`JAVA_HOME=~/.sdkman/candidates/java/17.0.18-zulu ./gradlew - :fishjam-cloud_react-native-webrtc:compileDebugKotlin`), then a device test with a - second messaging service registered (e.g. RNFB) in both winner orders + +- Document the two setups: (a) default — our service + automatic forwarding, zero config + on Expo and bare RN alike; (b) advanced — declare *your* service instead and call the + helper first (bare RN directly; Expo via a local module or custom plugin). +- Document why the chain must be **native**, not JS: a killed app receives the FCM + wake-up before any JS runs, and the CallStyle notification must post within Telecom's + window (this is where Twilio's JS out-of-band route shows cracks — #445). +- Warn about libraries that auto-register a service from their **library** manifest + (RNFB messaging does): the app-manifest service wins the merge + ([merge priorities](https://developer.android.com/build/manage-manifests#merge_priorities)); + `android:priority="1"` (or `tools:node="remove"` on the library one) makes it explicit. + +**Expo plugin:** add `android.voipMessagingService?: boolean` (default `true`) to +`withFishjamVoip.ts` so users bringing their own dispatcher can skip injecting our +service. (Later, if demand shows: a plugin mode that *generates* the dispatcher service +for Expo apps with a configurable delegate list.) + +This single phase beats every surveyed SDK: zero-config coexistence on both Expo and +bare RN (none of them offer that), plus the industry-standard dispatcher escape hatch. + +### Phase 2 (optional polish) +- Explicit `voip: "true"` payload flag (server + docs) as a cleaner discriminator than + "has `roomName`". +- JS-level `handleVoipMessage(data)` wrapper for RNFB-centric apps (Twilio-style), shipped + **explicitly second-class** with the killed-state caveat documented. + +## Implementation checklist + +Phase 1: +- [ ] `PushNotificationService.kt`: discriminator gate; runtime forwarding + (`nextMessagingService()` via `queryIntentServices`, reflective `attachBaseContext`) + for unhandled messages + `onRegistered`/`onNewToken`; extract public companion + helpers +- [ ] SDK / example README §9: "Using other push-notification libraries" section + (zero-config default, dispatcher recipe, native-not-JS rationale, manifest-merge note) +- [ ] example `AndroidManifest.xml` + README snippet + `withFishjamVoip.ts`: + `android:priority="1"`; `android.voipMessagingService` prop (default `true`) +- [ ] Compile check: `JAVA_HOME=~/.sdkman/candidates/java/17.0.18-zulu ./gradlew + :fishjam-cloud_react-native-webrtc:compileDebugKotlin` (from `examples/mobile-client/voip-call/app/android`) +- [ ] Device test with a second messaging service registered (e.g. RNFB or + expo-notifications) in both winner orders + +iOS: **no change in any phase** — PushKit isolation verified (FEATURE-IDEAS #7); a payload +gate on iOS would violate the report-a-call rule +([pushRegistry(_:didReceiveIncomingPushWith:for:completion:)](https://developer.apple.com/documentation/pushkit/pkpushregistrydelegate/pushregistry(_:didreceiveincomingpushwith:for:completion:))). ## Open questions -- Forward non-VoIP messages to the **next** service only (Android-semantics-faithful, - current design) or to **all** other registered services? Next-only recommended; - fan-out risks duplicate notifications when two libraries both display the same message. -- Should Layer 2's `handleVoipMessage` also accept a raw `Map` overload for - hosts that transform payloads before dispatch? +- Should `handleVoipMessage` also accept a raw `Map` overload for hosts + that transform payloads before dispatch? +- Bare-RN docs currently instruct declaring our service directly — should the coexistence + section become the *primary* documented path (dispatcher-first, like Agora), with our + service as the convenience shortcut? diff --git a/examples/mobile-client/voip-call/FEATURE-IDEAS.md b/examples/mobile-client/voip-call/FEATURE-IDEAS.md index 6a4ab083d..c83719eba 100644 --- a/examples/mobile-client/voip-call/FEATURE-IDEAS.md +++ b/examples/mobile-client/voip-call/FEATURE-IDEAS.md @@ -35,13 +35,19 @@ Legend: 🔴 correctness / product quality · 🟠 user-visible polish · 🟡 A duration in the dynamic island / status bar / Recents. - Docs: [reportOutgoingCall(with:connectedAt:)](https://developer.apple.com/documentation/callkit/cxprovider/reportoutgoingcall(with:connectedat:)) -### 3. Call timeouts (incoming / outgoing / fulfill-answer) +### 3. Call timeouts (incoming / outgoing / fulfill-answer) — ✅ DONE (verified 2026-07-15) - **Ours:** none — an unanswered incoming call rings until something external stops it. - **Theirs:** three configurable timeouts (incoming 45 s, outgoing 60 s, fulfill 30 s) baked into Info.plist / manifest metadata by the config plugin; expiry auto-ends the call with reason `unanswered` and stops the dialtone. - -### 4. `serverCallId` + opaque `metadata` in the push payload, with dedup +- **Status:** built on both platforms. iOS `CallKitManager.m:8-10` + (`kDefaultIncomingCallTimeout=45`, `kDefaultOutgoingCallTimeout=60`, + `kDefaultFulfillAnswerTimeout=10`, overridable via Info.plist keys + `VoipIncomingCallTimeout` / `VoipOutgoingCallTimeout` / `VoipFulfillAnswerTimeout`); + Android `CallManager.kt:54-56` (same 45/60/10 s defaults, `ringTimeoutJob` / + `waitingRingTimeoutJob`). Note: our fulfill default is **10 s**, not the 30 s above. + +### 4. `serverCallId` + opaque `metadata` in the push payload, with dedup — ⚠️ PARTIAL (verified 2026-07-15) - **Ours:** payload hardwired to `roomName` / `displayName` / `isVideo`; any new field requires native changes on both platforms. No dedup — a duplicate FCM send double-reports the call on Android. @@ -51,6 +57,17 @@ Legend: 🔴 correctness / product quality · 🟠 user-visible polish · 🟡 A `eventId` drives a dedup window on Android (`ExpoCallKitTelecomMessagingService.kt`) and `Equatable` on iOS. `startedAt` lets the call be backdated. Documented payload shape in `docs/voip-push.md`. +- **Status — asymmetric, remaining work is Android + dedup:** + - ✅ **iOS opaque passthrough done:** `VoipManager.m:75` mutable-copies and forwards the + *entire* `payload.dictionaryPayload`, surfaced to JS via `getPendingIncomingCall()` + (`VoIP.ts:32`) — so `serverCallId` / arbitrary `metadata` already ride through with no + native change. + - ❌ **Android still hardwired:** `PushNotificationService.kt:27-31` extracts only + `roomName` / `displayName` / `handle` / `isVideo` into a typed + `VoipPushRegistry.Incoming`; any extra `metadata` field is dropped. Needs an + opaque-map passthrough to reach JS parity with iOS. + - ❌ **Dedup not implemented** on either platform (no `eventId` window); no canonical + `incomingCall` wrapper key. ### 5. Killed-app decline broadcast (Android) - **Ours:** if the user declines while the app process is dead, no JS ever runs and the @@ -62,13 +79,20 @@ Legend: 🔴 correctness / product quality · 🟠 user-visible polish · 🟡 A `androidEventReceiver`) and POSTs the decline to the backend. Their test-push script even demos it: `--metadata '{"declineToken":"abc"}'`. See their `docs/platform-notes.md`. -### 6. End reasons (`CallEndedReason`) +### 6. End reasons (`CallEndedReason`) — ✅ DONE (verified 2026-07-15) - **Ours:** bare `endCall()`, no reasons anywhere. - **Theirs:** `reportCallEnded(id, reason)` with `failed | remoteEnded | unanswered | answeredElsewhere | declinedElsewhere`, mapped to [CXCallEndedReason](https://developer.apple.com/documentation/callkit/cxcallendedreason) — correct "missed" vs "declined" in Recents, and multi-device "answered elsewhere" becomes expressible. +- **Status:** built. `endCall(reason?)` / `endCallKitSession(reason?)` take a + `CallEndedReason` (`Telecom.ts:40`), iOS maps it in `cxEndedReasonForReason` + (`CallKitManager.m:242`) and Android in `reasonToCause` / `causeToReason` + (`CallManager.kt:216-232`, both directions). Our shipped reason set is + `local | rejected | missed | remote | answeredElsewhere | failed` — the names differ + from theirs above (`missed`≈`unanswered`, `remote`≈`remoteEnded`, no separate + `declinedElsewhere`; `rejected` is Android-only, folds to `local` on iOS). ### 7. FCM messaging-service coexistence (Android) — ⏸ DEFERRED Implemented and verified (compiled) on 2026-07-09, then reverted — to be revisited. diff --git a/examples/mobile-client/voip-call/HOLD-MULTICALL-RECENTS-PLAN.md b/examples/mobile-client/voip-call/HOLD-MULTICALL-RECENTS-PLAN.md new file mode 100644 index 000000000..d48a23e95 --- /dev/null +++ b/examples/mobile-client/voip-call/HOLD-MULTICALL-RECENTS-PLAN.md @@ -0,0 +1,482 @@ +# Hold, multiple calls, Recents & Siri — research + implementation plan + +> Covers four related asks: (1) on-hold support, (2) multiple simultaneous incoming +> calls with the ability to dismiss one of them, (3) calls appearing in iOS Recents, +> (4) an assessment of expo-callkit-telecom's Siri integration. Verified against +> `~/Desktop/expo-callkit-telecom` source and our tree on 2026-07-13. Companion to +> [CALL-TIMEOUTS-RESEARCH.md](./CALL-TIMEOUTS-RESEARCH.md) / +> [CALL-TIMEOUTS-PLAN.md](./CALL-TIMEOUTS-PLAN.md) — Phase 3 below supersedes part of +> that plan's timer design (called out explicitly where it does). + +## TL;DR verdicts + +| Ask | Verdict | +|---|---| +| Hold | Do it — and it's not just a feature: **without it, media keeps flowing while the OS holds our call for a cellular interruption** (real bug on both platforms today). Phase 1, moderate effort. | +| Multiple incoming calls | Possible, but it is **not** `supportsGrouping`/`supportsUngrouping` — those gate conference-merge UI and must stay off. The real enablers are `maximumCallGroups=2` + hold + per-call identity through the whole stack. Phase 3, large, breaking API change. | +| Recents | `includesCallsInRecents = YES` is one line; making the Recents entry *tappable-to-call-back* requires the call-intent plumbing. Phase 2, small-to-moderate. | +| Siri | **Free byproduct of Phase 2, don't invest beyond that.** Their entire "Siri integration" is the same `INStartCallIntent` handler Recents uses + 3 Info.plist entries. Real Siri usefulness needs phone/email handles we don't have (FEATURE-IDEAS #4 dependency). | + +--- + +# Part I — Research + +## How expo-callkit-telecom does hold + +**iOS.** Their JS `setHeld(id, onHold)` (`ExpoCallKitTelecomModule.swift:463-471`) requests a +[`CXSetHeldCallAction`](https://developer.apple.com/documentation/callkit/cxsetheldcallaction) +transaction through `CXCallController` (`CallManager.swift:661-681`). The provider +delegate (`CallManager+CXProviderDelegate.swift:141-154`) then updates the session +store and emits `SetHeldActionEvent` to JS, and JS is responsible for actually pausing +media. Note their config sets `supportsHolding = false` (`CallManager.swift:27`) — a +deliberate choice: app-driven hold works via transactions regardless, but the *system* +never holds their call (an incoming cellular call offers only "End & Accept", never +"Hold & Accept"). + +**Android.** `setHeld(id, onHold)` (`CallManager.kt:631-645`) sends `setInactive`/ +`setActive` into the per-call action channel → +[`CallControlScope.setInactive()`](https://developer.android.com/reference/androidx/core/telecom/CallControlScope#setInactive()) / +`setActive()` (`:741, :738-740`), updates the store, and emits `SET_HELD_ACTION`. +Crucially, they also wire the **externally-initiated** direction: the `addCall` +lambdas `onSetActive = { setHeld(id, false) }` and `onSetInactive = { setHeld(id, true) }` +(`:699-700`) — this is what fires when *Telecom itself* holds the call because another +app's call went active. All their calls declare +[`CallAttributesCompat.SUPPORTS_SET_INACTIVE`](https://developer.android.com/reference/androidx/core/telecom/CallAttributesCompat#SUPPORTS_SET_INACTIVE) +(`:265, :374`). + +**Multiple calls: they don't.** Hard single-session guards on both platforms +(`CallManager.swift:216-220`, `CallManager.kt:318-325`), `maximumCallGroups = 1` +(`CallManager.swift:59-60`). So Phase 3 below goes beyond their library — but their +per-call plumbing (`activeCalls[UUID]` map, `launchCallScope`, `CallManager.kt:676-748`) +is the exact shape a multi-call refactor needs, which is presumably why it's built +that way. + +## How they do Recents + Siri (one mechanism, not two) + +1. **Recents entries**: `includesCallsInRecents = true` + ([`CXProviderConfiguration.includesCallsInRecents`](https://developer.apple.com/documentation/callkit/cxproviderconfiguration/includescallsinrecents), + `CallManager.swift:62`) plus *meaningful handles*: `makeHandle` prefers + `phoneNumber` → `email` → generic participant id (`CallManager.swift:166-174`). + Their TS docs are explicit that a phone-number handle is what "enables Recents and + Siri" (`Calls.types.ts:95`). +2. **Recents tap / Siri "call Jane using "**: both arrive as an + [`INStartCallIntent`](https://developer.apple.com/documentation/sirikit/instartcallintent) + inside `application(_:continue:restorationHandler:)`. Their + `AppDelegateSubscriber.swift:31-92` unwraps `userActivity.interaction?.intent`, + accepts `INStartCallIntent` plus the deprecated-but-still-delivered + `INStartAudioCallIntent`/`INStartVideoCallIntent`, extracts the first + [`INPerson`](https://developer.apple.com/documentation/sirikit/inperson)'s + `personHandle`, and emits `CallIntentReceivedEvent { handle, handleType, hasVideo }` + to JS (queue-buffered for cold start, `AppDelegateSubscriber.swift:20`). JS then + starts the outgoing call itself — sessions started this way get origin + `outgoingSystem` (`Calls.types.ts:76`, `CallManager.swift:208`). +3. **The config plugin part**: adds the three intent class names to + [`NSUserActivityTypes`](https://developer.apple.com/documentation/bundleresources/information-property-list/nsuseractivitytypes) + in Info.plist (`withExpoCallKitTelecomIos.ts:29-97`). That's the entire "Siri + integration" — no Intents app extension, no shortcut donations, no custom + vocabulary. CallKit auto-donates the `INInteraction`s for calls it reports, which + is what populates both Recents and Siri suggestions. + +**Siri assessment for us:** with our current generic handles (`CXHandleTypeGeneric` +carrying `displayName`, `CallKitManager.m:59, :94`), Siri's contact resolution has +nothing to match against — "Hey Siri, call Paweł using " works unreliably at +best. The plumbing is still worth having because Recents needs the identical code +path; the *quality* upgrade for both comes automatically if FEATURE-IDEAS **#4** +(richer push payload with `phoneNumber`/`email` in a caller object) ever lands. +Recommendation: implement Phase 2, mark Siri "works where handles allow", spend +nothing more. + +## `supportsGrouping` / `supportsUngrouping` — what they actually gate + +You asked to investigate these for multi-call. Result: **they are the wrong knob, and +should stay off.** + +- [`supportsGrouping`](https://developer.apple.com/documentation/callkit/cxcallupdate/supportsgrouping) + advertises that this call can be *merged* with another into one call group — it + makes the system UI show a "merge" affordance, which arrives as + [`CXSetGroupCallAction`](https://developer.apple.com/documentation/callkit/cxsetgroupcallaction). + That is conference calling (N parties, one mixed audio session). +- [`supportsUngrouping`](https://developer.apple.com/documentation/callkit/cxcallupdate/supportsungrouping) + advertises the reverse: splitting a call out of a group. +- What "two independent calls, switch between them, dismiss one" — i.e. call waiting — + actually needs is: + - [`maximumCallGroups`](https://developer.apple.com/documentation/callkit/cxproviderconfiguration/maximumcallgroups) + `= 2` (each independent call is its own group of 1; we have `1` today, + `CallKitManager.m:36`), + - `maximumCallsPerCallGroup = 1` (unchanged — forbids merging), + - `supportsHolding = true` on the calls — without it the second-call sheet offers + only **End & Accept / Decline**; with it you also get **Hold & Accept**, + - per-UUID bookkeeping, because "dismiss one of several" is just a + [`CXEndCallAction`](https://developer.apple.com/documentation/callkit/cxendcallaction) + targeted at that call's UUID. +- Both libraries already `fail` the group action (`CallKitManager.m:269-271`) — + keep that, keep both flags `NO`. + +Android equivalent: [`CallsManager`](https://developer.android.com/reference/androidx/core/telecom/CallsManager) +happily hosts multiple concurrent `addCall` scopes; the platform coordinates "only +one active at a time" through `onSetInactive` — the same hook hold uses. There is no +grouping flag to worry about; the work is entirely in our singleton-shaped manager. + +One more Android fact worth stating so nobody looks for parity: **self-managed calls +(`MANAGE_OWN_CALLS` / Core-Telecom) never appear in the system call log** — "Recents" +is an iOS-only concept here; Android apps keep in-app call history. + +## Current state of our code (audited) + +| Piece | iOS | Android | +|---|---|---| +| Hold action from system | `performSetHeldCallAction` exists and emits `held` (`CallKitManager.m:255-260`, `WebRTCModule+CallKit.m:38-40`) — but effectively **dead code**: `supportsHolding = NO` (`:97`) means the system never sends it | `onSetInactive = { }` — **silently swallowed** (`voip/CallManager.kt:219`). If a cellular call is answered, Telecom holds us and we keep streaming media. Latent bug. | +| Hold action from app | No bridge method | `CallAction.Hold → setInactive()` exists in the action loop (`:279`) but **nothing ever sends it** — no bridge method | +| Un-hold | — | `CallAction.Activate → setActive()` exists and is reachable (it's the "connected" signal) | +| Hold in TS types | `CallKitAction.held?: boolean` exists (`CallKit.ts:18`), `useCallKitEvent('held', …)` works | `TelecomEventType` has no hold member; `CallEventsListener` has no hold callback | +| Multi-call | Hard single call: `currentCallUUID`, `maximumCallGroups=1` | Hard single call: `hasActiveCall` guard (`:177`), all state is singleton fields | +| Recents | `includesCallsInRecents = NO` (`:37`) | n/a (no system call log for self-managed) | +| Call intents | Nothing handles `continueUserActivity` | n/a | +| `SUPPORTS_SET_INACTIVE` | n/a | already declared (`:196`) ✓ | + +So hold is *half-wired on both platforms in opposite halves*: iOS has the +system→JS event but no way to trigger or receive holds; Android has the app→Telecom +action but no trigger, no events, and drops external holds. + +--- + +# Part II — Implementation plan + +Phases are independently shippable and ordered by value/effort. 1 and 2 are additive; +3 is breaking. + +## Phase 1 — Hold (single call, both platforms) + +### 1a. iOS: enable holding + app-side API + +**`packages/react-native-webrtc/ios/RCTWebRTC/CallKitManager.m`** + +1. Flip the incoming-call update: `update.supportsHolding = YES` (`:97`). +2. Outgoing calls get no `CXCallUpdate` today, so their hold capability rides on + CallKit defaults. Make it explicit: in the `startCallWithDisplayName:` transaction + completion (`:79-85`), report an update — + + ```objc + CXCallUpdate *update = [[CXCallUpdate alloc] init]; + update.supportsHolding = YES; + update.supportsGrouping = NO; + update.supportsUngrouping = NO; + update.supportsDTMF = NO; + [weakSelf.provider reportCallWithUUID:uuid updated:update]; + ``` + + (Verify against the `CXCallUpdate.h` header during implementation: the + capability properties default to YES, which is exactly why every serious + integration sets all of them explicitly — silent defaults are how the grouping + button appears by accident.) +3. Track state + expose an app API: + + ```objc + @property(nonatomic, assign) BOOL isCallOnHold; // class extension + + - (void)setCallHeld:(BOOL)onHold { + if (self.currentCallUUID == nil) { + return; + } + CXSetHeldCallAction *action = + [[CXSetHeldCallAction alloc] initWithCallUUID:self.currentCallUUID onHold:onHold]; + CXTransaction *transaction = [[CXTransaction alloc] initWithAction:action]; + [self.callController requestTransaction:transaction completion:^(NSError *error) { + if (error) { + NSLog(@"[CallKitManager] Failed to set held: %@", error.localizedDescription); + } + // performSetHeldCallAction fires on success and emits the event — + // do not emit here or JS sees the change twice. + }]; + } + ``` +4. In `performSetHeldCallAction:` (`:255-260`) set `self.isCallOnHold = action.isOnHold` + before the existing `onCallHeld` emit. Reset `isCallOnHold` in `cleanup`. +5. Guard interplay: an on-hold call must not be killed by the answer path or timers — + ring timers (CALL-TIMEOUTS-PLAN) only run pre-answer, no interaction. But + `endCallWithReason:` works held ✓ (End button while held is legitimate). + +**`WebRTCModule+CallKit.m`** — export `setCallKitCallHeld:(BOOL)onHold` (promise), plus +`RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(isCallKitCallHeld)` for state rehydration. + +Audio note (why there is no native audio work): when CallKit holds the only call it +deactivates the AVAudioSession, and our delegate already forwards +`didActivate`/`didDeactivate` to `RTCAudioSession` (`CallKitManager.m:273-279`), so +the WebRTC audio unit stops/starts correctly. What native code *cannot* do is stop +the outbound video track or tell the remote party — that's JS (below). + +### 1b. Android: expose the action, emit the events, fix the swallow + +**`voip/CallManager.kt`** + +1. Add the missing listener member and an un-hold-capable API: + + ```kotlin + interface CallEventsListener { + // ... existing ... + fun onHoldChanged(onHold: Boolean) + } + + fun setCallHeld(onHold: Boolean) { + actions?.trySend(if (onHold) CallAction.Hold else CallAction.Activate) + } + ``` +2. Wire the **external** transitions (the bug fix) in `register`'s `addCall` lambdas + (`:213-219`): + + ```kotlin + onSetActive = { answered = true; listener?.onHoldChanged(false) }, + onSetInactive = { listener?.onHoldChanged(true) }, + ``` +3. Wire the **app-initiated** transitions in `processActions` (`:284-293`) — the + lambdas above do *not* fire for `scope.setInactive()`/`setActive()` calls we make + ourselves (same asymmetry as the answer path, `:286-289`): + + ```kotlin + } else if (action == CallAction.Hold) { + listener?.onHoldChanged(true) + } else if (action == CallAction.Activate) { + // Activate doubles as "outgoing connected" and "un-hold"; both mean not-held. + listener?.onHoldChanged(false) + } + ``` +4. Track `@Volatile var onHold = false` next to `answered` for the sync getter, reset + in the `finally` block. + +Caveat to encode in a comment: `CallAction.Activate` is also the outgoing-connected +signal (`setTelecomCallActive()` from JS), so JS will see a spurious +`holdChanged(false)` when an outgoing call connects. That is harmless (it's already +not held) but must not be "fixed" by splitting the action without also updating the +CALL-TIMEOUTS-PLAN Step 5 cancel site. + +**`TelecomController.java`** — implement `onHoldChanged` → emit +`telecomActionPerformed { event: 'holdChanged', held }`; add `setCallHeld`/`isOnHold` +pass-throughs. **`WebRTCModule.java`** — export `setTelecomCallHeld(boolean)` and +sync `isTelecomCallHeld()` next to the existing Telecom methods (`:1707-1747`). + +### 1c. TS + example + +- `Telecom.ts`: add `'holdChanged'` to `TelecomEventType`, `held?: boolean` to + `TelecomEvent`; export `setTelecomCallHeld(onHold)` / `isTelecomCallHeld()`. +- `CallKit.ts`: export `setCallKitCallHeld(onHold)` / `isCallKitCallHeld()` (the + `held` member of `CallKitAction` already exists). +- `useVoIPEvents.ts`: add optional `onHeldChanged?: (onHold: boolean) => void` to + `VoIPEventHandlers`; wire from `useCallKitEvent('held', …)` on iOS and the + `holdChanged` telecom event on Android — hold finally becomes cross-platform in the + one hook the example actually uses. +- Example `VoipProvider.tsx`: on `onHeldChanged(true)` → mute mic + disable outbound + video + (optionally) pause remote audio rendering; on `false` → restore. Media + policy is deliberately JS-owned, same as theirs: the SDK reports the state, the + app decides what "held media" means for its product. + +**QA (Phase 1):** the critical test needs a real SIM device: during an active VoIP +call, receive a cellular call → the sheet must show **Hold & Accept** (iOS) → accept +→ our call holds, mic/video stop, other party sees/hears nothing → end cellular call +→ tap our call → resumes. Plus app-driven hold/unhold round-trip on both platforms, +and hold→hang-up-while-held. + +## Phase 2 — Recents + call intents (iOS), Siri as byproduct + +1. **Config**: new Info.plist key `FishjamVoipIncludeCallsInRecents` (bool, + default **NO** — preserves current behavior and its privacy posture: Recents + entries persist in the system UI and sync via iCloud). Read it in + `CallKitManager` `init` and set + `providerConfiguration.includesCallsInRecents` accordingly (`:37`). Plugin prop + `voip.includeCallsInRecents` alongside the timeout props from CALL-TIMEOUTS-PLAN + Step 4. +2. **Intent handler** — `VoipManager.m` (it already owns the "buffer things for + cold-start JS" pattern): + + ```objc + + (BOOL)handleContinueUserActivity:(NSUserActivity *)userActivity; + ``` + + Implementation mirrors theirs (`AppDelegateSubscriber.swift:31-92`): unwrap + `userActivity.interaction.intent`, accept `INStartCallIntent` / + `INStartAudioCallIntent` / `INStartVideoCallIntent`, map + `INPersonHandleType` → `"phoneNumber" | "email" | "unknown"`, then (a) buffer as + `pendingCallIntent` (single slot, mirroring `pendingIncomingCall`, + `VoipManager.m:82`) and (b) fire a new `onCallIntent` block that + `WebRTCModule+PushKit`-style glue forwards as a `voipPushEvent` payload + `{ callIntent: { handle, handleType, isVideo } }`. Return `YES` iff handled, so + the host AppDelegate can chain. `#import ` — no extra target, + no extension. +3. **AppDelegate integration**: + - Bare RN (document in README): + + ```objc + - (BOOL)application:(UIApplication *)application + continueUserActivity:(NSUserActivity *)userActivity + restorationHandler:(void (^)(NSArray> *))restorationHandler { + if ([VoipManager handleContinueUserActivity:userActivity]) { + return YES; + } + return [RCTLinkingManager application:application + continueUserActivity:userActivity + restorationHandler:restorationHandler]; + } + ``` + - Expo: extend the config plugin with a + [`withAppDelegate`](https://docs.expo.dev/config-plugins/plugins-and-mods/#dangerous-mods) + source mod inserting the call before the existing `continueUserActivity` return. + It's a string-level "dangerous mod" — accept the maintenance cost, add a + defensive "already contains `handleContinueUserActivity`" check, and keep the + manual snippet documented as the fallback. +4. **Info.plist `NSUserActivityTypes`**: plugin appends + `INStartCallIntent`, `INStartAudioCallIntent`, `INStartVideoCallIntent` + (set-union like theirs, `withExpoCallKitTelecomIos.ts:89-97`), gated on + `voip.includeCallsInRecents || voip.enableCallIntents`. +5. **JS**: `VoIPEventHandlers.onCallIntent?: (intent: { handle: string; handleType: 'phoneNumber'|'email'|'unknown'; isVideo: boolean }) => void` + in `useVoIPEvents.ts` (both the live event and the cold-start + `getPendingCallIntent()` replay, mirroring the `pendingCall` replay at + `useVoIPEvents.ts:89-104`). Example: treat `handle` (our generic handle carries + `displayName`) as the callee and start an outgoing call to the mapped room — + demonstrating the loop *Recents tap → app opens → call re-dials*. +6. **Siri**: nothing further. Document the honest behavior: works where Siri can + resolve the spoken name to a handle CallKit reported. Revisit only after + FEATURE-IDEAS #4 introduces `phoneNumber`/`email` in the caller payload — + then `makeHandle`-style preference (theirs, `CallManager.swift:166-174`) slots + into `reportIncomingCallWithDisplayName:` and both Recents and Siri upgrade + without touching this phase's plumbing. + +**QA (Phase 2):** call with Recents enabled → entry appears with the display name; +tap it cold (app killed) and warm → `onCallIntent` fires exactly once with the right +handle; "Hey Siri, call using " on a device where the name is +unambiguous; verify default-off config leaves Recents empty. + +## Phase 3 — Multiple calls (call waiting) ⚠️ large, breaking + +Ship 1–2 first; take this only when the product actually needs a second simultaneous +call. It is the only phase that changes existing API shapes. + +### 3a. The prerequisite everything else hangs on: call identity in the JS contract + +Today **no event carries a call id** (`CallKitAction`, `TelecomEvent`, +`VoIPEventHandlers`). With two calls, "ended" / "answer(requestId)" / "holdChanged" +are ambiguous. First (mechanical, additive) step: + +- Native: iOS emits `callId` (`currentCallUUID.UUIDString`) in every + `kEventCallKitActionPerformed` payload; Android adds `callId` to every + `telecomActionPerformed` body (requires threading the UUID through + `CallEventsListener`). +- TS: add `callId?: string` to `TelecomEvent` and restructure `CallKitAction` + payloads to objects (`{ callId, requestId }`, `{ callId, reason }` …) — **this is + the breaking change**; do it in one release with `useVoIPEvents` absorbing the + difference so example-level code only gains an optional `callId` argument. +- Bridge methods gain id-taking variants: `endCallKitSession(callId, reason)`, + `endTelecomCall(callId, reason)`, `setCallHeld(callId, onHold)`; the id-less forms + stay as "the only/active call" conveniences. + +### 3b. iOS + +**`CallKitManager.m`** — replace the scalar state (`currentCallUUID`, +`isCallAnswered`, `pendingAnswerRequestId`, plus Phase 1's `isCallOnHold` and +CALL-TIMEOUTS-PLAN's single `ringTimeoutBlock`) with a registry: + +```objc +@interface FJCallRecord : NSObject +@property NSUUID *uuid; +@property NSString *displayName; +@property BOOL isVideo, answered, onHold, outgoing; +@property(copy, nullable) NSString *pendingAnswerRequestId; +@property(copy, nullable) dispatch_block_t ringTimeoutBlock; +@end +// CallKitManager: NSMutableDictionary *calls; +``` + +- `maximumCallGroups = 2` (`:36`); `maximumCallsPerCallGroup` stays 1; + grouping flags stay NO; `performSetGroupCallAction` keeps failing. +- `reportIncomingCallWithDisplayName:` drops the implicit single-call assumption; + refuse a third call (`calls.count >= 2` → report busy? No — CallKit itself + enforces `maximumCallGroups`; still guard and log). +- **This converts CALL-TIMEOUTS-PLAN Step 1's single ring timer into the per-UUID + design of the research doc** (`callTimeoutTasks`-equivalent): the timer block moves + into `FJCallRecord`, cancel sites become record-scoped. Note this in that plan when + Phase 3 starts. +- Delegate methods already receive `action.callUUID` everywhere — the changes are + lookups instead of assumptions. The interesting new flow, **Hold & Accept**, + arrives as one transaction containing `CXSetHeldCallAction(callA, onHold:YES)` + + `CXAnswerCallAction(callB)`; Phase 1's per-action handlers compose correctly as + long as both are record-scoped and JS gets `callId` on both events (3a). +- "Dismiss one of several": system UI does it natively (per-call End); the app API + is `endCallKitSession(callId, reason)` → `CXEndCallAction` with that UUID, or the + reported-reason branch of `endCallWithReason:` scoped to the UUID. +- `cleanup` becomes `cleanupCall:(NSUUID *)` + `cleanupAll` (provider reset); + `FulfillRequestManager` is already request-id-keyed and needs no change; + `VoipManager.pendingIncomingCall` single slot → small FIFO (two entries suffice). + +### 3c. Android + +**`voip/CallManager.kt`** — the structural refactor. Their `launchCallScope` + +`handleCallActions` (`CallManager.kt:676-748`) is the blueprint to copy: + +- Singleton fields that must become per-call: `actions` channel, `displayName`, + `videoCall`, `answered`, `pendingAnswerRequestId`, `endpointJob`/`availableJob`/ + `muteJob`, ring timer → a `CallController(job, actions, state…)` in + `activeCalls: MutableMap`; `hasActiveCall` becomes + `activeCalls.isNotEmpty()`. +- Remove the `if (hasActiveCall) return` guard in `register` (`:177`) — replace with + a two-call cap. `CallsManager.addCall` supports concurrent scopes; Telecom + coordinates activation: answering call B triggers `onSetInactive` on call A → + Phase 1's `onHoldChanged(true)` (now with `callId`) tells JS to pause A's media. +- `PushNotificationService` must not drop a push while a call exists (today `register` + early-returns) — and needs event dedup before this ships (FEATURE-IDEAS #4 overlap). +- Notifications: `CallNotificationManager` currently manages one notification; + per-call notification ids, and the incoming-call full-screen + (`IncomingCallActivity`) should only launch when the screen is locked *and* no + call is active — during an active call the second call must present as a heads-up + CallStyle notification (default behavior when the screen is on) with + answer/decline actions, which is exactly the "dismiss one of multiple" surface. +- Audio: `AudioOutputManager.setTelecomOwnsRouting` and the endpoint collectors + assume one call — scope them to the *active* call, switching on hold-swap. +- `ForegroundServiceController` state ("connecting"/"connected") follows the active + call only. + +### 3d. JS + example + +- `useVoIPEvents` handlers all gain `callId`; `VoipProvider` state becomes + `Map` with one `activeCallId`; the `pendingAnswerRequestIdRef` + /`activationInFlightRef` pair becomes per-call (the same one-shot semantics, keyed). +- Product decision to make explicit in the example: what a **held** call's media does. + Recommended default: stay connected to the held call's room with mic muted and + inbound audio disabled (fast resume, costs bandwidth/battery); alternative — leave + the room and re-join on resume (cheap, slow resume, loses in-room state). The SDK + ships events; the example demonstrates the first policy. + +**QA (Phase 3):** second incoming during active call → heads-up/system sheet on both +platforms; Hold & Accept → A held (media stopped), B live; decline B → A untouched; +end B → tap A → resumes; both ring timers independent (let B ring out while A active +→ only B ends, reason `missed`); cold-start with two queued pushes. + +## Interplay with the other plans + +- **CALL-TIMEOUTS-PLAN**: Phases 1–2 don't touch it. Phase 3 upgrades its single ring + timer to the per-UUID map (both platforms) — the research doc already describes the + target design; add a cross-reference line to that plan when Phase 3 is scheduled. + Also Phase 1's Android note about `Activate` doubling as un-hold applies to that + plan's Step 5 cancel site. +- **OUTGOING-CONNECT-PLAN (#2)**: independent, but its `reportCallWithUUID:updated:` + moment (outgoing display-name update) is the natural place to also set the + Phase 1a capability flags — coordinate to avoid two adjacent `CXCallUpdate` reports. +- **FEATURE-IDEAS #4 (payload)**: the handle-quality upgrade (`phoneNumber`/`email`) + that makes Recents entries properly attributed and Siri actually usable lives + there, not here. +- **FEATURE-IDEAS #6 (end reasons)**: declining call B while on call A surfaces as + `rejected` on Android but `local` on iOS (documented gap, `Telecom.ts:22-24`) — + more visible once two calls exist; unchanged by this plan. + +## Files touched (summary) + +| File | Phase 1 | Phase 2 | Phase 3 | +|---|---|---|---| +| `ios/RCTWebRTC/CallKitManager.m` | hold flags, `setCallHeld`, state | `includesCallsInRecents` read | registry refactor, `maximumCallGroups=2` | +| `ios/RCTWebRTC/WebRTCModule+CallKit.m` | 2 methods | — | id-taking variants, `callId` in events | +| `ios/RCTWebRTC/VoipManager.m` | — | intent handler + pending buffer | pending buffer → FIFO | +| `android/.../voip/CallManager.kt` | `setCallHeld`, lambda wiring, listener | — | per-call controller refactor | +| `android/.../TelecomController.java` + `WebRTCModule.java` | holdChanged event, 2 methods | — | `callId` everywhere, id-taking variants | +| `src/CallKit.ts`, `src/Telecom.ts`, `src/useVoIPEvents.ts` | hold APIs + `holdChanged` + `onHeldChanged` | `onCallIntent` + pending replay | breaking payload change, per-call handlers | +| `packages/mobile-client/plugin` | — | Recents/intents props, `NSUserActivityTypes`, AppDelegate mod | — | +| example `VoipProvider.tsx` | hold → mute wiring | Recents re-dial demo | multi-call state | + +No changes in any phase to: `FulfillRequestManager` (either platform), +`PushNotificationService` (until Phase 3), `IncomingCallActivity` (until Phase 3). diff --git a/examples/mobile-client/voip-call/IOS-VOIP-REGISTRATION-PLUGIN-PLAN.md b/examples/mobile-client/voip-call/IOS-VOIP-REGISTRATION-PLUGIN-PLAN.md new file mode 100644 index 000000000..2e5e5a661 --- /dev/null +++ b/examples/mobile-client/voip-call/IOS-VOIP-REGISTRATION-PLUGIN-PLAN.md @@ -0,0 +1,147 @@ +# iOS VoIP push registration via config plugin — implementation plan + +> Automates what the example app currently does by hand at +> [`AppDelegate.swift:25`](./app/ios/voipcall/AppDelegate.swift) — +> `VoipManager.registerForVoIPPushes()` — so consumers of +> `@fishjam-cloud/react-native-client` don't have to hand-edit their AppDelegate. +> Verified against the tree 2026-07-13. + +## Current state + +- Native PushKit registration lives in the webrtc fork: + `packages/react-native-webrtc/ios/RCTWebRTC/VoipManager.h:9` exposes + `+ (void)registerForVoIPPushes`, implemented at `VoipManager.m:24-36` + (lazily creates a `PKPushRegistry`, sets `desiredPushTypes` to `PKPushTypeVoIP`). +- Nothing calls it automatically. The example app calls it by hand at + `examples/mobile-client/voip-call/app/ios/voipcall/AppDelegate.swift:25`, + inside `didFinishLaunchingWithOptions`, right after `bindReactNativeFactory(factory)`. +- The Android side already has an equivalent opt-in config plugin + (`packages/mobile-client/plugin/src/withFishjamVoipAndroid.ts`, gated by + `android.enableVoip`) that injects manifest permissions/components. iOS has no + matching plugin — this plan closes that gap. +- `packages/mobile-client/plugin/src/withFishjamIos.ts` already composes several + `withInfoPlist`/`withXcodeProject`/`withPodfileProperties` mods behind `ios.*` flags + (screensharing, PiP, VoIP background mode, VoIP timeouts) — the new mod follows the + same pattern. + +## Decision: `withAppDelegate` + `mergeContents`, not AppDelegate Subscribers + +Expo docs steer native modules toward `ExpoAppDelegateSubscriber` and call +`withAppDelegate` string-patching "strongly discouraged." Subscribers were rejected +here because they require `expo-modules-core` as a **runtime** dependency — this SDK +must work in bare React Native with zero runtime Expo deps. `withAppDelegate` is +build-time only (`expo prebuild`), so it doesn't compromise that constraint. + +The "discouraged" risk (duplicate inserts across re-prebuilds, brittle anchors) is +mitigated by `@expo/config-plugins`' own `mergeContents` helper — it wraps the +inserted line in `// @generated begin/end ` markers so re-prebuilds **replace** +the block instead of duplicating it, and reports `didMerge: false` if the anchor +isn't found (fail loud, not silently). + +### Known fragility (accepted, not blocking) + +1. **Swift-only.** Expo's AppDelegate template moved objc → Swift in SDK 52. Consumers + on SDK ≤51 have an objc `AppDelegate.mm`; the mod must detect + `modResults.language !== 'swift'` and throw a clear error pointing at the manual + fallback, not silently no-op. +2. **`mergeContents` is an internal import** — `@expo/config-plugins/build/utils/generateCode`, + not part of the package's public `exports`. Widely relied on by other plugins + (this is the standard idempotent-injection pattern in the Expo ecosystem) but not a + stability contract. Most likely thing to break on an `@expo/config-plugins` major bump. +3. **Anchor drift.** If Expo reshuffles the AppDelegate template again, the merge fails + loud via `didMerge` — a build-time error, not a silent runtime gap. + +Net: acceptable for a one-line injection given the loud-failure behavior; not silently +broken in production if the anchor ever moves. + +## Files to change + +| File | Change | +|---|---| +| `packages/mobile-client/plugin/src/types.ts` | Add `ios.enableVoip?: boolean` to `FishjamPluginOptions['ios']`, gating the new mod (mirrors `android.enableVoip`) | +| `packages/mobile-client/plugin/src/withFishjamIos.ts` | Add `withFishjamVoipRegistration` mod (import `withAppDelegate` + `mergeContents`); wire it into `withFishjamIos`'s composition | +| `packages/mobile-client/README.md` | Document `ios.enableVoip`; add a bare-RN manual-line fallback section (mirrors the existing Android bare-RN section) | +| `examples/mobile-client/voip-call/app/ios/voipcall/AppDelegate.swift` | Once the plugin injects the call, remove the hand-added `VoipManager.registerForVoIPPushes()` at line 25 and set `ios.enableVoip: true` in the example's Expo config, proving the plugin works end-to-end | + +No changes needed in `packages/react-native-webrtc` — `VoipManager` already ships as-is. + +## Sketch + +```ts +// withFishjamIos.ts +import { withAppDelegate, type ConfigPlugin } from '@expo/config-plugins'; +import { mergeContents } from '@expo/config-plugins/build/utils/generateCode'; + +/** + * Registers PushKit VoIP at launch by injecting one call into the iOS AppDelegate. + * Opt in with `ios.enableVoip`. Uses tagged markers so repeated prebuilds replace + * (not duplicate) the block. + */ +const withFishjamVoipRegistration: ConfigPlugin = (config, props) => { + if (!props?.ios?.enableVoip) { + return config; + } + + return withAppDelegate(config, (configuration) => { + const { language, contents } = configuration.modResults; + + if (language !== 'swift') { + throw new Error( + `withFishjamVoipRegistration only supports a Swift AppDelegate, found "${language}". ` + + `Add "VoipManager.registerForVoIPPushes()" to didFinishLaunchingWithOptions manually.`, + ); + } + + if (contents.includes('VoipManager.registerForVoIPPushes()') && + !contents.includes('@generated begin fishjam-voip-register')) { + // Already present by hand (e.g. a consumer who added it before adopting the + // plugin). Don't double-inject. + return configuration; + } + + const merged = mergeContents({ + tag: 'fishjam-voip-register', + src: contents, + newSrc: ' VoipManager.registerForVoIPPushes()', + anchor: /bindReactNativeFactory\(factory\)/, + offset: 1, + comment: '//', + }); + + if (!merged.didMerge) { + throw new Error( + 'withFishjamVoipRegistration could not find the AppDelegate anchor ' + + '"bindReactNativeFactory(factory)". The Expo AppDelegate template may have changed — ' + + 'file an issue or add the call manually.', + ); + } + + configuration.modResults.contents = merged.contents; + return configuration; + }); +}; + +const withFishjamIos: ConfigPlugin = (config, props) => { + // ...existing screensharing + podfile + PiP + background mode + timeouts... + config = withFishjamVoipRegistration(config, props); // <-- add + return config; +}; +``` + +### Anchor choice + +`bindReactNativeFactory\(factory\)` — matches the example app's AppDelegate exactly, +inserts right after RN bootstrap. Alternative `didFinishLaunchingWithOptions` is more +template-agnostic but lands at the method signature, requiring a deeper offset into +the body — slightly more fragile. Go with `bindReactNativeFactory` first; fall back to +the method-signature anchor only if real-world Expo templates diverge from the example. + +## Open questions for whoever picks this up + +- Should `ios.enableVoip` also gate the existing `ios.enableVoIPBackgroundMode` Info.plist + flag (`withFishjamVoIPBackgroundMode`, `withFishjamIos.ts:298`), or stay independent? + Today they're separate flags; bundling might simplify the README but reduces flexibility + for consumers who want the background mode without PushKit registration (unlikely, but + worth deciding deliberately rather than accidentally). +- Confirm minimum supported Expo SDK for this package before deciding whether the + Swift-only guard needs an objc fallback (`AppDelegate.mm` regex path) or can just throw. diff --git a/examples/mobile-client/voip-call/KILLED-APP-DECLINE-PLAN.md b/examples/mobile-client/voip-call/KILLED-APP-DECLINE-PLAN.md new file mode 100644 index 000000000..311309d7e --- /dev/null +++ b/examples/mobile-client/voip-call/KILLED-APP-DECLINE-PLAN.md @@ -0,0 +1,258 @@ +# Plan: a decline reaches the caller even when the app is killed + +Expands [FEATURE-IDEAS.md](./FEATURE-IDEAS.md) item **#5 "Killed-app decline broadcast (Android)"**, +and widens it: the same class of bug exists on iOS, where expo-callkit-telecom has no answer either. + +Depends on item **#4** (`serverCallId` + opaque `metadata` in the push payload) — without a way to +identify the call and authenticate the decline with zero app-side state, none of this can talk to a +backend. + +--- + +## Problem + +The user's phone rings while our app is force-stopped / swiped away. They decline from the +system UI. The caller keeps ringing until their own 60-second outgoing timeout fires, because +nothing ever told the server the call was rejected. + +### Android: the event is guaranteed to be lost + +1. FCM data push → [`PushNotificationService.onMessageReceived`](../../../packages/react-native-webrtc/android/src/main/java/com/oney/WebRTCModule/voip/PushNotificationService.kt) → + `CallManager.reportIncomingCall` → Core-Telecom rings and `IncomingCallActivity` shows. All native, + no JS needed. Good. +2. `warmUpReact()` starts the React **context**, so the JS bundle loads and `WebRTCModule` is + constructed (which is what attaches `TelecomController` as the `CallEventsListener`). But nothing + mounts an RN **root view** — `IncomingCallActivity` is a plain `Activity`, not a `ReactActivity` — + so the app's component tree never renders and the `useVoIPEvents` call inside + [`VoipProvider.tsx:233`](./app/src/voip/VoipProvider.tsx) never subscribes. +3. User declines → Telecom `onDisconnect` → `CallManager` → `TelecomController.onEnded("rejected")` → + `webRTCModule.sendEvent("telecomActionPerformed", …)` → emitted into a live JS runtime with **zero + subscribers**. Silently dropped. + +So warm-up buys us a fast answer path (the answer launches the host activity, which mounts React); +it does nothing for a decline, where no activity is ever launched. This is the worst shape of bug: +everything looks healthy, the event just evaporates. + +### iOS: mostly works, with a real race + +PushKit launches the app process for a VoIP push, and RN builds and mounts the root view during +`didFinishLaunchingWithOptions` even for a background launch — so the React tree **does** mount and +`useVoIPEvents` **does** subscribe. Decline → +[`performEndCallAction`](../../../packages/react-native-webrtc/ios/RCTWebRTC/CallKitManager.m) → +`onCallEnded(@"local")` → JS hears it. Two holes remain: + +- **Cold-start race:** the JS bundle takes ~1–3 s. A decline inside that window hits an + `onCallEnded` block whose JS listener isn't subscribed yet. Dropped, no queue. +- **No channel to the server:** even when JS hears it, our only path to the backend is the signaling + WebSocket opened by the logged-in UI ([`useCallSignaling.ts:91`](./app/src/signaling/useCallSignaling.ts) + sends `call-rejected`). A cold-started, logged-out-looking process has no socket and no auth state, + and iOS suspends it seconds after the call ends. + +### The server is also blind + +`POST /call` in [`server/main.ts:165`](./server/main.ts) fires the push and forgets. The WebSocket is a +dumb relay: the caller only learns of a rejection because the **callee's** app sends `call-rejected` +over its own socket. No server-side call state ⇒ no server-side fallback. Fixing the client without +giving the server a decline endpoint fixes nothing. + +--- + +## How expo-callkit-telecom does it + +### Android: broadcast the events that JS can never receive + +[`CallEventEmitter.kt`](~/Desktop/expo-callkit-telecom/android/src/main/java/expo/modules/callkittelecom/events/CallEventEmitter.kt) +is a single chokepoint for every native→JS event, and it has three tiers: + +1. JS is observing this event → send it. +2. JS is not observing, but the event has a queue limit > 0 → queue it, flush on `startObserving` + with `meta: { flushed: true }`. +3. JS is not observing **and** the queue limit is `0` → the event is **dropped** and will never reach + JS. Exactly these events get a package-internal broadcast instead. + +Terminal events (`onCallEnded`, `onCallReportedEnded`) sit in tier 3 deliberately: replaying a stale +"call ended" on the next app launch is useless, so they're never queued — which is precisely what +makes them broadcastable without risking double-delivery. + +The broadcast is `Intent(ACTION_CALL_EVENT).setPackage(pkg)` with two extras: `eventName`, and +`payload`, a JSON string of the exact body JS would have received. For terminal events that body +embeds the **full session**, including `serverCallId` and the push `metadata` — so the receiver has +the backend ids and any auth token with **zero app-side state**. Failures are warn-logged and +swallowed; a dead broadcast must never break event emission. + +The host app registers a manifest `` whose class name comes from the config-plugin prop +`androidEventReceiver`. Their example +([`CallEndedReceiver.kt`](~/Desktop/expo-callkit-telecom/example/client/plugins/CallEndedReceiver.kt)) +filters `eventName == "onCallEnded"`, digs out `session.incomingCallEvent.serverCallId` + `metadata`, +and calls `goAsync()` so the (possibly freshly cold-started) process survives past `onReceive` while +it does its work. Their test-push script demos the auth story directly: +`--metadata '{"declineToken":"abc"}'`. + +Documented in their [`docs/platform-notes.md`](~/Desktop/expo-callkit-telecom/docs/platform-notes.md) +under "Call ended while the app is killed". + +### iOS: they don't solve it + +`onCallEnded` is queue-limit `0` on iOS too +([`AppDelegateSubscriber.swift:19-25`](~/Desktop/expo-callkit-telecom/ios/AppDelegateSubscriber.swift) only +raises limits for `CallIntentReceived`, `AudioSessionActivated/Deactivated`, `IncomingCallReported`, +`CallAnswered`, `VoIPPushTokenUpdated`), and there is no broadcast path on iOS. They rely on PushKit +having launched the app so JS is up in time. The fast-decline race is unhandled — same as ours. + +--- + +## Design for our repo + +Guiding constraint: **the SDK must not do networking.** It doesn't know the backend, and taking a URL +out of a push payload and POSTing to it would be an SSRF footgun. The SDK's job is to surface the +event to app-owned native code that can run without JS; the app does the HTTP call. + +Three layers. Layer 0 is a hard prerequisite; layers 1 and 2 are independent of each other. + +### Layer 0 — give the decline something to say (needs #4) + +- **Push payload** (#4): server mints `serverCallId` and a short-lived `declineToken` at + `POST /call` time and puts both in the FCM data / APNs payload under the opaque `metadata`. Native + keeps them on the call so they're readable with no JS and no app state. +- **Server:** new `POST /call/:serverCallId/decline` (bearer `declineToken`), which looks up the + caller and pushes `call-rejected` down the **caller's** socket. The server, not the callee's + process, becomes the thing that notifies the caller. Make it **idempotent on `serverCallId`** — + layers 1/2 can race with a live JS handler. +- **Server-side safety net (recommended anyway):** once the server knows a call exists, it can also + expire it on its own. That is the real backstop; the decline path just makes it _instant_ instead + of _eventually_. + +### Layer 1 (Android) — broadcast terminal events that no JS listener can hear + +The decision has to be "**is a JS listener subscribed?**", not "is React alive?" — our warm-up makes +React alive while the tree is unmounted, which is exactly the case that currently loses the event. +`WebRTCModule.sendEvent` already bails on `!ctx.hasActiveReactInstance()` +([`WebRTCModule.java:182-184`](../../../packages/react-native-webrtc/android/src/main/java/com/oney/WebRTCModule/WebRTCModule.java)), +which is necessary but not sufficient. + +Luckily the hook is already there and empty: RN requires `addListener(String)` / +`removeListeners(Integer)` on any `NativeEventEmitter` module, and ours are no-op stubs at +[`WebRTCModule.java:1687`](../../../packages/react-native-webrtc/android/src/main/java/com/oney/WebRTCModule/WebRTCModule.java) +and `:1702`. Make them maintain a per-event subscriber count. + +1. **Subscriber counting** — `addListener` increments, `removeListeners(count)` decrements. RN's + `removeListeners` only passes a _count_, not the event name, so keep a total-per-emitter counter + plus a per-event map populated in `addListener`; on `removeListeners` decrement the total and, if + it hits 0, clear the map. Coarse but sufficient: the only question we ask is "could anyone hear a + `telecomActionPerformed`?" +2. **New `voip/CallEventBroadcaster.kt`** — `broadcast(context, eventName, payloadJson)` building + `Intent(ACTION_CALL_EVENT).setPackage(pkg)` + `eventName`/`payload` extras. Swallow + log failures. + Action constant: `com.oney.WebRTCModule.ACTION_CALL_EVENT`. +3. **Wire it in `TelecomController.onEnded` / `onFailed` only.** Terminal events are the only ones + worth broadcasting: `started`/`answer`/`muteChanged`/`holdChanged` are meaningless to a JS-less + process (and `answer` already launches the host app, which mounts React). If there's no live + subscriber → broadcast instead of `sendEvent`. Never both. +4. **Payload:** `{ event: "ended", reason, roomName, displayName, serverCallId, metadata }` — same + shape JS gets, plus the #4 fields, so the receiver needs no state. +5. **Config plugin:** new `androidEventReceiver` prop in + [`plugin/src/types.ts`](../../../packages/mobile-client/plugin/src/types.ts) → + `withFishjamVoipAndroid` writes + ``. +6. **Example app:** ship `plugins/CallEndedReceiver.kt` + a `withCallEndedReceiver` plugin that copies + it into the generated project. In `onReceive`: filter `reason == "rejected"` (and `"missed"`, so the + caller stops ringing on a timeout too), then **enqueue a `WorkManager` job** rather than POSTing + inline. A `BroadcastReceiver` — even with `goAsync()` — only gets ~10 s and no retry; a + killed-app decline is exactly when the network is likeliest to be cold. WorkManager gives us + retry/backoff for free and survives the process dying again. (Their example POSTs inline from + `goAsync()`; fine for a demo, not for "almost production-ready".) + +**Rejected alternative — HeadlessJS.** `HeadlessJsTaskService` would let the decline be handled in TS +instead of Kotlin. Rejected: a much heavier runtime dependency for one HTTP POST, it must start +within the FCM/foreground-service start window, and its standing on bridgeless/new-arch needs +verifying (see Open questions). Not worth it when the receiver is ~30 lines. + +### Layer 2 (iOS) — close the cold-start race, symmetrically + +Queue-and-replay is the wrong tool: replaying a "call ended" on the next launch tells the app +something it can no longer act on. Mirror the Android design instead — surface the event to +app-owned native code when JS can't hear it. + +`WebRTCModule` is an `RCTEventEmitter` +([`WebRTCModule.h:34`](../../../packages/react-native-webrtc/ios/RCTWebRTC/WebRTCModule.h)), so we get +subscriber tracking **for free**: `RCTEventEmitter` flips `self.hasListeners` in +`startObserving`/`stopObserving`. No new plumbing. + +1. In the `onCallEnded` path, if `!self.hasListeners` → post an + `NSNotification` (`FishjamVoipCallEventUndelivered`) carrying the same dictionary, instead of the + JS event. The app observes it from its `AppDelegate` (or a small local module) and does the POST. +2. Wrap it in + [`beginBackgroundTaskWithExpirationHandler`]() + before posting and end it when the app calls a new `voipEventHandled()` bridge method (or after a + short deadline) — otherwise iOS suspends the process the moment the call tears down and the + request never leaves the device. +3. Same for the **live-JS** path, honestly: JS `fetch()` after a decline in a background-launched app + is equally racing suspension. The background-task wrapper should cover both — begin it on + `onCallEnded` regardless, end it when the app confirms. + +This gives one conceptual shape on both platforms: _terminal event + no JS listener → hand it to +app-owned native code + keep the process alive long enough to deliver._ + +--- + +## What the user actually sees, after + +Killed app, decline on the lock screen → receiver/notification fires within ~100 ms → app POSTs the +decline (token from the push) → server pushes `call-rejected` to the caller's socket → the caller's +ring stops immediately instead of after 60 s. + +--- + +## Implementation checklist + +**Prerequisite (#4)** + +- [ ] `serverCallId` + opaque `metadata` in the push payload, kept on the native call +- [ ] Server: mint `serverCallId` + `declineToken` in `POST /call` +- [ ] Server: `POST /call/:serverCallId/decline`, idempotent, notifies the caller's socket + +**Android** + +- [ ] Subscriber counting in `WebRTCModule.addListener` / `removeListeners` +- [ ] `voip/CallEventBroadcaster.kt` + `ACTION_CALL_EVENT` +- [ ] `TelecomController.onEnded` / `onFailed`: broadcast when no subscriber, else `sendEvent` (never both) +- [ ] Config plugin: `androidEventReceiver` prop → manifest `` +- [ ] Example: `CallEndedReceiver.kt` → WorkManager job → decline POST + +**iOS** + +- [ ] `FishjamVoipCallEventUndelivered` notification when `!self.hasListeners` +- [ ] Background task around the `onCallEnded` path + `voipEventHandled()` bridge method +- [ ] Example: `AppDelegate` observer → decline POST + +**Docs** + +- [ ] README: killed-app decline section; push-payload fields; the `androidEventReceiver` prop + +## Verification + +- **Android, force-stopped:** `adb shell am force-stop `, send a push, decline from the + full-screen activity **and** (separately) from the notification. Watch + `adb logcat -s CallEndedReceiver` + the server log. Repeat with the app swiped from Recents and + with the screen locked. +- **iOS, killed:** kill from the app switcher, push, decline from the CallKit lock-screen UI. Confirm + the server sees the decline. Then the race: decline **within ~1 s** of the ring starting — this is + the case that fails today. +- **Regression (both):** app in the foreground → decline must go through JS only. The server must see + exactly **one** decline, not two. Assert on the server, not just the client. +- **Missed call:** let it ring out to the 45 s native timeout with the app killed — the caller should + also stop ringing (this is why the receiver handles `missed`, not just `rejected`). + +## Open questions + +- **Does `removeListeners(count)` give us enough to avoid a stuck non-zero count** across an RN + reload? If the count leaks upward we'd stop broadcasting (we'd think JS is listening). Consider + resetting it in `TelecomController.detach()` / on catalyst-instance destroy. +- **HeadlessJS on new arch / bridgeless** — viable or not? Only matters if we ever want the decline + handled in TS. +- **Does the broadcast survive Doze / battery restrictions** when the process was started by a + high-priority FCM push? Expected yes (same process, delivered right after `onDisconnect`, and the + FCM start exemption is still in effect), but it needs a real device test on a locked, dozing phone. +- **Should the server own call lifecycle entirely** (FCE-3435 asks for "make the server aware of the + call")? If it does, a server-side ring timeout is a strictly better backstop, and the client-side + decline becomes a latency optimization rather than the only mechanism. Worth deciding before + building layer 1. diff --git a/examples/mobile-client/voip-call/OUTGOING-CONNECT-PLAN.md b/examples/mobile-client/voip-call/OUTGOING-CONNECT-PLAN.md new file mode 100644 index 000000000..b3b15ff31 --- /dev/null +++ b/examples/mobile-client/voip-call/OUTGOING-CONNECT-PLAN.md @@ -0,0 +1,447 @@ +# Plan: real outgoing-call connection reporting + +> Companion to [FEATURE-IDEAS.md](./FEATURE-IDEAS.md) item #2 (P0 🔴), ported from +> `~/Desktop/expo-callkit-telecom` (source verified 2026-07-10) and mapped onto our +> ObjC/Kotlin/Java code. +> +> **The dialtone is explicitly out of scope** — see [Deliberately omitted](#deliberately-omitted-the-dialtone) +> for what that costs and where it would hook in later. +> +> Sibling plan: [ANSWER-HANDSHAKE-PLAN.md](./ANSWER-HANDSHAKE-PLAN.md) (item #1). The two +> converge on a shared "mark the call connected" path — read the [Symmetry](#symmetry-with-the-answer-handshake-1) +> section before implementing either in isolation. + +## Problem + +### iOS: the timer starts when we dial, not when they answer + +```objc +// packages/react-native-webrtc/ios/RCTWebRTC/CallKitManager.m:80-81 +[weakSelf.provider reportOutgoingCallWithUUID:uuid startedConnectingAtDate:[NSDate date]]; +[weakSelf.provider reportOutgoingCallWithUUID:uuid connectedAtDate:[NSDate date]]; +``` + +Both reports fire back-to-back inside the `requestTransaction` completion — i.e. the instant +CallKit *accepts* the call, not when the remote party picks up. +[`reportOutgoingCall(with:connectedAt:)`](https://developer.apple.com/documentation/callkit/cxprovider/reportoutgoingcall(with:connectedat:)) +is what starts the system call timer, so the dynamic island / status bar / lock screen counts +from dial time. A 20-second ring becomes 20 seconds of phantom call duration. + +Separately, `provider:performStartCallAction:` (`CallKitManager.m:148-150`) is a bare +`[action fulfill]`. The `startedConnecting` report belongs *there* — that is where Apple's own +sample code and the reference put it. + +### Android: Telecom is honest, the notification is not + +Our Core-Telecom state is already close to correct. `setActive()` is only sent when JS calls +`setTelecomCallActive()` (`voip/CallManager.kt:84`), which the example does from its +`remotePeers.length > 0` effect. Nothing marks the call active at dial time. + +The lie is in the notification. `register()` posts the ongoing notification immediately for +outgoing calls: + +```kotlin +// voip/CallManager.kt:172-173 +if (isIncoming) callNotificationManager.showIncoming(...) +else showOngoingNotification() // ← at dial time +``` + +`showOngoingNotification()` → `ForegroundServiceController.onCallStarted(...)`, which sets +`callConnectedAtMs = System.currentTimeMillis()` +(`foregroundService/ForegroundServiceController.java:123-128`). That timestamp reaches +`CallNotificationManager.ongoingBuilder`, which does +`.setUsesChronometer(true).setWhen(connectedAtMs)` (`voip/CallNotificationManager.kt:137-138`). +So the shade shows a running call timer and the text "Ongoing call" while the callee's phone is +still ringing. + +So: **iOS reports connected too early to CallKit; Android renders connected too early in the +notification.** Same bug, two surfaces, two different fixes. + +## How expo-callkit-telecom does it + +### iOS + +`startedConnecting` is reported from the delegate, and the timeout starts there too +(`CallManager+CXProviderDelegate.swift:30-54`): + +```swift +func provider(_ provider: CXProvider, perform action: CXStartCallAction) { + provider.reportOutgoingCall(with: action.callUUID, startedConnectingAt: Date()) + startCallTimeout(for: action.callUUID, timeout: Self.outgoingCallTimeout) + Task { + await store.updateStatus(for: action.callUUID, status: .connecting) + await MainActor.run { CallEventEmitter.shared.send(OutgoingCallStartedEvent(id: action.callUUID)) } + } + action.fulfill() +} +``` + +`connectedAt` is reported only when the app asks (`CallManager.swift:502-520`): + +```swift +func reportOutgoingCallConnected(for id: UUID) async { + DialtonePlayer.shared.stop() + cancelCallTimeout(for: id) + let now = Date() + provider.reportOutgoingCall(with: id, connectedAt: now) + await store.update(for: id) { $0.status = .connected; $0.connectedAt = now } +} +``` + +### Android + +`startOutgoingCall` posts a **dialing** notification inside the `addCall` scope +(`CallManager.kt:275-296`): status `CONNECTING`, `CallNotificationManager.showDialingCall(...)`, +speaker endpoint for video, `DialtonePlayer.play(context)`, emit `OUTGOING_CALL_STARTED`, then +`startCallTimeout(id, outgoingCallTimeoutMs)`. No `setActive()`. + +`reportOutgoingCallConnected(id)` (`CallManager.kt:437-451`) is what promotes it: stop dialtone, +cancel timeout, status `CONNECTED` + `connectedAt`, `actions.setActive.trySend(Unit)`, and +`showOngoingCall(context, id, callerName, now.toEpochMilli())` — the chronometer notification, +posted with the *real* connect timestamp. + +Note the Android `reportOutgoingCallConnected` body is **line-for-line the same shape** as their +`fulfillIncomingCallConnected` (`CallManager.kt:417-433`): `setActive` + swap to the ongoing +notification. That is the symmetry our plan should preserve. + +The JS surface is a single `reportOutgoingCallConnected(id)` (`src/Calls.ts:580-582`) that hits +the identically-named native function on both platforms. + +## Design for our repo + +We are single-call, so no id parameter is needed — `reportOutgoingCallConnected()` operates on +`currentCallUUID` (iOS) / the one active `CallManager` call (Android), exactly as +`endCallKitSession()` / `endTelecomCall()` already do. + +### JS API (new) + +In `packages/react-native-webrtc/src/VoIP.ts`, re-exported from `src/index.ts` and +`packages/mobile-client/src/index.ts`: + +```ts +/** + * Reports that an outgoing call's media is connected — the remote party answered. + * Until this is called, the OS shows the call as "Calling…" / "Dialing…" and no + * call timer runs. No-op for incoming calls. + */ +export function reportOutgoingCallConnected(): Promise; +``` + +`setTelecomCallActive()` / `useTelecom().setCallActive` become an implementation detail of this +function. Both are currently public (`src/Telecom.ts:32-37`, re-exported via +`packages/mobile-client/src/index.ts`), so **deprecate rather than delete**: keep the export, +have it delegate to the new native path, and mark it `@deprecated — use reportOutgoingCallConnected()`. +It is Android-only and no-ops on iOS today, which is precisely the platform seam this change +removes. + +### iOS implementation + +**Track the direction.** `CallKitManager` sets `currentCallUUID` from *both* +`startCallWithDisplayName:` (line 53) and `reportIncomingCallWithDisplayName:` (line 86), and +nothing distinguishes them. Calling `reportOutgoingCall(with:connectedAt:)` on an incoming call +is CallKit misuse, so add the flag first: + +```objc +// CallKitManager.h — internal state, exposed read-only for the guard +@property(nonatomic, readonly) BOOL isOutgoingCall; +- (void)reportOutgoingCallConnected; +``` + +Set `_isOutgoingCall = YES` in `startCallWithDisplayName:`, `NO` in +`reportIncomingCallWithDisplayName:`, and `NO` in `cleanup` (alongside `isCallAnswered`). + +**Move `startedConnecting` into the delegate** (`CallKitManager.m:148-150`): + +```objc +- (void)provider:(CXProvider *)provider performStartCallAction:(CXStartCallAction *)action { + [provider reportOutgoingCallWithUUID:action.callUUID startedConnectingAtDate:[NSDate date]]; + [action fulfill]; +} +``` + +**Delete line 81** (the premature `connectedAtDate:`) and line 80 (now redundant — it moved into +the delegate). The completion block keeps only its error handling and `onCallStarted()`. + +**Add the reporter:** + +```objc +- (void)reportOutgoingCallConnected { + NSUUID *uuid = self.currentCallUUID; + if (uuid == nil || !self.isOutgoingCall) { + NSLog(@"[CallKitManager] No outgoing call to report as connected"); + return; + } + [self.provider reportOutgoingCallWithUUID:uuid connectedAtDate:[NSDate date]]; +} +``` + +**Bridge** (`WebRTCModule+CallKit.m`, next to `endCallKitSession`): + +```objc +RCT_EXPORT_METHOD(reportOutgoingCallConnected:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +``` + +**Ordering constraint.** CallKit ignores a `connectedAt` report for a call it has not seen +`startedConnectingAt` for, and ignores both before the `CXStartCallAction` is fulfilled. Our JS +flow is safe by construction: `VoipProvider.startCall()` awaits `startCallKitSession(to)` — whose +promise resolves in the transaction completion, i.e. *after* the delegate ran and fulfilled — and +only then joins the room. The `currentCallUUID == nil` guard covers the rest. + +### Android implementation + +**Track the direction.** `CallManager.register()` already receives `direction`; store it: +`@Volatile private var isOutgoing = false`, set from +`direction == CallAttributesCompat.DIRECTION_OUTGOING`, cleared in the `finally` block (line 200) +next to `hasActiveCall = false`. + +**Dial with a connecting notification** (`CallManager.kt:172-173`): + +```kotlin +if (isIncoming) callNotificationManager.showIncoming(ctx.applicationContext, displayName, isVideo) +else showConnectingNotification() // "Dialing…", no chronometer +``` + +**Promote on connect** — one shared private helper, because this is the exact body that +[ANSWER-HANDSHAKE-PLAN.md](./ANSWER-HANDSHAKE-PLAN.md)'s `fulfillAnswered()` also needs: + +```kotlin +/** Telecom goes active and the shade switches to the chronometer notification. */ +private fun markConnected() { + setCallActive() // actions.trySend(CallAction.Activate) + showOngoingNotification() // ForegroundServiceController.onCallConnected() +} + +fun reportOutgoingCallConnected() { + if (!hasActiveCall || !isOutgoing) return + markConnected() +} +``` + +**The foreground service must learn a second state.** `ForegroundServiceController.onCallStarted` +conflates "a call exists" with "the call connected at *now*" (it sets `callConnectedAtMs` in the +same breath, line 126). Split it: + +```java +// ForegroundServiceController.java +public synchronized void onCallStarted(String displayName, boolean isVideo) { + callActive = true; + callDisplayName = displayName != null ? displayName : ""; + callIsVideo = isVideo; + callConnectedAtMs = 0L; // ← 0 == not connected yet + applyState(); +} + +public synchronized void onCallConnected() { + if (!callActive) return; + callConnectedAtMs = System.currentTimeMillis(); + applyState(); +} +``` + +`applyState()` already re-issues `startService(...)` with fresh extras, and +`WebRTCForegroundService.onStartCommand` re-`startForeground()`s with a rebuilt notification, so +this is an in-place notification update with no new plumbing. The FGS type set +(`microphone`, plus `camera` for video) is unchanged across the transition — the service starts +at dial time and simply swaps its notification, which keeps us clear of +[foreground-service background-start restrictions](https://developer.android.com/develop/background-work/services/fgs/restrictions-bg-start). + +`WebRTCForegroundService.java:58-62` then branches on the sentinel: + +```java +long connectedAt = intent.getLongExtra("voipConnectedAt", 0L); +Notification notification = connectedAt > 0 + ? callNotificationManager.buildOngoing(this, voipDisplayName, connectedAt) + : callNotificationManager.buildConnecting(this, voipDisplayName, /* isOutgoing */ ...); +``` + +**`CallNotificationManager.buildConnecting(...)`** reuses `callNotificationBuilder(ctx, +CHANNEL_ONGOING, displayName, text)` with `text = "Dialing…"` and +[`CallStyle.forOngoingCall(person, hangupPendingIntent)`](https://developer.android.com/reference/androidx/core/app/NotificationCompat.CallStyle#forOngoingCall(androidx.core.app.Person,android.app.PendingIntent)) +— there is no dedicated "dialing" CallStyle; `forOngoingCall` is the correct one, it just gets a +hangup action and **no** `setUsesChronometer(true)` / `setWhen(...)`. Omitting the chronometer is +the entire fix. (Item #12 wants "Dialing…" for outgoing and "Connecting…" for a freshly answered +incoming call; pass the string in so both plans share this builder.) + +**Bridge**: `WebRTCModule.java` gains `reportOutgoingCallConnected(Promise)` next to the existing +telecom block (lines 1707-1733), routed through `TelecomController.reportOutgoingCallConnected()` +with the usual `Build.VERSION.SDK_INT >= O` guard. + +**Side effect worth noting:** `onSetActive = { answered = true }` (`CallManager.kt:168`) means the +Android `answered` flag now flips at real connect time for outgoing calls rather than whenever JS +happened to call `setTelecomCallActive()`. That is strictly more correct and matches the flag's +`isTelecomCallAnswered()` contract. + +### Symmetry with the answer handshake (#1) + +After both plans land, "the call is really up" has exactly one meaning per platform and one +entry point per direction: + +| | incoming | outgoing | +| --- | --- | --- | +| JS calls | `fulfillIncomingCallConnected(requestId)` | `reportOutgoingCallConnected()` | +| iOS does | `action.fulfill()` on the parked `CXAnswerCallAction` | `reportOutgoingCall(with:connectedAt:)` | +| Android does | `markConnected()` | `markConnected()` | + +Land #1 first if you are doing both: it introduces `showConnectingNotification()` and the +`markConnected()` split, and this plan then reduces to the iOS reporting fix plus the `isOutgoing` +guard. Landing #2 first is also fine — it introduces the same two helpers — but do not build them +twice. + +### Example app (`app/src/voip/VoipProvider.tsx`) + +The `remotePeers` effect (lines 184-201) is already the "media is live" signal, and for an +outgoing call `remotePeers.length > 0` means precisely *the callee joined the room* — the answer +event we never had. It becomes direction-aware and stops being platform-conditional: + +```tsx +if (status === 'connecting' && remotePeers.length > 0) { + const call = currentCallRef.current; + if (call?.isOutgoing) { + await reportOutgoingCallConnected(); + } else if (pendingRequestIdRef.current) { // from ANSWER-HANDSHAKE-PLAN + const connected = await fulfillIncomingCallConnected(pendingRequestIdRef.current); + pendingRequestIdRef.current = null; + if (!connected) { await endCall(); return; } + } + setStatus('active'); + // ...startedAt bookkeeping unchanged — it now agrees with the OS timer +} +``` + +The Android-only `setTelecomCallActive()` call goes away. `currentCall.isOutgoing` already exists +(`VoipContext.ts`, set in `startCall`), and `OutgoingCallScreen` already renders for +`status === 'connecting'`, so the app-side "ringing" UI needs no work. + +`startedAt` is currently stamped in this effect and drives the in-app call duration; it now lines +up with the OS timer to within a render, instead of trailing it by the whole ring duration. + +## Deliberately omitted: the dialtone + +Per the request, no `DialtonePlayer`. What that costs and where it would go: + +- **Cost:** during `status === 'connecting'` on an outgoing call the earpiece is silent. The user + gets no audible confirmation that the callee's phone is ringing — only `OutgoingCallScreen`. + This makes the in-app ringing UI load-bearing rather than decorative, and it makes the missing + outgoing timeout (below) more noticeable, since silence and "callee never picked up" are + indistinguishable to the ear. +- **Where it would hook, if added later:** iOS from `provider:didActivateAudioSession:` + (`CallKitManager.m:186-188`) gated on `isOutgoingCall && !isConnected` — the reference gates on + exactly that predicate (`session.origin == .outgoingApp && session.status == .connecting`) — + and stopped in `reportOutgoingCallConnected`, `endCall`, and the timeout handler. Android from + the `addCall` scope at dial time, stopped in `markConnected()` and the `finally` block. +- The `isOutgoingCall` flag and the connecting/connected split this plan introduces **are** that + predicate. Nothing here needs redoing to add sound later; it is a `play()`/`stop()` pair at four + call sites. + +## Regression risk: an unanswered outgoing call now hangs forever + +Read this before shipping. Today the instant `connectedAt` report at least made the system UI +settle into a stable (wrong) state. After this change, an outgoing call that is never answered +displays "Calling…" indefinitely: CallKit will not time it out, and our Android side has no timer +either. + +The example app doesn't save us. Its only teardown paths are `status === 'active' && remotePeers.length === 0` +(never reached, since we never became active) and the user hanging up. + +The reference handles this with **FEATURE-IDEAS item #3** — a 60 s outgoing timeout started in +`performStartCallAction` / the `addCall` scope, ending the call with reason `unanswered`. That +item is a much smaller job than #3's full three-timeout story: this plan only needs the outgoing +one, and it needs nothing from the dialtone. + +**Recommendation: land [CALL-TIMEOUTS-PLAN.md](./CALL-TIMEOUTS-PLAN.md) Steps 1–5 first, then +ship this plan together with its Step 6.** That plan (written after this one) carries the full +timeout design — cancellable `dispatch_block_create` timer with a wall clock on iOS, coroutine +`Job` on Android, `FishjamVoip*Timeout` config keys (same key name in Info.plist *and* manifest +meta-data), default 60 s — and its Step 6 is exactly the iOS outgoing timer this section needs: +start in `provider:performStartCallAction:`, cancel in `reportOutgoingCallConnected` + `cleanup`. +With Steps 1–5 already merged, Step 6 is a ~15-line addition to this PR because the timer +infrastructure, config reads, and the Android outgoing timeout all exist. + +Two corrections to what this section previously claimed: +- No dependency on item #6 for the *unanswered* label: `endCallWithReason:@"missed"` already maps + to `CXCallEndedReasonUnanswered` (`CallKitManager.m:123-124`), so expiry via the existing + end-call path labels the call correctly today. +- The config reader helpers come from CALL-TIMEOUTS-PLAN Step 3, not ANSWER-HANDSHAKE-PLAN (the + handshake shipped with a hardcoded 10 s deadline and no readers). + +If you nevertheless ship #2 without any timeout, say so in the example's README and file #3 as a +follow-up — do not leave it undiscovered. + +## Scope note: Recents + +FEATURE-IDEAS #2 cites "correct call duration in the dynamic island / status bar / Recents". +Recents is **not** a benefit you will observe from this change alone: we set +`providerConfiguration.includesCallsInRecents = NO` (`CallKitManager.m:33`), so our calls never +reach the Recents list. Flipping that flag is item #11. The immediately visible wins here are the +dynamic island, the status-bar pill, the lock-screen call UI, and the Android shade. + +## Implementation checklist + +- [ ] `ios/RCTWebRTC/CallKitManager.h` — `isOutgoingCall` readonly property; + `- (void)reportOutgoingCallConnected;` +- [ ] `ios/RCTWebRTC/CallKitManager.m` — set/clear `_isOutgoingCall` in + `startCallWithDisplayName:` / `reportIncomingCallWithDisplayName:` / `cleanup`; move + `startedConnectingAtDate:` into `provider:performStartCallAction:`; **delete both report + lines from the transaction completion (80-81)**; add `reportOutgoingCallConnected` with the + `currentCallUUID` + `isOutgoingCall` guard +- [ ] `ios/RCTWebRTC/WebRTCModule+CallKit.m` — `RCT_EXPORT_METHOD(reportOutgoingCallConnected:…)` +- [ ] `android/.../voip/CallManager.kt` — `isOutgoing` flag (set in `register`, cleared in + `finally`); dial posts `showConnectingNotification()`; `markConnected()` helper; + `reportOutgoingCallConnected()` guarded on `hasActiveCall && isOutgoing` +- [ ] `android/.../voip/CallNotificationManager.kt` — `buildConnecting(ctx, displayName, text)`: + `CallStyle.forOngoingCall` + hangup action, **no** `setUsesChronometer`/`setWhen` +- [ ] `android/.../foregroundService/ForegroundServiceController.java` — `onCallStarted` sets + `callConnectedAtMs = 0`; new `onCallConnected()` stamps it and re-applies state +- [ ] `android/.../foregroundService/WebRTCForegroundService.java` — branch on + `voipConnectedAt > 0` → `buildOngoing` else `buildConnecting` +- [ ] `android/.../TelecomController.java` + `WebRTCModule.java` — + `reportOutgoingCallConnected(Promise)` with the API-26 guard +- [ ] `src/Telecom.ts` — `reportOutgoingCallConnected()` wrapper; mark `setTelecomCallActive` + `@deprecated` and delegate to it +- [ ] `src/useTelecom.ts` — same deprecation note on `setCallActive` +- [ ] `src/VoIP.ts` — cross-platform `reportOutgoingCallConnected()` +- [ ] `src/index.ts` + `packages/mobile-client/src/index.ts` — re-export +- [ ] `examples/.../app/src/voip/VoipProvider.tsx` — direction-aware `remotePeers` effect; drop + the Android-only `setTelecomCallActive()` +- [ ] `examples/mobile-client/voip-call/README.md` — document the outgoing lifecycle + (`startCallKitSession` → "Calling…" → `reportOutgoingCallConnected()` → timer starts) +- [ ] **Strongly recommended, same PR:** outgoing timeout (see the regression-risk section) +- [ ] `FEATURE-IDEAS.md` #2 — link here; mark done when it lands + +### Verification + +- Compile: `JAVA_HOME=~/.sdkman/candidates/java/17.0.18-zulu ./gradlew + :fishjam-cloud_react-native-webrtc:compileDebugKotlin` (per project memory — the default sdkman + JDK 26 breaks gradle), plus an iOS build of `examples/mobile-client/voip-call/app`. +- Device checks, two phones, both platforms as caller: + 1. **The headline** — call B from A, let it ring ~15 s, then answer. A's dynamic island / status + bar timer must read ~0:00 at the moment B answers, not ~0:15. Compare against the in-app + `startedAt` duration on `InCallScreen`; they should agree. This is the whole point of the + change and it is only observable on a real device. + 2. **Android shade** — while ringing, pull down the shade: "Dialing…", no running timer, hangup + button works. After B answers: chronometer starts from 0. + 3. **Declined / never answered** — confirm the caller's UI never shows a running timer, and + (if the timeout ships) that the call self-terminates at ~60 s. + 4. **Caller hangs up while ringing** — exactly one `ended` event; no chronometer ever appeared; + FGS stops. + 5. **Incoming calls unaffected** — `reportOutgoingCallConnected()` must be a no-op; verify the + `isOutgoingCall` / `isOutgoing` guards by calling it from JS during an incoming call and + confirming nothing changes. + 6. **Android FGS types** — video outgoing call: confirm `camera` + `microphone` types are held + across the dialing → connected notification swap (`adb shell dumpsys activity services`), + i.e. the swap does not restart the service into a narrower type set. + +## Open questions + +- **Does `reportOutgoingCall(with:connectedAt:)` accept a backdated `Date`?** It does for + `startedConnectingAt`. If it does for `connectedAt` too, the connect timestamp could come from + the peer-joined event rather than from when the JS round-trip completes, shaving the bridge + latency off the reported duration. Worth measuring; not required for correctness. +- **Should `reportOutgoingCallConnected()` reject instead of silently no-op'ing** when there is no + outgoing call? Silent no-op matches `endCallKitSession()`'s existing behaviour + (`CallKitManager.m:114-117` just logs), so this plan keeps it consistent. Revisit if #14's call + session model gives JS a way to know the direction before calling. +- **Android "Dialing…" copy** is hardcoded English, like everything else in + `CallNotificationManager`. Item #12 flags i18n for the whole file; don't solve it here, but put + the string next to the others so there is one place to fix. From 118ba7e80df427c4767ec805d24a1bad6ea96be1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gadomski?= Date: Thu, 16 Jul 2026 16:31:28 +0200 Subject: [PATCH 42/52] Format + Lint --- .../voip-call/app/src/signaling/useCallSignaling.ts | 2 +- packages/react-native-webrtc | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/mobile-client/voip-call/app/src/signaling/useCallSignaling.ts b/examples/mobile-client/voip-call/app/src/signaling/useCallSignaling.ts index 36c88382f..6ade8d0f1 100644 --- a/examples/mobile-client/voip-call/app/src/signaling/useCallSignaling.ts +++ b/examples/mobile-client/voip-call/app/src/signaling/useCallSignaling.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, type MutableRefObject } from 'react'; -import { type CurrentCall, type VoipCallStatus, useVoip } from '../voip'; +import { useVoip, type CurrentCall, type VoipCallStatus } from '../voip'; type Params = { serverUrl: string; diff --git a/packages/react-native-webrtc b/packages/react-native-webrtc index 6fe374721..70bcdcbeb 160000 --- a/packages/react-native-webrtc +++ b/packages/react-native-webrtc @@ -1 +1 @@ -Subproject commit 6fe374721e1850684afe4a72b2328e2ca08ca339 +Subproject commit 70bcdcbeb517b6f9c0079aae356513895ff5a3d4 From 72f4a08578a145f49dbb923073572c48ce6f40be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gadomski?= Date: Thu, 16 Jul 2026 16:38:43 +0200 Subject: [PATCH 43/52] bump --- packages/react-native-webrtc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native-webrtc b/packages/react-native-webrtc index 70bcdcbeb..ae4db4f35 160000 --- a/packages/react-native-webrtc +++ b/packages/react-native-webrtc @@ -1 +1 @@ -Subproject commit 70bcdcbeb517b6f9c0079aae356513895ff5a3d4 +Subproject commit ae4db4f3562c09b7c23aaf8703dae3490ee36cee From b0ac87f54a472f343b6bc32d4ee4ff27ad61d845 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Thu, 16 Jul 2026 19:23:41 +0200 Subject: [PATCH 44/52] Remove plan/research docs from repo Co-Authored-By: Claude Fable 5 --- .../voip-call/ANSWER-HANDSHAKE-PLAN.md | 542 ------------------ .../voip-call/CALL-TIMEOUTS-PLAN.md | 525 ----------------- .../voip-call/CALL-TIMEOUTS-RESEARCH.md | 280 --------- .../voip-call/FCM-COEXISTENCE-PLAN.md | 215 ------- .../mobile-client/voip-call/FEATURE-IDEAS.md | 329 ----------- .../voip-call/HOLD-MULTICALL-RECENTS-PLAN.md | 482 ---------------- .../IOS-VOIP-REGISTRATION-PLUGIN-PLAN.md | 147 ----- .../voip-call/KILLED-APP-DECLINE-PLAN.md | 258 --------- .../voip-call/OUTGOING-CONNECT-PLAN.md | 447 --------------- 9 files changed, 3225 deletions(-) delete mode 100644 examples/mobile-client/voip-call/ANSWER-HANDSHAKE-PLAN.md delete mode 100644 examples/mobile-client/voip-call/CALL-TIMEOUTS-PLAN.md delete mode 100644 examples/mobile-client/voip-call/CALL-TIMEOUTS-RESEARCH.md delete mode 100644 examples/mobile-client/voip-call/FCM-COEXISTENCE-PLAN.md delete mode 100644 examples/mobile-client/voip-call/FEATURE-IDEAS.md delete mode 100644 examples/mobile-client/voip-call/HOLD-MULTICALL-RECENTS-PLAN.md delete mode 100644 examples/mobile-client/voip-call/IOS-VOIP-REGISTRATION-PLUGIN-PLAN.md delete mode 100644 examples/mobile-client/voip-call/KILLED-APP-DECLINE-PLAN.md delete mode 100644 examples/mobile-client/voip-call/OUTGOING-CONNECT-PLAN.md diff --git a/examples/mobile-client/voip-call/ANSWER-HANDSHAKE-PLAN.md b/examples/mobile-client/voip-call/ANSWER-HANDSHAKE-PLAN.md deleted file mode 100644 index 5905205cb..000000000 --- a/examples/mobile-client/voip-call/ANSWER-HANDSHAKE-PLAN.md +++ /dev/null @@ -1,542 +0,0 @@ -# Plan: proper answer/connect handshake (fulfill/fail instead of instant-fulfill) - -> Companion to [FEATURE-IDEAS.md](./FEATURE-IDEAS.md) item #1 (P0 🔴). That item states the -> gap in two sentences; this file is the full implementation plan, ported from how -> `~/Desktop/expo-callkit-telecom` does it (verified against their source on 2026-07-10). - -## Problem - -When the user answers an incoming call, our iOS delegate fulfills the `CXAnswerCallAction` -**immediately**: - -```objc -// packages/react-native-webrtc/ios/RCTWebRTC/CallKitManager.m:160-166 -- (void)provider:(CXProvider *)provider performAnswerCallAction:(CXAnswerCallAction *)action { - self.isCallAnswered = YES; - if (self.onCallAnswered) { self.onCallAnswered(); } - [action fulfill]; // ← "connected" before a single RTP packet exists -} -``` - -Two consequences: - -1. **The system UI lies.** `action.fulfill()` is what tells CallKit the call is up, so the - dynamic island / lock screen starts its call timer while we are still fetching a peer - token and joining the Fishjam room. Call duration in the UI is wrong by however long the - join takes. -2. **A failed join is unreportable.** If `getPeerToken()` 401s or `joinRoom()` throws, the - `CXAnswerCallAction` is long gone. There is no way to tell CallKit "that answer didn't - work" — the call sits in the system UI as connected and silent. Today - `VoipProvider.answerCall()` catches the error and calls `endCall()` - (`examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx:144-155`), which reads to - the user as *"the call connected and then instantly dropped"* rather than *"the call - failed"*. - -Android has the mirror problem in a different shape: `handleAnswered()` posts the ongoing -(chronometer) notification the moment the user answers -(`packages/react-native-webrtc/android/src/main/java/com/oney/WebRTCModule/voip/CallManager.kt:253-259`), -and nothing ever calls `setActive()` unless JS happens to call `setTelecomCallActive()` -— which the example only does from a `remotePeers.length > 0` effect, with no timeout and no -failure path. - -Neither platform has a timeout: if JS never finishes connecting, the call hangs forever. - -## How expo-callkit-telecom does it - -The answer action is **parked**, not fulfilled. A `FulfillRequestManager` keyed by a -generated `requestId` holds it; JS is handed the `requestId` in the answer event and later -resolves it. - -### iOS (`ios/Managers/FulfillRequestManager.swift`, `CallManager+CXProviderDelegate.swift:58-95`) - -`FulfillRequestManager` is a Swift `actor` mapping `requestId → PendingRequest { callId, -continuation, timeoutTask }`. `createRequest(callId:timeout:)` returns -`(requestId, Task)` where `Result` is `.fulfilled(callId:) | .cancelled | .timedOut`. -The delegate literally awaits it: - -```swift -func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) { - cancelCallTimeout(for: action.callUUID) - Task { - await store.updateStatus(for: action.callUUID, status: .connecting) - let (requestId, resultTask) = await FulfillRequestManager.shared.createRequest( - callId: action.callUUID, timeout: Self.answerCallTimeout) - await MainActor.run { - CallEventEmitter.shared.send(CallAnsweredEvent(id: action.callUUID, requestId: requestId)) - } - switch await resultTask.value { - case .fulfilled: action.fulfill() - case .cancelled: action.fail() - case .timedOut: action.fail() - } - } -} -``` - -- `fulfill(requestId:)` cancels the timeout task and resumes `.fulfilled`; returns `nil` if - the request already timed out (so a late JS call is a safe no-op). -- `cancel(requestId:)` resumes `.cancelled` → `action.fail()`. -- `cancelAll()` runs from `providerDidReset` alongside `DialtonePlayer.stop()`, - `cancelAllCallTimeouts()` and `store.removeAll()`. -- Timeout is read from the Info.plist key `ExpoCallKitTelecomFulfillAnswerCallTimeout` - (seconds, default 30), written by their config plugin. - -### Android (`managers/FulfillRequestManager.kt`, `managers/CallManager.kt:812-848`) - -Same shape, expressed with coroutines: a `mutableMapOf` (requestId → callId) -plus `timeoutJobs`, both guarded by `synchronized(lock)`. `createRequest` takes an -`onTimeout: (UUID) -> Unit` callback rather than returning a result — `CallManager` passes -`{ reportCallEnded(it, CallEndedReason.FAILED) }`. - -`onCallAnswered(id)` — the shared path for both the system answer callback and the in-app -`answerCall(id)` — does: early-return if already `CONNECTED`, cancel the incoming-call -timeout, set status `CONNECTING`, activate audio, request the speaker endpoint for video -calls, create the fulfill request, then emit `CALL_ANSWERED` with `{ id, requestId }`. - -`fulfillIncomingCallConnected(requestId)` is where the call actually goes live: - -```kotlin -fun fulfillIncomingCallConnected(requestId: UUID): Boolean { - val callId = FulfillRequestManager.fulfill(requestId) ?: return false - val now = Instant.now() - CallStore.update(callId) { it.copy(status = CONNECTED, connectedAt = now) } - activeCalls[callId]?.actions?.setActive?.trySend(Unit) // ← core-telecom goes active here - CallNotificationManager.showOngoingCall(context, callId, callerName, now.toEpochMilli()) - return true -} -``` - -There is **no** `failIncomingCallConnected` native function on Android. Their JS shim -(`src/Calls.ts:552-561`) branches instead: - -```ts -export async function failIncomingCallConnected(id: string, requestId: string) { - if (Platform.OS === "ios") { - await ExpoCallKitTelecomModule.failIncomingCallConnected(requestId); - } else { - await ExpoCallKitTelecomModule.reportCallEnded(id, "failed"); - } -} -``` - -…because on Android "failing" just means disconnecting; `reportCallEnded` internally calls -`FulfillRequestManager.cancelForCall(id)` to reap the orphaned request. Their `endCall` path -does the same. The Android timeout is read from an application `meta-data` int via -`PackageManager.GET_META_DATA` (`readTimeoutMs`, `CallManager.kt:149-161`). - -**The asymmetry is inherent, not an accident:** iOS has a first-class `CXAction` object with -`fulfill()`/`fail()` semantics that CallKit itself acts on; Android core-telecom has no -"answer failed" concept, only `setActive()` vs `disconnect()`. - -## Design for our repo - -Our VoIP layer is **single-call** (`currentCallUUID` is private to `CallKitManager`; the -Android `CallManager` is an `object` with one `hasActiveCall` flag; JS events are payload-keyed -objects with no call id). So we do **not** need to port `CallSession`/`CallStore` (that is -item #14) — the `requestId` is the only correlation handle JS needs, and it doubles as a -generation counter that makes stale fulfills safe. - -Three states become distinguishable where today there are two: - -| | today | after | -| --- | --- | --- | -| user tapped answer | `isCallAnswered = true`, CallKit says *connected* | `isCallAnswered = true`, CallKit says *connecting* | -| media connected | (same state) | `action.fulfill()` → CallKit says *connected*, timer starts | -| media failed | `endCall()` → looks like a drop | `action.fail()` → CallKit ends it as a failure | - -### JS API (new) - -Added to the unified layer in `packages/react-native-webrtc/src/VoIP.ts` (which already hosts -the cross-platform push glue), re-exported from `src/index.ts` and -`packages/mobile-client/src/index.ts`: - -```ts -/** Resolves the parked answer action; the OS now shows the call as connected. - * Returns false if the request already timed out (the call is gone — bail out). */ -export function fulfillIncomingCallConnected(requestId: string): Promise; - -/** Aborts the parked answer action. iOS fails the CXAnswerCallAction (CallKit tears the - * call down); Android disconnects. Safe to call after a timeout — no-ops. */ -export function failIncomingCallConnected(requestId: string): Promise; - -/** The requestId of an answer that is still awaiting fulfillment, or null. - * Needed on cold start: the user can answer before the JS bundle is up. */ -export function getPendingAnswerRequestId(): string | null; -``` - -`useVoIPEvents`' handler signature gains the id — additive for existing call sites, which -simply ignore the argument: - -```ts -export type VoIPEventHandlers = { - onIncoming?: (payload: VoipIncomingPayload) => void; - onAnswered?: (requestId: string) => void; // was: () => void - onEnded?: () => void; - onRegistered?: (token: string) => void; -}; -``` - -Returning `boolean` from `fulfillIncomingCallConnected` (rather than `void` like theirs) is a -deliberate improvement: both native sides already compute "did this request still exist", and -without it JS cannot distinguish *"we connected"* from *"we connected 2 s after the OS gave -up and killed the call"*. - -### iOS implementation - -**New `ios/RCTWebRTC/FulfillRequestManager.h/.m`** — the ObjC equivalent of their actor. No -Swift concurrency available, so: a serial `dispatch_queue_t` guards the dictionary, and the -timeout is a `dispatch_after` on that same queue whose handler checks whether the request is -still present (exactly the reference's `removeRequest`-returns-nil trick — a `dispatch_after` -cannot be cancelled, so the dictionary *is* the cancellation token). - -```objc -typedef NS_ENUM(NSInteger, FulfillResult) { - FulfillResultFulfilled, - FulfillResultCancelled, - FulfillResultTimedOut, -}; - -@interface FulfillRequestManager : NSObject -+ (instancetype)shared; -/// completion runs exactly once, on the main queue. -- (NSString *)createRequestWithTimeout:(NSTimeInterval)timeout - completion:(void (^)(FulfillResult result))completion; -- (BOOL)fulfill:(NSString *)requestId; // NO if unknown/timed out -- (BOOL)cancel:(NSString *)requestId; -- (void)cancelAll; -@end -``` - -`requestId` is `NSUUID.UUID.UUIDString`. The completion hops to the main queue because -`CXProvider` was constructed with `[_provider setDelegate:self queue:nil]` -(`CallKitManager.m:36`) — delegate callbacks and action fulfillment belong on the main queue. - -**`CallKitManager.h`** — `onCallAnswered` changes from `CallKitVoidCallback` to -`CallKitStringCallback` (carries the `requestId`); add -`@property(nonatomic, readonly, nullable) NSString *pendingAnswerRequestId;` and - -```objc -- (BOOL)fulfillIncomingCallConnected:(NSString *)requestId; -- (void)failIncomingCallConnected:(NSString *)requestId; -``` - -**`CallKitManager.m`** — the delegate parks the action: - -```objc -- (void)provider:(CXProvider *)provider performAnswerCallAction:(CXAnswerCallAction *)action { - self.isCallAnswered = YES; // semantics unchanged: "the user accepted", not "media is up" - - __weak typeof(self) weakSelf = self; - NSString *requestId = [[FulfillRequestManager shared] - createRequestWithTimeout:kFulfillAnswerTimeout - completion:^(FulfillResult result) { - weakSelf.pendingAnswerRequestId = nil; - if (result == FulfillResultFulfilled) { - [action fulfill]; - } else { - [action fail]; - [weakSelf reportAnswerFailureForCall:action.callUUID]; - } - }]; - - self.pendingAnswerRequestId = requestId; - if (self.onCallAnswered) { self.onCallAnswered(requestId); } -} -``` - -- The fulfill timeout is a fixed **10 seconds** on both platforms. It is intentionally not - configurable so every integration has the same answer-to-media deadline. -- `reportAnswerFailureForCall:` is defensive cleanup: `[self.provider reportCallWithUUID:uuid - endedAtDate:[NSDate date] reason:CXCallEndedReasonFailed]` then `[self cleanup]`, guarded by - `[uuid isEqual:self.currentCallUUID]` so it is a no-op when the call already ended. The - reference asserts that `action.fail()` alone makes CallKit issue a `CXEndCallAction`; that - is not stated anywhere in Apple's docs, so we belt-and-brace it. **Verify on device**: if - `performEndCallAction:` does fire, the guard makes the second report harmless; if it does - not, this line is what prevents a stuck call. -- `fulfillIncomingCallConnected:` → `[[FulfillRequestManager shared] fulfill:requestId]`. -- `failIncomingCallConnected:` → `[[FulfillRequestManager shared] cancel:requestId]`. -- `cleanup`, `providerDidReset:` and `performEndCallAction:` call `[[FulfillRequestManager - shared] cancelAll]` — matching their `providerDidReset`. **Re-entrancy hazard:** `cancelAll` - synchronously drives the completion → `action.fail()` → `reportAnswerFailureForCall:` → - `cleanup` → `cancelAll`. The `currentCallUUID` guard above breaks the cycle; null - `currentCallUUID` *before* calling `cancelAll` in `cleanup`. - -**New: `provider:timedOutPerformingAction:`.** CallKit gives every `CXAction` a -[`timeoutDate`](https://developer.apple.com/documentation/callkit/cxaction/timeoutdate) and -calls -[`provider(_:timedOutPerforming:)`](https://developer.apple.com/documentation/callkit/cxproviderdelegate/provider(_:timedoutperforming:)) -when it elapses. Apple does not document the duration, and the reference implementation does -not implement this delegate method at all — a latent bug in *their* code that we should not -copy, because the system's own action timeout is undocumented. Implement -it: if the action is a `CXAnswerCallAction`, cancel the pending request (which fails the -action) and report the call ended. - -**`WebRTCModule+CallKit.m`** — bridge surface: - -```objc -RCT_EXPORT_METHOD(fulfillIncomingCallConnected:(NSString *)requestId - resolver:(RCTPromiseResolveBlock)resolve - rejecter:(RCTPromiseRejectBlock)reject) // resolves @(BOOL) -RCT_EXPORT_METHOD(failIncomingCallConnected:(NSString *)requestId ...) -RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(getPendingAnswerRequestId) // NSString or nil -``` - -and the `onCallAnswered` block becomes -`^(NSString *requestId) { [weakSelf sendEventWithName:kEventCallKitActionPerformed body:@{@"answer": requestId}]; }`. -`CallKitAction.answer` therefore changes type from `undefined` to `string` — `useCallKitEvent` -already forwards `payload[action]` untouched (`src/useCallKit.ts:74-76`), so no change there. - -### Android implementation - -**New `voip/FulfillRequestManager.kt`** — port theirs nearly verbatim (`synchronized(lock)` -map + `timeoutJobs`, `createRequest(timeoutMs, onTimeout)`, `fulfill`, `cancel`, `cancelAll`). -Ours is single-call, so key it by `requestId: String` alone and drop `cancelForCall`. - -**`voip/CallManager.kt`** - -- Add `@Volatile private var pendingAnswerRequestId: String? = null` and - `fun pendingAnswerRequestId(): String?` for the bridge. -- `handleAnswered()` (line 253) stops posting the ongoing notification. Instead: - -```kotlin -private fun handleAnswered() { - answered = true - callNotificationManager.stopVibration() - appContext?.let { LockScreenController.onCallAnswered(it) } - showConnectingNotification() // ← see FGS note below - val requestId = FulfillRequestManager.createRequest(FULFILL_ANSWER_TIMEOUT_MS) { - pendingAnswerRequestId = null - listener?.onFailed("answer fulfill timed out") - endCall() // Disconnect(DisconnectCause.LOCAL) - } - pendingAnswerRequestId = requestId - listener?.onAnswered(requestId) -} -``` - -- New `fun fulfillAnswered(requestId: String): Boolean` — the Android analogue of - `action.fulfill()`: - -```kotlin -fun fulfillAnswered(requestId: String): Boolean { - if (!FulfillRequestManager.fulfill(requestId)) return false - pendingAnswerRequestId = null - setCallActive() // actions.trySend(CallAction.Activate) - showOngoingNotification() // chronometer starts here - return true -} -``` - -- New `fun failAnswered(requestId: String)` → `FulfillRequestManager.cancel(requestId)` then - `endCall()` with `DisconnectCause(DisconnectCause.ERROR)`. -- The `finally` block of `register()` (line 200) gains `FulfillRequestManager.cancelAll()` and - `pendingAnswerRequestId = null`, next to the existing `VoipPushRegistry.clearPending()`. -- `FULFILL_ANSWER_TIMEOUT_MS` is fixed at `10_000L`, matching iOS. - -> ⚠️ **Foreground-service start window — do not naively move `showOngoingNotification()` to -> fulfill.** Ours is not just a notification: `showOngoingNotification()` calls -> `ForegroundServiceController.onCallStarted(...)` -> (`CallManager.kt:261-263`), which starts an FGS with `microphone`/`mediaProjection` types. -> Android only permits an FGS start from the background inside a short window after the -> user-visible event that authorised it; deferring the start by up to 10 s risks -> [`ForegroundServiceStartNotAllowedException`](https://developer.android.com/develop/background-work/services/fgs/restrictions-bg-start). -> Hence `showConnectingNotification()` **at answer time** (starts the FGS immediately, content -> text "Connecting…", no chronometer) and an `notify()`-level update to the chronometer -> notification on fulfill. This is item #12's "dialing/ended states" arriving early, and it is -> a place where a verbatim port of the reference would be wrong for our repo. - -The user-facing side already cooperates: `IncomingCallActivity` shows `buildConnectingUi(...)` -after the swipe (`IncomingCallActivity.kt:111`) and finishes when the host activity covers it, -so the "connecting" phase has a UI on Android without new work. - -**`TelecomController.java`** — `CallEventsListener.onAnswered()` becomes -`onAnswered(String requestId)`; the emitted body gains `body.putString("requestId", requestId)`. -`onFailed(reason)` already exists and is reused for the timeout. - -**`WebRTCModule.java`** (near the existing telecom block at lines 1707-1733) — add -`fulfillTelecomCallAnswered(String requestId, Promise)` (resolves the `boolean`), -`failTelecomCallAnswered(String requestId, Promise)`, and a blocking-synchronous -`getPendingAnswerRequestId()`. - -**Note on `answered`:** `onSetActive = { answered = true }` (line 168) and `handleAnswered()` -both set it, so `answered` means *"the user accepted"*. Keep that meaning — `useVoIPEvents`' -cold-start replay depends on it. "Connected" is now expressed by the fulfill having landed, -not by a flag. - -### Cold start — the case the reference under-serves - -The user can answer from the lock screen while the JS bundle is still loading. The fulfill -request is created in native at that instant and its clock is already running; the -`onAnswered` event has nobody to reach. expo-callkit-telecom solves this with per-event -queues flushed on `startObserving` (item #15); we already have a narrower version of that -mechanism, and it needs one addition. - -`useVoIPEvents`' cold-start branch (`src/useVoIPEvents.ts:81-98`, and the Android twin at -150-167) currently does: - -```ts -const pendingCall = getPendingIncomingCall(); -if (pendingCall && hasActiveCallKitSession()) { - assertRoomName(pendingCall); - handlersRef.current.onIncoming?.(pendingCall as VoipIncomingPayload); - if (isCallAnswered()) { handlersRef.current.onAnswered?.(); } // ← no requestId to give -} -``` - -Change the last line to pull the parked id from native: - -```ts -const requestId = getPendingAnswerRequestId(); -if (requestId) { handlersRef.current.onAnswered?.(requestId); } -``` - -`isCallAnswered()` / `isTelecomCallAnswered()` stay as they are (still the honest answer to -"did the user accept?"), but the *replay* is now driven by the presence of an unfulfilled -request, which is strictly more precise: it cannot re-fire for a call that already connected. - -Consequences to document: - -- The fixed 10 s deadline has to cover **bundle startup + token fetch + room join** on a cold - start from a killed app, not just the join. -- On iOS the clock starts inside `performAnswerCallAction:`, which the OS may deliver before - React Native's `startObserving` runs (`WebRTCModule+CallKit.m:46-50` is where the manager is - first constructed). - -### Example app (`examples/mobile-client/voip-call/app/src/voip/VoipProvider.tsx`) - -The provider already has the exact shape this handshake wants: it treats -`remotePeers.length > 0` as "media is live" and, on Android only, calls `setTelecomCallActive()` -there (lines 184-201). That effect becomes the fulfill point, and it stops being -platform-conditional — `fulfillIncomingCallConnected` *is* `setActive()` on Android and -`action.fulfill()` on iOS. - -```tsx -const pendingRequestIdRef = useRef(null); - -onAnswered: useCallback(async (requestId: string) => { - pendingRequestIdRef.current = requestId; - setStatus('connecting'); - try { - await handleJoinRoom(call.roomName); - } catch (err) { - console.error('Failed to join room on answer:', err); - await failIncomingCallConnected(requestId); // ← was: endCall() - pendingRequestIdRef.current = null; - resetCallState(); // endCall() minus the native teardown - } -}, [handleJoinRoom]), -``` - -and in the `remotePeers` effect: - -```tsx -if (status === 'connecting' && remotePeers.length > 0) { - const requestId = pendingRequestIdRef.current; - if (requestId) { - pendingRequestIdRef.current = null; - const connected = await fulfillIncomingCallConnected(requestId); - if (!connected) { await endCall(); return; } // native already timed out and killed it - } - setStatus('active'); - // ...startedAt bookkeeping unchanged -} -``` - -Two things to get right here: - -- **The fail path must not call `endNativeCallSession()`.** `failIncomingCallConnected` already - ends the native call (iOS via `action.fail()`, Android via `disconnect`). Calling `endCall()` - on top of it double-reports. Split the current `endCall()` into `resetCallState()` (JS state - + `handleLeaveRoom()`) and `endCall()` (`resetCallState()` + `endNativeCallSession()`). -- **Outgoing calls have no `requestId`** — `pendingRequestIdRef` is null and the effect skips - the fulfill, preserving today's behaviour. Making the *outgoing* leg honest is - FEATURE-IDEAS #2 (`reportOutgoingCallConnected`), which slots into the same effect and shares - this timeout infrastructure. Land #1 first; #2 is a two-line follow-up on this foundation. - -## Edge cases the implementation must handle - -| Case | Expected behaviour | -| --- | --- | -| JS fulfills after the native timeout fired | `fulfill` returns `false`; call is already ended; JS bails out via the `!connected` branch | -| JS fulfills twice | second call returns `false` — the map lookup already removed the entry | -| Remote hangs up while we're connecting | `CXEndCallAction` / `onDisconnect` → `cancelAll()` → the parked action fails; the `currentCallUUID` guard suppresses the duplicate end report | -| User answers, then kills the app | `providerDidReset` (iOS) / `finally` block (Android) → `cancelAll()` | -| `fail` called after the request timed out | `cancel` no-ops (returns `false`); the call is already gone | -| Answer arrives before JS is ready | request parked; replayed via `getPendingAnswerRequestId()` on `startObserving` | -| Malformed push → no `roomName` | today `assertRoomName` throws in the cold-start `try` and the answer is swallowed. With this change the parked request now times out and cleanly fails the call instead of hanging (partial credit toward FEATURE-IDEAS #19) | -| System `CXAction` timeout fires first | `provider:timedOutPerformingAction:` cancels the request and ends the call | - -## Implementation checklist - -- [ ] `ios/RCTWebRTC/FulfillRequestManager.h/.m` — serial-queue map, `dispatch_after` timeout, - `createRequestWithTimeout:completion:` / `fulfill:` / `cancel:` / `cancelAll` -- [ ] `ios/RCTWebRTC/CallKitManager.h/.m` — park the action in `performAnswerCallAction:`; - `onCallAnswered` → `CallKitStringCallback`; `fulfillIncomingCallConnected:` / - `failIncomingCallConnected:` / `pendingAnswerRequestId`; `reportAnswerFailureForCall:` - with the `currentCallUUID` guard; `cancelAll` in `cleanup` / `providerDidReset:` / - `performEndCallAction:`; **new** `provider:timedOutPerformingAction:` -- [ ] `ios/RCTWebRTC/WebRTCModule+CallKit.m` — 3 bridge methods; `answer` event body carries the - `requestId` -- [ ] Fixed 10-second timeout on iOS and Android -- [ ] `android/.../voip/FulfillRequestManager.kt` — new, ported from theirs, single-call keyed -- [ ] `android/.../voip/CallManager.kt` — `handleAnswered()` creates the request + - `showConnectingNotification()`; `fulfillAnswered()` does `setActive()` + - `showOngoingNotification()`; `failAnswered()`; `cancelAll()` in the `finally` block -- [ ] `android/.../voip/CallNotificationManager.kt` — "Connecting…" variant + in-place update to - the chronometer notification (keeps the FGS start inside its allowed window) -- [ ] `android/.../TelecomController.java` — `onAnswered(String requestId)`; `requestId` in the - `telecomActionPerformed` body -- [ ] `android/.../WebRTCModule.java` — `fulfillTelecomCallAnswered` / `failTelecomCallAnswered` - / `getPendingAnswerRequestId` -- [ ] `src/CallKit.ts` — `CallKitAction.answer: string`; 3 new wrappers -- [ ] `src/Telecom.ts` — `TelecomEvent.requestId?: string`; 3 new wrappers -- [ ] `src/VoIP.ts` — cross-platform `fulfillIncomingCallConnected` / `failIncomingCallConnected` - / `getPendingAnswerRequestId` -- [ ] `src/useVoIPEvents.ts` — `onAnswered: (requestId: string) => void`; both cold-start - branches use `getPendingAnswerRequestId()` instead of `isCallAnswered()` -- [ ] `src/index.ts` + `packages/mobile-client/src/index.ts` — re-export the new functions -- [ ] `examples/.../app/src/voip/VoipProvider.tsx` — `pendingRequestIdRef`; fulfill in the - `remotePeers` effect; `failIncomingCallConnected` on the join-failure path; split - `endCall()` into `resetCallState()` + `endCall()` -- [ ] `examples/mobile-client/voip-call/README.md` §6 — document the handshake, the two - fulfillers, and the fixed timeout -- [ ] `FEATURE-IDEAS.md` #1 — link here; mark done when it lands - -### Verification - -- Compile: `JAVA_HOME=~/.sdkman/candidates/java/17.0.18-zulu ./gradlew - :fishjam-cloud_react-native-webrtc:compileDebugKotlin` (per project memory — the default - sdkman JDK 26 breaks gradle), and an iOS build of `examples/mobile-client/voip-call/app`. -- Device matrix, both platforms, **warm and cold (killed-app) start** for each: - 1. **Happy path** — answer; confirm the system call timer starts only when the remote peer's - media arrives, not at answer time. This is the whole point of the change and the one thing - a unit test cannot show you. - 2. **Join failure** — point `getPeerToken` at a 401; confirm the OS ends the call as *failed* - (iOS: Recents shows a missed/failed entry, not a 0-second connected call) rather than - showing a connect-then-drop. - 3. **Timeout** — stub `handleJoinRoom` to never resolve; confirm the call self-terminates - after ~10 s on both platforms and that a late `fulfillIncomingCallConnected` returns - `false` instead of resurrecting anything. - 4. **Remote hangup during connect** — hang up from the caller while the callee is joining; - confirm exactly one `ended` event and no duplicate end report. - 5. **Android FGS** — answer from a killed app with the screen locked; confirm no - `ForegroundServiceStartNotAllowedException` in logcat (this is what the - `showConnectingNotification()` split is defending). - -## Open questions - -- **Does `action.fail()` on a `CXAnswerCallAction` really trigger `CXEndCallAction`?** The - reference's doc comment says so; Apple's docs don't. The plan ships the defensive - `reportCallWithUUID:endedAtDate:reason:` either way — but the answer determines whether - `onCallEnded` fires once or twice, so measure it before wiring JS-side cleanup to `onEnded`. -- **What *is* the system `CXAction` timeout?** Measure with instrumentation in - `provider:timedOutPerformingAction:` and confirm it does not undercut the fixed 10 s deadline. -- **Should `failIncomingCallConnected` carry a reason?** Item #6 (`CallEndedReason`) would let - the fail path distinguish `failed` from `remoteEnded`. Not required for #1 — but if #6 lands - first, thread the reason through instead of hardcoding `CXCallEndedReasonFailed` / - `DisconnectCause.ERROR`. -- **Should the in-app answer button drive a real `CXAnswerCallAction`?** Today - `VoipProvider.answerCall()` joins the room without telling CallKit, so an in-app answer never - creates a fulfill request and the iOS system UI stays out of sync. That is item #16's - "programmatic answer"; it is the natural next step after this one and would let the - `requestId` flow through a single path on both platforms. diff --git a/examples/mobile-client/voip-call/CALL-TIMEOUTS-PLAN.md b/examples/mobile-client/voip-call/CALL-TIMEOUTS-PLAN.md deleted file mode 100644 index c63ee519b..000000000 --- a/examples/mobile-client/voip-call/CALL-TIMEOUTS-PLAN.md +++ /dev/null @@ -1,525 +0,0 @@ -# Call timeouts — implementation plan - -> Implements FEATURE-IDEAS.md item **#3** (incoming / outgoing / fulfill-answer timeouts), -> based on [CALL-TIMEOUTS-RESEARCH.md](./CALL-TIMEOUTS-RESEARCH.md) (verified against -> `~/Desktop/expo-callkit-telecom` source 2026-07-13) and audited against our tree the -> same day. All `file:line` references below were checked against the current code. - -## Current state (what exists, what's missing) - -| Timeout | iOS | Android | -|---|---|---| -| Fulfill-answer | ✅ hardcoded 10 s — `kFulfillAnswerTimeout`, `CallKitManager.m:8`, used at `:217` | ✅ hardcoded 10 s — `FULFILL_ANSWER_TIMEOUT_MS`, `voip/CallManager.kt:41`, used at `:312` | -| Incoming ring | ❌ rings forever | ❌ rings forever | -| Outgoing dial | ❌ (moot today: `connected` is reported instantly, `CallKitManager.m:80-81`) | ❌ (implementable now: "connected" moment = `setTelecomCallActive()` from JS) | - -Everything below keeps the **native-first rule**: ring timers must live in native code -because on both platforms the call can be ringing while no JS exists (iOS PushKit -cold-launch reports the call from `VoipManager.m:79`; Android's -`PushNotificationService.kt:32` reports it from the FCM process before React loads). -A JS `setTimeout` would silently not exist in exactly the scenario the timeout is for. - -## Design decisions (with reasoning) - -1. **End reason for ring expiry = existing `missed`, no new TS type.** - Our `missed` already maps to - [`CXCallEndedReason.unanswered`](https://developer.apple.com/documentation/callkit/cxcallendedreason/unanswered) - (`CallKitManager.m:123-124`) and - [`DisconnectCause.MISSED`](https://developer.android.com/reference/android/telecom/DisconnectCause#MISSED) - (`voip/CallManager.kt:117`) — byte-identical to what expo-callkit-telecom's - `UNANSWERED` produces natively (their `disconnectCauseFor`, `CallManager.kt:578-582`). - JS receives `onEnded('missed')` through the existing pipeline - (`useVoIPEvents.ts:57-60` iOS, `:129-131` Android) with **zero** TS changes. - Outgoing expiry also uses `missed` (matching their MISSED mapping); if product later - wants to distinguish, that belongs in FEATURE-IDEAS item #6 (end reasons), not here. - -2. **Expiry reuses the normal end-call paths, no bespoke teardown.** - iOS expiry calls the existing `endCallWithReason:@"missed"` (`CallKitManager.m:135`), - which already does `reportCallWithUUID:endedAtDate:reason:`, fires `onCallEnded`, - and runs `cleanup` (cancels fulfill requests, clears the buffered push payload). - Android expiry calls the existing `endCall(DisconnectCause(MISSED))` - (`voip/CallManager.kt:108`) → `Disconnect` action → `listener.onEnded("missed")` - (`:290-292`) → the `finally` block (`:250-267`) which already cancels notifications, - stops the foreground service, **and broadcasts `ACTION_CALL_ENDED` to dismiss - `IncomingCallActivity`** — the comment at `:262-263` even anticipates "timeout". - Nothing new to invent on the teardown side. - -3. **Single ring timer, not a per-UUID map.** Both our managers enforce one call at a - time (`currentCallUUID` + `maximumCallGroups = 1` on iOS `CallKitManager.m:35-36`; - `if (hasActiveCall) return` on Android `voip/CallManager.kt:177`). Their per-UUID - `[UUID: Task]` map exists because they support call stores; we'd be adding dead - generality. One cancellable timer + a call-identity guard at expiry is equivalent - and simpler. `startRingTimeout` cancels any previous timer first (restart-safe, - like theirs). Known future upgrade: if multi-call lands - ([HOLD-MULTICALL-RECENTS-PLAN.md](./HOLD-MULTICALL-RECENTS-PLAN.md) Phase 3), the - single timer becomes a per-UUID map — that phase owns the migration; don't - pre-build it here. - -4. **One-shot discipline.** The winner between "expire" and "answer/end" must be - decided once. On iOS everything relevant runs on the main queue (the provider - delegate uses `queue:nil` → main, `CallKitManager.m:40`; we schedule the expiry - block on main), so cancel-before-fire is ordered by the queue itself, plus a - `uuid == currentCallUUID && !isCallAnswered` guard inside the block. On Android the - answer paths aren't serialized with the timer coroutine, so expiry re-checks the - `@Volatile` `answered`/`hasActiveCall` flags after `delay()` (same belt-and-braces - as their `status == CONNECTED` check, `CallManager.kt:173-176`); the residual - window is closed by the fact that expiry only `trySend`s a `Disconnect` action into - the same serial `processActions` channel every other transition uses. - -5. **Config = build-time metadata, native defaults, exactly like theirs.** - Info.plist keys on iOS, `` on Android, written by our existing Expo - config plugin (`packages/mobile-client/plugin`), read once natively with fallbacks - so bare-RN apps and plugin-less setups keep working. Keys (namespaced to us): - - `FishjamVoipIncomingCallTimeout` — seconds, default **45** - - `FishjamVoipOutgoingCallTimeout` — seconds, default **60** - - `FishjamVoipFulfillAnswerTimeout` — seconds, default **10** (keeps our current - behavior; theirs defaults to 30 — do *not* silently change an existing deadline) - Values must be > 0; anything else (missing, 0, negative, non-numeric) falls back to - the default. No runtime API — matching theirs, and it avoids "timeout changed - mid-ring" states. - -6. **iOS timer primitive: `dispatch_block_t` + `dispatch_after`, wall clock.** - The ObjC equivalent of their cancellable `Task.sleep`: - [`dispatch_block_create`](https://developer.apple.com/documentation/dispatch/1431052-dispatch_block_create) - gives a handle that [`dispatch_block_cancel`](https://developer.apple.com/documentation/dispatch/1431058-dispatch_block_cancel) - can revoke. Schedule with [`dispatch_walltime`](https://developer.apple.com/documentation/dispatch/1420512-dispatch_walltime) - rather than `dispatch_time(DISPATCH_TIME_NOW, …)`: the ringing app is typically - backgrounded (PushKit launch), and the uptime clock stops if the process is - briefly suspended — wall time fires on schedule or immediately on resume. (The - existing `FulfillRequestManager.m:36` uses the uptime clock; acceptable there - because the app is in an active CallKit answer transaction, but don't copy it for - the ring timer.) - -7. **Android timer primitive: a coroutine `Job` with `delay`,** identical to theirs - and to our existing `FulfillRequestManager.kt` — launched on the existing - `CallManager.scope` (`Dispatchers.Default`, `voip/CallManager.kt:43`), cancelled via - [`Job.cancel()`](https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-job/cancel.html) - which aborts the suspended - [`delay`](https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/delay.html). - -8. **iOS outgoing timeout is gated on OUTGOING-CONNECT-PLAN.md** (FEATURE-IDEAS #2). - Today `startCallWithDisplayName` reports `connected` in the same completion block - that would start the timer (`CallKitManager.m:80-81`), so the timer would be - cancelled microseconds after starting — dead code at best, a live-call killer at - worst if the cancel is misplaced. Steps 1–5 below are independent of #2; Step 6 - lands with it. **Android outgoing is implementable now** because the "connected" - moment already exists as a distinct signal (`setTelecomCallActive()` / - `onSetActive`). - ---- - -## Step 1 — iOS: ring-timer infrastructure + incoming timeout - -**File: `packages/react-native-webrtc/ios/RCTWebRTC/CallKitManager.m`** - -1a. Replace the single constant at line 8 with three ivars + a read helper (defaults -only in this step; the Info.plist read is Step 3 — keep the diff reviewable): - -```objc -static const NSTimeInterval kDefaultIncomingCallTimeout = 45; -static const NSTimeInterval kDefaultOutgoingCallTimeout = 60; -static const NSTimeInterval kDefaultFulfillAnswerTimeout = 10; -``` - -Add to the class extension (next to `pendingAnswerRequestId`, line 15): - -```objc -@property(nonatomic, copy, nullable) dispatch_block_t ringTimeoutBlock; -@property(nonatomic, assign) NSTimeInterval incomingCallTimeout; // set in init -@property(nonatomic, assign) NSTimeInterval outgoingCallTimeout; -@property(nonatomic, assign) NSTimeInterval fulfillAnswerTimeout; -``` - -1b. New methods (place next to `cleanup`): - -```objc -- (void)startRingTimeoutForCall:(NSUUID *)uuid timeout:(NSTimeInterval)timeout { - [self cancelRingTimeout]; - - __weak typeof(self) weakSelf = self; - dispatch_block_t block = dispatch_block_create(0, ^{ - typeof(self) strongSelf = weakSelf; - if (strongSelf == nil) { - return; - } - strongSelf.ringTimeoutBlock = nil; - // The call this timer was armed for may already be gone or answered. - if (![uuid isEqual:strongSelf.currentCallUUID] || strongSelf.isCallAnswered) { - return; - } - [strongSelf endCallWithReason:@"missed"]; - }); - self.ringTimeoutBlock = block; - dispatch_after(dispatch_walltime(NULL, (int64_t)(timeout * NSEC_PER_SEC)), - dispatch_get_main_queue(), block); -} - -- (void)cancelRingTimeout { - if (self.ringTimeoutBlock != nil) { - dispatch_block_cancel(self.ringTimeoutBlock); - self.ringTimeoutBlock = nil; - } -} -``` - -Why this is safe: -- Expiry runs on main; `performAnswerCallAction` / `performEndCallAction` / - `providerDidReset` also run on main (delegate queue is nil, `CallKitManager.m:40`), - so a cancel that happens before the block dequeues always wins — no lock needed. -- Expiry → `endCallWithReason:@"missed"` → the reported-reason branch - (`CallKitManager.m:159-165`): `reportCallWithUUID:endedAtDate:reason:` with - `CXCallEndedReasonUnanswered` (via `cxEndedReasonForReason`, `:123-124`) — this is - the documented way to dismiss a ringing CallKit UI for an unanswered call - ([`reportCall(with:endedAt:reason:)`](https://developer.apple.com/documentation/callkit/cxprovider/reportcall(with:endedat:reason:))) - — then `onCallEnded(@"missed")` → JS `onEnded('missed')` → the example's - `endCall('missed')` cleanup, and `cleanup` clears the buffered push payload. -- `endCallWithReason` → `cleanup` → `cancelRingTimeout` calls - `dispatch_block_cancel` on the *currently executing* block — documented no-op for a - block that already started; harmless. - -1c. Wire the sites: -- **Start**: in `reportIncomingCallWithDisplayName:` (`:103-114`), add a success - branch to the [`reportNewIncomingCall`](https://developer.apple.com/documentation/callkit/cxprovider/reportnewincomingcall(with:update:completion:)) - completion (it runs on the delegate queue = main): - - ```objc - completion:^(NSError *_Nullable error) { - if (error) { - /* existing failure handling */ - return; - } - [weakSelf startRingTimeoutForCall:uuid timeout:weakSelf.incomingCallTimeout]; - }]; - ``` - - Only after success, matching theirs — if CallKit refused the call there is nothing - ringing to time out, and `currentCallUUID` was already reset. -- **Cancel on answer**: first line of `performAnswerCallAction:` - (`:211`, before `isCallAnswered = YES`): `[self cancelRingTimeout];`. - This is the *only* answer surface on iOS today — there is no in-app answer bridge - method (`WebRTCModule+CallKit.m` exports none), the example's `answerCall` is - triggered *by* this delegate's `onCallAnswered` event. If an in-app answer API is - ever added (their `answerCall(for:)` via `CXCallController`), it funnels through - this same delegate anyway, so the cancel site stays correct. -- **Cancel on every teardown**: add `[self cancelRingTimeout];` as the first line of - `cleanup` (`:185`). That covers `endCallWithReason` (both branches end in cleanup), - `performEndCallAction` (`:208`), answer-failure teardown - (`reportAnswerFailureForCall`, `:182`), and `providerDidReset` (`:196`). - -**Sanity check (no config yet):** temporarily set the default to ~15 s, ring the -device from the example server, don't answer → CallKit UI dismisses at 15 s, example -shows `lastEndedReason: missed`. Also verify a call answered at ~10 s is *not* ended -at 15 s. - -## Step 2 — Android: ring-timer infrastructure + incoming timeout - -**File: `packages/react-native-webrtc/android/src/main/java/com/oney/WebRTCModule/voip/CallManager.kt`** - -2a. Constants and state (next to `FULFILL_ANSWER_TIMEOUT_MS`, line 41): - -```kotlin -private const val DEFAULT_INCOMING_CALL_TIMEOUT_MS = 45_000L -private const val DEFAULT_OUTGOING_CALL_TIMEOUT_MS = 60_000L -private const val DEFAULT_FULFILL_ANSWER_TIMEOUT_MS = 10_000L - -private var incomingCallTimeoutMs = DEFAULT_INCOMING_CALL_TIMEOUT_MS -private var outgoingCallTimeoutMs = DEFAULT_OUTGOING_CALL_TIMEOUT_MS -private var fulfillAnswerTimeoutMs = DEFAULT_FULFILL_ANSWER_TIMEOUT_MS -private var ringTimeoutJob: Job? = null -``` - -(Replace the use of `FULFILL_ANSWER_TIMEOUT_MS` at `:312` with -`fulfillAnswerTimeoutMs`; delete the old constant.) - -2b. Timer helpers: - -```kotlin -private fun startRingTimeout(timeoutMs: Long) { - cancelRingTimeout() - ringTimeoutJob = scope.launch { - delay(timeoutMs) - // Re-check after waking: the call may have been answered or torn down - // between the last cancel site and this dispatch. - if (!hasActiveCall || answered) return@launch - endCall(DisconnectCause(DisconnectCause.MISSED)) - } -} - -private fun cancelRingTimeout() { - ringTimeoutJob?.cancel() - ringTimeoutJob = null -} -``` - -Why this is safe: -- Expiry funnels through the **existing** `endCall` → `CallAction.Disconnect` channel - (`:108-110`), so it is serialized with every other call transition by - `processActions` (`:275-294`); the `finally` block then handles notification - cancel, foreground-service stop, `IncomingCallActivity` dismissal broadcast — all - already written for exactly this case (`:250-267`). -- `causeToReason(MISSED)` → `"missed"` (`:127`) → `listener.onEnded("missed")` → - `telecomActionPerformed {event:'ended', reason:'missed'}` (`TelecomController.java:109-114`) - → JS `onEnded('missed')` (`useVoIPEvents.ts:129-131`). No TS changes. -- The `@Volatile answered` re-check mirrors their `status == CONNECTED` guard; the - worst-case race (answer lands in the same instant as expiry's `trySend`) resolves - in the serial action channel, and `handleAnswered`'s cancel (below) shuts the - window on the common path. -- `scope` is `Dispatchers.Default` — `endCall` only does `actions?.trySend`, which is - thread-safe ([`Channel.trySend`](https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.channels/-send-channel/try-send.html)). - -2c. Wire the sites: -- **Start (incoming)**: in the `addCall` control-scope body, next to the incoming - branch (`:222`): - - ```kotlin - if (isIncoming) { - callNotificationManager.showIncoming(ctx.applicationContext, displayName, isVideo) - startRingTimeout(incomingCallTimeoutMs) - } else { - showOngoingNotification() - } - ``` - - Inside the scope body — i.e. only once - [`CallsManager.addCall`](https://developer.android.com/reference/androidx/core/telecom/CallsManager#addCall(androidx.core.telecom.CallAttributesCompat,kotlin.coroutines.SuspendFunction1,kotlin.coroutines.SuspendFunction1,kotlin.coroutines.SuspendFunction1,kotlin.coroutines.SuspendFunction1)) - has actually registered the call — matching theirs (`CallManager.kt:387-394`), and - crucially it also runs on the FCM cold-start path since - `PushNotificationService.kt:32` → `reportIncomingCall` → `register` needs no JS. -- **Cancel on answer**: first line of `handleAnswered()` (`:305`), *before* the - `pendingAnswerRequestId != null` early-return, so a re-entrant answer can't skip - the cancel: - - ```kotlin - private fun handleAnswered() { - cancelRingTimeout() - if (pendingAnswerRequestId != null) return - ... - ``` - - Both answer paths funnel here: external answers via the `onAnswer` lambda (`:209-212`) - and app-initiated answers via `CallAction.Answer` success (`:286-289`) — - `IncomingCallActivity`'s swipe/notification answer also goes through - `CallManager.answer()` (`IncomingCallActivity.kt:103`). -- **Cancel on every teardown**: add `cancelRingTimeout()` to the `finally` block - (`:250`, alongside `FulfillRequestManager.cancelAll()`). Every end path — - disconnect action, remote `onDisconnect`, `addCall` failure — flows through - `finally`, so no timer survives a dead call. - -**Sanity check:** same as Step 1, on the emulator, including the killed-app case: -force-stop the app, send the FCM push, don't answer → full-screen incoming UI and -notification disappear at the deadline with no JS ever running. - -## Step 3 — native config reads (both platforms, incl. fulfill timeout) - -3a. **iOS** — in `CallKitManager.m` `init` (`:29`), after the provider setup: - -```objc -static NSTimeInterval timeoutFromInfoPlist(NSString *key, NSTimeInterval fallback) { - id value = [NSBundle.mainBundle objectForInfoDictionaryKey:key]; - if ([value respondsToSelector:@selector(doubleValue)]) { - double seconds = [value doubleValue]; - if (seconds > 0) { - return seconds; - } - } - return fallback; -} -``` - -```objc -_incomingCallTimeout = timeoutFromInfoPlist(@"FishjamVoipIncomingCallTimeout", kDefaultIncomingCallTimeout); -_outgoingCallTimeout = timeoutFromInfoPlist(@"FishjamVoipOutgoingCallTimeout", kDefaultOutgoingCallTimeout); -_fulfillAnswerTimeout = timeoutFromInfoPlist(@"FishjamVoipFulfillAnswerTimeout", kDefaultFulfillAnswerTimeout); -``` - -`respondsToSelector:@selector(doubleValue)` accepts both `NSNumber` (plist -``) and `NSString` — more forgiving than their `as? Int`, same fallback -semantics ([`object(forInfoDictionaryKey:)`](https://developer.apple.com/documentation/foundation/bundle/1408696-object)). -Then replace `kFulfillAnswerTimeout` at `:217` with `self.fulfillAnswerTimeout`. - -3b. **Android** — in `ensureRegistered` (`voip/CallManager.kt:159`), which is the -first point with a `Context` on both the JS and FCM cold-start paths: - -```kotlin -private var timeoutsLoaded = false - -private fun loadTimeouts(context: Context) { - if (timeoutsLoaded) return - timeoutsLoaded = true - incomingCallTimeoutMs = readTimeoutMs(context, "FishjamVoipIncomingCallTimeout", DEFAULT_INCOMING_CALL_TIMEOUT_MS) - outgoingCallTimeoutMs = readTimeoutMs(context, "FishjamVoipOutgoingCallTimeout", DEFAULT_OUTGOING_CALL_TIMEOUT_MS) - fulfillAnswerTimeoutMs = readTimeoutMs(context, "FishjamVoipFulfillAnswerTimeout", DEFAULT_FULFILL_ANSWER_TIMEOUT_MS) -} - -/** Reads a manifest meta-data value in seconds; returns milliseconds. */ -private fun readTimeoutMs(context: Context, key: String, defaultMs: Long): Long = try { - val appInfo = context.packageManager.getApplicationInfo( - context.packageName, PackageManager.GET_META_DATA) - val seconds = appInfo.metaData?.getInt(key, (defaultMs / 1000).toInt()) - ?: (defaultMs / 1000).toInt() - if (seconds > 0) seconds * 1000L else defaultMs -} catch (_: Exception) { - defaultMs -} -``` - -Call `loadTimeouts(context)` at the top of `ensureRegistered`. Notes: -- [`getApplicationInfo(…, GET_META_DATA)`](https://developer.android.com/reference/android/content/pm/PackageManager#getApplicationInfo(java.lang.String,%20int)) - + [`Bundle.getInt`](https://developer.android.com/reference/android/os/Bundle#getInt(java.lang.String,%20int)) - works because the manifest parser stores a numeric `android:value` as an integer - [`TypedValue`](https://developer.android.com/guide/topics/manifest/meta-data-element#val) - — this is exactly the read expo-callkit-telecom does (`CallManager.kt:149-161`), - so the write format in Step 4 must remain a plain integer string. -- Imports needed: `android.content.pm.PackageManager`. - -## Step 4 — config plugin + docs - -**Files: `packages/mobile-client/plugin/src/types.ts`, `withFishjamVoip.ts`, new -`withFishjamVoipIos` mod (or extend `withFishjamIos.ts`), and README/docs.** - -4a. `types.ts` — one cross-platform knob (the values mean the same thing on both -platforms, so don't split them under `android`/`ios`): - -```ts -voip?: { - /** Seconds an unanswered incoming call rings before auto-ending as `missed`. Default 45. */ - incomingCallTimeout?: number; - /** Seconds an outgoing call may stay unconnected before auto-ending as `missed`. Default 60. */ - outgoingCallTimeout?: number; - /** Seconds JS has to fulfill an answered call before it fails. Default 10. */ - fulfillAnswerCallTimeout?: number; -}; -``` - -4b. Android — extend `withFishjamVoipAndroid` (`withFishjamVoip.ts:65`), inside the -existing `enableVoip` gate, next to the `INSTALLATION_ID_META` handling (`:119-126`): -for each provided prop, upsert a meta-data entry -`{ $: { 'android:name': key, 'android:value': String(Math.floor(seconds)) } }` using -the same find-index-or-push pattern. **Write only the props the user set** — omitted -props fall back to native defaults, keeping the manifest clean. Validate -`Number.isFinite(v) && v > 0` and throw a descriptive config error otherwise (fail at -prebuild, not silently at runtime). - -4c. iOS — add a small mod using -[`withInfoPlist`](https://docs.expo.dev/config-plugins/plugins-and-mods/#ios-mods): - -```ts -config.modResults.FishjamVoipIncomingCallTimeout = Math.floor(props.voip.incomingCallTimeout); // etc. -``` - -Wire it into the plugin composition in `withFishjam.ts` next to the existing VoIP -mods. Same validation, same only-when-provided rule. - -4d. Docs: -- `Telecom.ts:25` — extend the `missed` doc comment: "an incoming call rang and was - never answered — including the native ring timeout (default 45 s)". -- Example app `README.md` + package docs: document the three plugin props, the native - defaults, and the bare-RN escape hatch (add the Info.plist keys / manifest - `` entries by hand). -- Flip FEATURE-IDEAS.md item #3 status when done. - -## Step 5 — Android outgoing timeout - -Small, isolated, and implementable today because Android already has a real -"connected" signal: the example calls `setTelecomCallActive()` when the first remote -peer joins (`VoipProvider.tsx:254-258`) → `CallAction.Activate`, and external -surfaces (watch/Auto) arrive via the `onSetActive` lambda. - -In `voip/CallManager.kt`: -- **Start**: the outgoing branch from Step 2c: - - ```kotlin - } else { - showOngoingNotification() - startRingTimeout(outgoingCallTimeoutMs) - } - ``` -- **Cancel**: two sites, covering both activation paths: - - `onSetActive = { answered = true }` (`:218`) → `onSetActive = { answered = true; cancelRingTimeout() }` - - in `processActions`, on successful `Activate` (`:284-289` area) add an - `else if (action == CallAction.Activate) { cancelRingTimeout() }` branch — - [`CallControlScope.setActive()`](https://developer.android.com/reference/androidx/core/telecom/CallControlScope#setActive()) - does **not** invoke the `onSetActive` lambda (that callback is for - externally-initiated transitions), so both sites are required. -- The Step 2b guard `if (!hasActiveCall || answered)` already protects an active - outgoing call, because `onSetActive`/successful activate set `answered = true` - (`:218`, and `fulfillAnswered` → `setCallActive`). -- Hold-plan interplay: [HOLD-MULTICALL-RECENTS-PLAN.md](./HOLD-MULTICALL-RECENTS-PLAN.md) - Phase 1 reuses `CallAction.Activate` as "un-hold". An un-hold Activate hitting these - cancel sites is harmless (holding is only possible post-connect, when the ring timer - is already cancelled), but if Activate is ever split into separate connected/un-hold - actions, both cancel sites here must move with the *connected* variant. - -Semantics note: "connected" for outgoing means *first remote peer joined the room*. -In the example that is precisely when ringing conceptually ends, matching theirs -(`reportOutgoingCallConnected` cancels their timer, `CallManager.kt:437-440`). - -## Step 6 — iOS outgoing timeout (⏸ gated on OUTGOING-CONNECT-PLAN.md) - -Do **not** implement before FEATURE-IDEAS #2 lands (see decision 8). When it does: -- **Start**: `startRingTimeoutForCall:uuid timeout:self.outgoingCallTimeout` in the - `requestTransaction` completion of `startCallWithDisplayName:` (`CallKitManager.m:79-85`), - immediately after `reportOutgoingCallWithUUID:startedConnectingAtDate:` — and the - instant `connectedAtDate` report at `:81` must already be gone per that plan. -- **Cancel**: in the new `reportOutgoingCallConnected` method that plan introduces - (mirroring theirs, `CallManager.swift:502-507`), plus the existing `cleanup` site - from Step 1 already covers all end paths. -- The expiry guard from Step 1 needs one adjustment for outgoing: `isCallAnswered` - never becomes true for outgoing calls today, so the guard reduces to the UUID check — - correct, because for outgoing "still ringing" *is* "not torn down and not reported - connected", and the cancel in `reportOutgoingCallConnected` encodes the latter. -- Add a line to OUTGOING-CONNECT-PLAN.md referencing this step so neither plan ships - half of the pair (an outgoing timeout with instant-connected reporting can never - fire *after* the OUTGOING plan if the cancel is wired; an outgoing-connect change - without the timeout resurrects the "dials forever" gap). - ---- - -## Edge cases audited - -| Case | Behavior | -|---|---| -| App suspended mid-ring (iOS) | `dispatch_walltime` fires on schedule or immediately on resume; uptime-clock `dispatch_after` would silently extend the ring (decision 6). | -| Cold start / killed app | Both start sites run without JS (PushKit path `VoipManager.m:79`; FCM path `PushNotificationService.kt:32` — `register` needs no React context). Expiry paths are JS-free too. | -| Metro reload mid-ring | Timers are native singletons; `TelecomController.detach()` only clears the listener, and the iOS manager is a process singleton. The end event after reload reaches JS via the normal re-subscription. | -| Answer at T-0 ms race | iOS: serialized on main queue + UUID/answered guard. Android: `handleAnswered` cancel + post-`delay` volatile re-check + serial action channel (decision 4). | -| Expiry vs. already-ended call | iOS: `endCallWithReason` no-ops on nil `currentCallUUID` (`:136-139`) and the block's UUID guard catches a *new* call reusing the slot. Android: `finally` cancels the timer; the guard re-checks `hasActiveCall`. | -| Second call reusing the timer | `startRingTimeout` cancels any previous timer first (restart-safe, like theirs `CallManager.kt:167`). | -| `IncomingCallActivity` left on screen | Already dismissed by the `ACTION_CALL_ENDED` broadcast in `finally` (`CallManager.kt:262-266`). | -| CallKit's own `timedOutPerformingAction` | Unrelated: it concerns un-fulfilled `CXAction`s post-answer and is already handled (`CallKitManager.m:241-253`); the ring timer only lives pre-answer. | -| Config value invalid | Plugin throws at prebuild; native clamps `<= 0` / non-numeric to defaults (Step 3). | -| Fulfill default change | None — stays 10 s unless configured (decision 5). | - -## Verification / QA plan - -Configure the example app (`examples/mobile-client/voip-call/app/app.json`) with -short values — `"voip": { "incomingCallTimeout": 15, "outgoingCallTimeout": 20 }` — -prebuild, then run the matrix on both platforms (drive with argent where scripted): - -1. **Foreground ring-out**: incoming call, don't answer → auto-end at 15 s, native UI - gone, example shows `lastEndedReason: missed`, caller side gets the room-leave. -2. **Locked-screen ring-out** (Android full-screen intent, iOS lock-screen CallKit). -3. **Cold-start ring-out**: force-stop the app first; no JS may run before expiry. -4. **Answer at ~10 s** → call connects and is *not* ended at 15 s (ride it past 60 s). -5. **Android outgoing ring-out**: call a room nobody joins → ends at 20 s, `missed`. -6. **Android outgoing answered**: callee joins at ~10 s → no end at 20 s. -7. **Fulfill timeout regression**: answer while Metro is stopped (JS can't fulfill) → - call fails at 10 s exactly as today. -8. **Defaults path**: remove the `voip` config, prebuild → behavior unchanged except - 45/60 s auto-end. - -## Files touched (summary) - -| File | Change | -|---|---| -| `packages/react-native-webrtc/ios/RCTWebRTC/CallKitManager.m` | ring timer, start/cancel sites, Info.plist reads, fulfill timeout ivar | -| `packages/react-native-webrtc/android/.../voip/CallManager.kt` | ring timer, start/cancel sites, meta-data reads, fulfill timeout var | -| `packages/mobile-client/plugin/src/types.ts` | `voip` timeout props | -| `packages/mobile-client/plugin/src/withFishjamVoip.ts` | Android meta-data entries | -| `packages/mobile-client/plugin/src/withFishjamIos.ts` (or new mod) | Info.plist keys | -| `packages/react-native-webrtc/src/Telecom.ts` | `missed` doc comment only | -| READMEs / FEATURE-IDEAS.md | config docs, status flip | - -No changes to: `FulfillRequestManager` (either platform), `useVoIPEvents.ts`, -`CallKit.ts` API surface, `TelecomController.java`, the example's `VoipProvider.tsx` -(it already handles `onEnded('missed')`). diff --git a/examples/mobile-client/voip-call/CALL-TIMEOUTS-RESEARCH.md b/examples/mobile-client/voip-call/CALL-TIMEOUTS-RESEARCH.md deleted file mode 100644 index 9d7ee7fdd..000000000 --- a/examples/mobile-client/voip-call/CALL-TIMEOUTS-RESEARCH.md +++ /dev/null @@ -1,280 +0,0 @@ -# Call timeouts — how expo-callkit-telecom does it - -> Deep-dive into how `~/Desktop/expo-callkit-telecom` implements the three call timeouts -> (incoming / outgoing / fulfill-answer), verified against their source on 2026-07-13. -> Companion to [FEATURE-IDEAS.md](./FEATURE-IDEAS.md) item **#3** and -> [ANSWER-HANDSHAKE-PLAN.md](./ANSWER-HANDSHAKE-PLAN.md) (their fulfill-answer timeout is -> the timeout leg of the handshake we already ported with a hardcoded 10 s deadline). - -## The three timeouts at a glance - -| Timeout | Default | Starts when… | Cancelled when… | On expiry | -|---|---|---|---|---| -| **Incoming** | 45 s | the incoming call is reported to CallKit / Core-Telecom | user answers, call ends, provider resets | end call, reason `unanswered` | -| **Outgoing** | 60 s | the outgoing call starts connecting (`CXStartCallAction` / Telecom scope up) | app calls `reportOutgoingCallConnected`, call ends, provider resets | stop dialtone, end call, reason `unanswered` | -| **Fulfill-answer** | 30 s | the answer event (with `requestId`) is emitted to JS | JS fulfills or fails the request, call ends | iOS: `CXAnswerCallAction.fail()` → CallKit tears the call down; Android: end call, reason `failed` | - -Key structural point: the incoming/outgoing **ring timeouts** and the **fulfill-answer -timeout** are two separate mechanisms. Ring timeouts live in `CallManager` as per-call -cancellable timers keyed by call UUID. The fulfill timeout lives inside -`FulfillRequestManager` and is the third resolution path of a pending answer request -(fulfilled / cancelled / timed out) — it is never tracked in the ring-timeout map. - ---- - -## Configuration pipeline (config plugin → build metadata → native) - -Values are **build-time config, not runtime API**. The Expo -[config plugin](https://docs.expo.dev/config-plugins/introduction/) accepts three props -and bakes them into platform metadata; the native side reads them once at startup. - -1. **Props + defaults** — `plugin/src/withExpoCallKitTelecom.ts:19-38` declares - `incomingCallTimeout`, `outgoingCallTimeout`, `fulfillAnswerCallTimeout` (seconds). - Defaults in `plugin/src/constants.ts:2-4`: **45 / 60 / 30**. - -2. **iOS** — `withTimeouts` (`plugin/src/withExpoCallKitTelecomIos.ts:106-121`) writes - three Info.plist keys via [`withInfoPlist`](https://docs.expo.dev/config-plugins/plugins-and-mods/#ios-mods): - - `ExpoCallKitTelecomIncomingCallTimeout` - - `ExpoCallKitTelecomOutgoingCallTimeout` - - `ExpoCallKitTelecomFulfillAnswerCallTimeout` - -3. **Android** — `withTimeouts` (`plugin/src/withExpoCallKitTelecomAndroid.ts:57-87`) - writes the same three keys as `` entries in `AndroidManifest.xml` via - [`AndroidConfig.Manifest.addMetaDataItemToMainApplication`](https://docs.expo.dev/config-plugins/plugins-and-mods/#android-mods) - (values stringified seconds). - -4. **Native read**: - - iOS reads lazily via [`Bundle.main.object(forInfoDictionaryKey:)`](https://developer.apple.com/documentation/foundation/bundle/1408696-object) - into `static let` [`Duration`](https://developer.apple.com/documentation/swift/duration)s - with the same hardcoded fallbacks (`ios/Managers/CallManager.swift:32-48` for - incoming/outgoing, `ios/Managers/CallManager+CXProviderDelegate.swift:7-14` for - fulfill-answer). - - Android reads in `CallManager.initialize()` via - [`PackageManager.getApplicationInfo(…, GET_META_DATA)`](https://developer.android.com/reference/android/content/pm/PackageManager#getApplicationInfo(java.lang.String,%20int)) - → `readTimeoutMs()` seconds→ms conversion, `try/catch` falling back to defaults - (`android/.../managers/CallManager.kt:126-131, 148-161`). - -Because both platforms fall back to the same defaults when the key is missing, the -feature works even without the config plugin step. - ---- - -## iOS implementation - -### Ring timeouts (incoming + outgoing) — `CallManager` - -State: `callTimeoutTasks: [UUID: Task]` guarded by an `NSLock` -(`CallManager.swift:50-54`). One structured-concurrency -[`Task`](https://developer.apple.com/documentation/swift/task) per call — no `Timer`, -no `DispatchQueue.asyncAfter`. - -`startCallTimeout(for:timeout:)` (`CallManager.swift:91-116`): - -```swift -let task = Task { - try? await Task.sleep(for: timeout) - guard !Task.isCancelled else { return } - self.removeTimeoutTask(for: id) // claim ownership of expiry - DialtonePlayer.shared.stop() // outgoing dialtone, no-op otherwise - await reportCallEnded(for: id, reason: .unanswered) -} -callTimeoutTasks[id] = task // under lock -``` - -- Cancellation is cooperative: `cancelCallTimeout` removes the task under the lock and - calls [`Task.cancel()`](https://developer.apple.com/documentation/swift/task/cancel()), - which makes the in-flight [`Task.sleep`](https://developer.apple.com/documentation/swift/task/sleep(for:tolerance:clock:)) - throw; the `guard !Task.isCancelled` then bails (`CallManager.swift:97, 129-137`). -- Expiry funnels into the same `reportCallEnded(for:reason:)` used for remote hangups - (`CallManager.swift:560-579`): stops dialtone, cancels any timeout (idempotent), - reports to CallKit via [`CXProvider.reportCall(with:endedAt:reason:)`](https://developer.apple.com/documentation/callkit/cxprovider/reportcall(with:endedat:reason:)) - with [`CXCallEndedReason`](https://developer.apple.com/documentation/callkit/cxcallendedreason)`.unanswered`, - emits `CallReportedEnded` to JS with the ended session snapshot, and removes the session. - -**Start sites:** -- Incoming: after [`reportNewIncomingCall`](https://developer.apple.com/documentation/callkit/cxprovider/reportnewincomingcall(with:update:completion:)) - succeeds — both the async path (`CallManager.swift:340`) and the callback path used for - VoIP pushes from a terminated state (`CallManager.swift:417`, inside the completion, - in a detached `Task` after the session is stored). -- Outgoing: in the [`CXStartCallAction`](https://developer.apple.com/documentation/callkit/cxstartcallaction) - delegate, right after [`reportOutgoingCall(with:startedConnectingAt:)`](https://developer.apple.com/documentation/callkit/cxprovider/reportoutgoingcall(with:startedconnectingat:)) - (`CallManager+CXProviderDelegate.swift:30-35`). - -**Cancel sites:** -- [`CXAnswerCallAction`](https://developer.apple.com/documentation/callkit/cxanswercallaction) - delegate — first line, before the fulfill handshake starts - (`CallManager+CXProviderDelegate.swift:58-62`). The incoming ring timeout is *replaced* - by the fulfill-answer timeout at this moment. -- `reportOutgoingCallConnected` — stops dialtone + cancels (`CallManager.swift:502-507`). -- [`CXEndCallAction`](https://developer.apple.com/documentation/callkit/cxendcallaction) - delegate — user/system hangup (`CallManager+CXProviderDelegate.swift:99-104`). -- `reportCallEnded` — any external end (`CallManager.swift:563-565`). -- [`providerDidReset`](https://developer.apple.com/documentation/callkit/cxproviderdelegate/providerdidreset(_:)) - — `cancelAllCallTimeouts()` drains the whole map atomically, plus - `FulfillRequestManager.cancelAll()` (`CallManager+CXProviderDelegate.swift:18-26`, - `CallManager.swift:139-161`). - -### Fulfill-answer timeout — `FulfillRequestManager` (actor) - -`ios/Managers/FulfillRequestManager.swift` is an [`actor`](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/#Actors) -holding `pendingRequests: [UUID: PendingRequest]`, where each request bundles the call -ID, a [`CheckedContinuation`](https://developer.apple.com/documentation/swift/checkedcontinuation) -and its own timeout `Task` (`FulfillRequestManager.swift:30-36`). - -The delegate literally **suspends the `CXAnswerCallAction` on the request's outcome** -(`CallManager+CXProviderDelegate.swift:64-94`): - -```swift -let (requestId, resultTask) = await FulfillRequestManager.shared.createRequest( - callId: action.callUUID, timeout: Self.answerCallTimeout) -CallEventEmitter.shared.send(CallAnsweredEvent(id: action.callUUID, requestId: requestId)) -switch await resultTask.value { -case .fulfilled: action.fulfill() -case .cancelled: action.fail() // JS called failIncomingCallConnected -case .timedOut: action.fail() // 30 s elapsed with no JS response -} -``` - -- `createRequest` (`FulfillRequestManager.swift:50-93`) wraps a - [`withCheckedContinuation`](https://developer.apple.com/documentation/swift/withcheckedcontinuation(isolation:function:_:)) - in a `Task` and spawns a sibling timeout task - (`Task.sleep(for: timeout)`); whichever side wins **removes the request from the map - first**, so the continuation is resumed exactly once — the loser finds the map empty - and no-ops. -- `fulfill(requestId:)` (`:102-114`) — removes the entry, cancels the timeout task, - resumes `.fulfilled(callId:)`. Returns `nil` if the request already timed out; the JS - API surfaces that as `fulfillIncomingCallAnswered` resolving `false` - (`ExpoCallKitTelecomModule.swift:365-373`). -- `cancel(requestId:)` (`:121-129`) — same, resumes `.cancelled`. This is - `failIncomingCallConnected` from JS. -- On timeout/cancel the delegate calls [`action.fail()`](https://developer.apple.com/documentation/callkit/cxaction/fail()); - per their docs (`CallManager.swift:481-487`) CallKit then ends the call via - `CXEndCallAction`, which runs the normal cleanup path — so a fulfill timeout does - **not** emit `unanswered`; it surfaces as a failed answer → ended call. - -There is deliberately **no ring timeout restart** after answering: once answered, the -call is either connected (JS fulfilled) or dead (fail/timeout) — never back to ringing. - ---- - -## Android implementation - -### Ring timeouts — `CallManager` coroutines - -State: each call's `CallController` carries a `timeoutJob: Job?` -(`android/.../managers/CallManager.kt:84-90`), launched on a -`CoroutineScope(Dispatchers.Main + SupervisorJob())`. - -`startCallTimeout(id, timeoutMs)` (`CallManager.kt:166-183`): - -```kotlin -cancelCallTimeout(id) // restart-safe -val job = scope.launch { - delay(timeoutMs) // cancellable suspend - val session = CallStore.session(id) ?: return@launch - if (session.status == CallSessionStatus.CONNECTED) return@launch // race guard - DialtonePlayer.stop() - reportCallEnded(id, CallEndedReason.UNANSWERED) -} -activeCalls[id]?.timeoutJob = job -``` - -Two guards the iOS side doesn't need: the session-existence check and the -`status == CONNECTED` check after [`delay`](https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/delay.html) -resumes — belt-and-braces against a connect racing the expiry on the main dispatcher. -Cancellation is [`Job.cancel()`](https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-job/cancel.html) -(`CallManager.kt:186-191`), which aborts the suspended `delay`. - -**Start sites:** -- Outgoing: last step of the Core-Telecom call scope body, after - [`CallsManager.addCall`](https://developer.android.com/reference/androidx/core/telecom/CallsManager#addCall(androidx.core.telecom.CallAttributesCompat,kotlin.coroutines.SuspendFunction1,kotlin.coroutines.SuspendFunction1,kotlin.coroutines.SuspendFunction1,kotlin.coroutines.SuspendFunction1,kotlin.coroutines.SuspendFunction2)) - is up, dialing notification shown, dialtone playing (`CallManager.kt:296`). -- Incoming: inside the incoming call scope, right after `INCOMING_CALL_REPORTED` is - emitted (`CallManager.kt:387-394`). - -**Cancel sites:** -- `onCallAnswered` — shared answer entrypoint for both the system Telecom `onAnswer` - lambda and the in-app `answerCall()` (`CallManager.kt:825`); like iOS, the ring timeout - is replaced by the fulfill request. -- `reportOutgoingCallConnected` — with `DialtonePlayer.stop()` (`CallManager.kt:437-440`). -- `finishCall` — the shared finalizer behind both `endCall` and `reportCallEnded`, which - also runs `FulfillRequestManager.cancelForCall(id)` so **every** end path clears both - mechanisms at once (`CallManager.kt:483-486`). - -Expiry funnels into `reportCallEnded(id, UNANSWERED)` → `finishCall` → Telecom -disconnect via the action channel with a mapped -[`DisconnectCause`](https://developer.android.com/reference/android/telecom/DisconnectCause), -`CALL_REPORTED_ENDED` event to JS with `reason: "unanswered"`, session removal -(`CallManager.kt:464-518`). - -### Fulfill-answer timeout — `FulfillRequestManager` (object) - -`android/.../managers/FulfillRequestManager.kt` is a singleton with two maps -(`requests: requestId→callId`, `timeoutJobs: requestId→Job`) guarded by -`synchronized(lock)` (`:38-44`). Unlike iOS there is no parked action to resume -(Core-Telecom answers via a suspend lambda, not a CXAction), so the request resolves -through an **`onTimeout` callback** instead of a continuation: - -- `onCallAnswered` creates the request and wires expiry straight to - `reportCallEnded(callId, CallEndedReason.FAILED)` (`CallManager.kt:838-841`) — note: - **`failed`, not `unanswered`**, mirroring iOS where a fulfill timeout fails the answer - action rather than reporting an unanswered ring. -- `createRequest` (`FulfillRequestManager.kt:53-81`): launch `delay(timeoutMs)`; on wake, - atomically remove both map entries and only invoke `onTimeout` if the request was still - present — same "remove-first wins" one-shot discipline as the iOS actor. -- `fulfill(requestId)` (`:88-107`): atomically remove + cancel the timeout job; returns - the `callId` or `null` if already timed out (JS `fulfillIncomingCallConnected` then - returns `false`, `CallManager.kt:417-434`). -- `cancel(requestId)` (`:114-124`) and `cancelForCall(callId)` (`:132-149`) cover the - JS-fail path and the call-ended path respectively. - ---- - -## Design details worth copying - -1. **Two mechanisms, not one.** Ring timeouts are per-call cancellable timers owned by - the call manager; the fulfill timeout is a resolution path of the pending answer - request. Keeping them separate keeps every cancel site trivial. -2. **One-shot by construction.** Both platforms make "expire" and "resolve" race safely - by *removing the map entry first* under a lock/actor; whoever removes it owns the - outcome, the other side no-ops. This is exactly the double-settlement problem our - `pendingAnswerRequestIdRef` clear-before-await in `VoipProvider.tsx` solves at the JS - layer — they solve it natively. -3. **Expiry reuses the normal end-call path** (`reportCallEnded`) instead of a bespoke - teardown, so notifications, audio, JS events, and store cleanup can't drift. -4. **Every end path cancels both mechanisms** (Android `finishCall` cancels the ring - timer *and* `cancelForCall`; iOS `providerDidReset` drains both). No leaked timers - after hangup. -5. **Reason semantics:** ring expiry → `unanswered`; fulfill expiry → failed answer - (`CXAnswerCallAction.fail()` on iOS, `CallEndedReason.FAILED` on Android). Callers can - distinguish "nobody picked up" from "picked up but media never connected". -6. **Restart-safe start:** Android's `startCallTimeout` cancels any existing timer for - the id before launching a new one. -7. **Build-time config with native defaults**, so the module behaves sanely even when - the plugin props are omitted. - -## Porting notes for our `packages/react-native-webrtc` - -- We already have the fulfill-answer timeout from the handshake port - (ANSWER-HANDSHAKE-PLAN.md), but hardcoded at 10 s. Making it configurable means: - an Info.plist key read in `ios/RCTWebRTC` (CallKit/Voip managers) and a manifest - `` read in `android/.../voip/` — we don't have an Expo config plugin, so - the app sets these directly (or via `expo.android.manifest`-style config if the - example app adopts one). -- The missing pieces are the **incoming** and **outgoing** ring timeouts: - - iOS: a `[UUID: Task]` map in `CallKitManager.m`'s Swift-adjacent layer is awkward - from ObjC — `NSMapTable` of `dispatch_block_t` created with - [`dispatch_block_create`](https://developer.apple.com/documentation/dispatch/1431052-dispatch_block_create) - + [`dispatch_after`](https://developer.apple.com/documentation/dispatch/1452876-dispatch_after) - and cancelled with [`dispatch_block_cancel`](https://developer.apple.com/documentation/dispatch/1431058-dispatch_block_cancel) - is the ObjC-native equivalent of their cancellable `Task.sleep`. - - Android: our `voip/` controllers already use coroutines around Core-Telecom, so the - `timeoutJob`-per-call pattern maps over almost verbatim. - - Expiry should call our existing native end-call/report-ended path with the - `unanswered` reason we added in the endCall-reasons work, so JS receives it through - the same `onEnded` event `VoipProvider.tsx` already handles. -- Watch the interaction with OUTGOING-CONNECT-PLAN.md: an outgoing ring timeout is only - correct once we stop reporting `connected` immediately (their timeout works *because* - connect reporting is deferred until real media connect — otherwise every outgoing call - would be "connected" instantly and the timeout would never matter, or worse, fire on - a live call if the connected-status guard is missing). diff --git a/examples/mobile-client/voip-call/FCM-COEXISTENCE-PLAN.md b/examples/mobile-client/voip-call/FCM-COEXISTENCE-PLAN.md deleted file mode 100644 index 633636f36..000000000 --- a/examples/mobile-client/voip-call/FCM-COEXISTENCE-PLAN.md +++ /dev/null @@ -1,215 +0,0 @@ -# Plan: FCM messaging-service coexistence (Android) - -> Companion to [FEATURE-IDEAS.md](./FEATURE-IDEAS.md) item #7 (⏸ DEFERRED). That item holds -> the original single-forwarder design; this file adds research into how other VoIP/chat -> SDKs solve the problem (verified 2026-07-10, extended 2026-07-15 with Agora) and a -> phased plan whose primary mechanism is **zero-config on both Expo and bare RN** -> (runtime forwarding), with a dispatcher escape hatch for multiple notification -> libraries and payload interception. - -## Problem (recap) - -Android delivers each FCM message to **exactly one** service per app — the first -`com.google.firebase.MESSAGING_EVENT` match in the merged manifest -([FirebaseMessagingService](https://firebase.google.com/docs/reference/android/com/google/firebase/messaging/FirebaseMessagingService)). -Our `PushNotificationService` claims that slot and consumes everything, so a host app that -also uses expo-notifications / `@react-native-firebase/messaging` / Notifee / OneSignal -loses either its notifications or our calls, plus `onNewToken` on the losing side. -iOS is unaffected — VoIP pushes ride PushKit's separate channel (see FEATURE-IDEAS #7). - -## Research: how other SDKs solve it - -### Twilio Voice React Native SDK — built-in service + opt-out flag + handler API -[@twilio/voice-react-native-sdk](https://github.com/twilio/twilio-voice-react-native) -documents this exact problem since v1.2.1 in -[out-of-band-firebase-messaging-service.md](https://github.com/twilio/twilio-voice-react-native/blob/main/docs/out-of-band-firebase-messaging-service.md): -- Built-in `FirebaseMessagingService` is ON by default (zero-config path). -- Opt-out via a **boolean resource** in the app - (`false` in - `android/app/src/main/res/values/config.xml`) — needed because their service lives in the - **library** manifest and can't simply be un-declared. -- Escape hatch is a JS API — `voice.handleFirebaseMessage(remoteMessage.data)` returns - `Promise` (`true` = it was a Twilio call push) — wired inside - `@react-native-firebase/messaging`'s - [`onMessage`](https://rnfirebase.io/reference/messaging#onMessage) / - [`setBackgroundMessageHandler`](https://rnfirebase.io/reference/messaging#setBackgroundMessageHandler). -- ⚠️ The out-of-band path depends on RNFB **headless JS** in killed state — see - [issue #445](https://github.com/twilio/twilio-voice-react-native/issues/445) (crash - answering from killed state) and - [issue #370](https://github.com/twilio/twilio-voice-react-native/issues/370). - -### Agora — no service at all; developer-owned service + SDK helpers (docs-only) -Agora's RTC/video SDK has **no push layer whatsoever** — for call invitations they point -you at your own signaling + FCM and (for RN) community patterns around -[react-native-callkeep](https://github.com/react-native-webrtc/react-native-callkeep) -with RNFB background messaging ([Agora FAQ](https://docs.agora.io/en/faq/call_invite_notification)). -Agora **Chat** likewise ships no service: the developer writes their own -`FirebaseMessagingService`, overrides `onNewToken`, and calls SDK helpers such as -`ChatClient.getInstance().sendFCMTokenToServer(token)` -([Integrate offline push](https://docs.agora.io/en/agora-chat/develop/offline-push/integrate-test)). -Coexistence is trivially the developer's job — the SDK only provides token/parse -entry points. Sendbird's chat SDK follows the identical pattern -(`isSendbirdMessage(remoteMessage)` + `markAsDelivered`, -[Sendbird FCM docs](https://sendbird.com/docs/chat/sdk/v4/android/push-notifications/multi-device-support/set-up-push-notifications-for-fcm)). - -### Stream (GetStream) Video React Native SDK — no native service, JS helpers -Ships **no** `FirebaseMessagingService`; mandates `@react-native-firebase/messaging` and -exposes JS helpers — discriminator `isFirebaseStreamVideoMessage(msg)` + processor -`firebaseDataHandler(msg.data)` — called inside `onMessage` / `setBackgroundMessageHandler` -([push docs](https://getstream.io/video/docs/react-native/incoming-calls/other-than-ringing-setup/react-native/)). -Perfect coexistence by construction, but killed-state handling rides RNFB's headless JS -task — slower to post the CallStyle notification and subject to OEM battery kills. - -### expo-callkit-telecom — inheritance, single hard-coded partner -`ExpoCallKitTelecomMessagingService extends ExpoFirebaseMessagingService` -(expo-notifications' service); non-call messages go to `super.onMessageReceived()`; config -plugin strips expo-notifications' own service with -[`tools:node="remove"`](https://developer.android.com/build/manage-manifests#node_markers). -Zero config for Expo apps, but compile-time-coupled to expo-notifications only; not -transferable to bare RN. - -### Pattern summary - -| | Native service? | Multiple PN libs? | Payload interception? | Killed-state robustness | -|---|---|---|---|---| -| Twilio Voice RN | yes + opt-out flag | ✅ via JS dispatcher | ✅ JS | ✅ native default / ⚠️ JS out-of-band | -| Agora (RTC & Chat) | no — developer-owned | ✅ by construction | ✅ native (yours) | ✅ native (yours) | -| Stream Video RN | no | ✅ via JS dispatcher | ✅ JS | ⚠️ headless JS only | -| expo-callkit-telecom | yes (subclass) | ❌ expo-notifications only | ❌ | ✅ native | -| **Ours (planned)** | yes + trivial opt-out | ✅ native dispatcher (+ opt. fwd) | ✅ native | ✅ native in every mode | - -**Industry consensus:** a payload discriminator + a public `handleMessage(...): Boolean` -helper + a **developer-owned dispatcher service** is the standard answer (Twilio, Agora, -Sendbird, Stream all converge on it — differing only in whether the helper is JS or -native and whether a default service ships at all). - -## What we already have (checked 2026-07-15) - -Two facts make a **docs-first** solution unusually cheap for us: - -1. **Our service is declared in the *app* manifest** (bare-RN snippet + `withFishjamVoip.ts`), - not the library manifest. So "opting out" is just *not declaring it* (or a plugin prop) — - we don't need Twilio's boolean-resource trick at all. -2. **The native entry points are already public**: `CallManager.reportIncomingCall(...)` - (`CallManager.kt:122`, public `object`) and `VoipPushRegistry.updateToken` / - `reportIncoming` / `bufferWaitingIncoming` (`VoipPushRegistry.kt`, public `object`). - A host-owned service *could* already call them — but the dispatch logic in - `PushNotificationService.onMessageReceived` is no longer trivial (parses - `roomName`/`displayName`/`handle`/`isVideo`/`avatarUrl`, handles the - `IncomingCallSlot.CURRENT/WAITING/REJECTED` call-waiting outcomes, private - `warmUpReact()`), so a copy-paste recipe would rot. **One small helper fixes that.** - -Note the payload already has a natural discriminator: a message is "ours" iff it carries -`roomName`. An explicit `voip: "true"` flag stays optional polish, not a prerequisite. - -## Plan — phased - -> **Constraint that sets the order (2026-07-15): must be zero-friction on BOTH Expo and -> bare RN.** The dispatcher pattern requires the user to write a Kotlin service — fine in -> bare RN, but Expo (CNG/prebuild) apps have no `android/` folder; they'd need a local -> module or custom plugin (Twilio's Expo support is an open issue, -> [#496](https://github.com/twilio/twilio-voice-react-native/issues/496)). Runtime -> forwarding is the only mechanism with zero user code on both, so it is **Phase 1**; -> the dispatcher helpers become the documented escape hatch for bare-RN power users. - -### Phase 1 (primary ship): discriminator gate + runtime forwarding + public helpers -The original FEATURE-IDEAS #7 design, now the core mechanism because it is the only -Expo-and-bare-compatible zero-config option: -- `PushNotificationService` handles only messages carrying the VoIP discriminator - (`roomName` today; explicit `voip: "true"` optional polish). -- Non-VoIP messages **and** token callbacks are forwarded to the next `MESSAGING_EVENT` - service found via - [`queryIntentServices`](https://developer.android.com/reference/android/content/pm/PackageManager#queryIntentServices(android.content.Intent,%20int)) - (reflective instantiation + `attachBaseContext`) — works with expo-notifications, RNFB, - Notifee, OneSignal… with no compile-time coupling and no user code. Forward to the - *next* service only (fan-out risks duplicate handling). The forwarded library keeps its - own native killed-state behavior — its real service code runs, just invoked by us. -- Expo: `withFishjamVoip` already injects our service → coexistence with - expo-notifications works with **no new config**. Bare RN: the README manifest snippet, - same result. Add `android:priority="1"` in both. - -**Also in Phase 1 (~20-line refactor the forwarding needs anyway):** extract the public -companion helpers on `PushNotificationService`: - -```kotlin -companion object { - /** Returns true iff the message was a Fishjam VoIP push and was handled. */ - fun handleVoipMessage(context: Context, message: RemoteMessage): Boolean - fun handleNewToken(token: String) -} -``` - -The service's own overrides become one-liners delegating to the helpers, so example-app -behavior is unchanged and there is nothing new to test beyond compilation. - -**Documentation:** a "Using other push-notification libraries" section in the SDK README / -example README §9. The zero-config forwarding covers the common case; this documents the -**escape hatch** for bare-RN apps with several push SDKs or payload-interception needs: - -```kotlin -// The app's single messaging service — replaces the SDK's service in the manifest. -class MyMessagingService : FirebaseMessagingService() { - override fun onMessageReceived(message: RemoteMessage) { - if (PushNotificationService.handleVoipMessage(this, message)) return - // full interception point — inspect/route message.data however you like - MyChatSdk.handle(message) // chain any number of other SDKs - } - override fun onNewToken(token: String) { - PushNotificationService.handleNewToken(token) - MyChatSdk.onNewToken(token) - } -} -``` - -- Document the two setups: (a) default — our service + automatic forwarding, zero config - on Expo and bare RN alike; (b) advanced — declare *your* service instead and call the - helper first (bare RN directly; Expo via a local module or custom plugin). -- Document why the chain must be **native**, not JS: a killed app receives the FCM - wake-up before any JS runs, and the CallStyle notification must post within Telecom's - window (this is where Twilio's JS out-of-band route shows cracks — #445). -- Warn about libraries that auto-register a service from their **library** manifest - (RNFB messaging does): the app-manifest service wins the merge - ([merge priorities](https://developer.android.com/build/manage-manifests#merge_priorities)); - `android:priority="1"` (or `tools:node="remove"` on the library one) makes it explicit. - -**Expo plugin:** add `android.voipMessagingService?: boolean` (default `true`) to -`withFishjamVoip.ts` so users bringing their own dispatcher can skip injecting our -service. (Later, if demand shows: a plugin mode that *generates* the dispatcher service -for Expo apps with a configurable delegate list.) - -This single phase beats every surveyed SDK: zero-config coexistence on both Expo and -bare RN (none of them offer that), plus the industry-standard dispatcher escape hatch. - -### Phase 2 (optional polish) -- Explicit `voip: "true"` payload flag (server + docs) as a cleaner discriminator than - "has `roomName`". -- JS-level `handleVoipMessage(data)` wrapper for RNFB-centric apps (Twilio-style), shipped - **explicitly second-class** with the killed-state caveat documented. - -## Implementation checklist - -Phase 1: -- [ ] `PushNotificationService.kt`: discriminator gate; runtime forwarding - (`nextMessagingService()` via `queryIntentServices`, reflective `attachBaseContext`) - for unhandled messages + `onRegistered`/`onNewToken`; extract public companion - helpers -- [ ] SDK / example README §9: "Using other push-notification libraries" section - (zero-config default, dispatcher recipe, native-not-JS rationale, manifest-merge note) -- [ ] example `AndroidManifest.xml` + README snippet + `withFishjamVoip.ts`: - `android:priority="1"`; `android.voipMessagingService` prop (default `true`) -- [ ] Compile check: `JAVA_HOME=~/.sdkman/candidates/java/17.0.18-zulu ./gradlew - :fishjam-cloud_react-native-webrtc:compileDebugKotlin` (from `examples/mobile-client/voip-call/app/android`) -- [ ] Device test with a second messaging service registered (e.g. RNFB or - expo-notifications) in both winner orders - -iOS: **no change in any phase** — PushKit isolation verified (FEATURE-IDEAS #7); a payload -gate on iOS would violate the report-a-call rule -([pushRegistry(_:didReceiveIncomingPushWith:for:completion:)](https://developer.apple.com/documentation/pushkit/pkpushregistrydelegate/pushregistry(_:didreceiveincomingpushwith:for:completion:))). - -## Open questions - -- Should `handleVoipMessage` also accept a raw `Map` overload for hosts - that transform payloads before dispatch? -- Bare-RN docs currently instruct declaring our service directly — should the coexistence - section become the *primary* documented path (dispatcher-first, like Agora), with our - service as the convenience shortcut? diff --git a/examples/mobile-client/voip-call/FEATURE-IDEAS.md b/examples/mobile-client/voip-call/FEATURE-IDEAS.md deleted file mode 100644 index c83719eba..000000000 --- a/examples/mobile-client/voip-call/FEATURE-IDEAS.md +++ /dev/null @@ -1,329 +0,0 @@ -# VoIP feature ideas (from expo-callkit-telecom gap analysis) - -Comparison of `~/Desktop/expo-callkit-telecom` against our `packages/react-native-webrtc` -VoIP layer (`CallKit.ts` / `Telecom.ts` / `VoIP.ts`, `useVoIPEvents` / `useCallKit` / -`useTelecom`) and its native code (`ios/RCTWebRTC/CallKitManager.m`, `VoipManager.m`, -`android/.../voip/*`). Sorted by priority. Analysis: 2026-07-09 (two passes). - -See the bottom for a **mapping to the FCE-3435 task** ("Handle common VoIP patterns"). - -Legend: 🔴 correctness / product quality · 🟠 user-visible polish · 🟡 API maturity · ⚪ DX/docs - ---- - -## P0 — Correctness wins 🔴 - -### 1. Proper answer/connect handshake (fulfill/fail instead of instant-fulfill) -- **Ours:** iOS `performAnswerCallAction` fulfills the `CXAnswerCallAction` immediately - (`CallKitManager.m:160`), so the system UI shows "connected" before WebRTC media exists, - and a failed room-join can never be reported back to CallKit. -- **Theirs:** the action is parked in a `FulfillRequestManager` keyed by `requestId`; - JS connects media then calls `fulfillIncomingCallConnected(requestId)` or - `failIncomingCallConnected()` (fails the CXAction → CallKit tears the call down cleanly), - with a configurable timeout (default 30 s). The iOS delegate literally `await`s the - fulfill/cancel/timeout result before calling `action.fulfill()` / `action.fail()` - (`CallManager+CXProviderDelegate.swift:58-95`). Same pattern on Android. -- Docs: [CXAnswerCallAction](https://developer.apple.com/documentation/callkit/cxanswercallaction) - -### 2. Real outgoing-call connection reporting (+ dialtone hook) -- **Ours:** `startCallWithDisplayName` reports `startedConnecting` and `connected` - back-to-back the moment the transaction succeeds (`CallKitManager.m:76-77`), so the - system call timer starts before the remote party answers. -- **Theirs:** report `startedConnecting`, play optional dialtone (started from - `didActivate audioSession` only while the outgoing call is still `connecting`), and only - report connected when the app calls `reportOutgoingCallConnected(id)`. Correct call - duration in the dynamic island / status bar / Recents. -- Docs: [reportOutgoingCall(with:connectedAt:)](https://developer.apple.com/documentation/callkit/cxprovider/reportoutgoingcall(with:connectedat:)) - -### 3. Call timeouts (incoming / outgoing / fulfill-answer) — ✅ DONE (verified 2026-07-15) -- **Ours:** none — an unanswered incoming call rings until something external stops it. -- **Theirs:** three configurable timeouts (incoming 45 s, outgoing 60 s, fulfill 30 s) - baked into Info.plist / manifest metadata by the config plugin; expiry auto-ends the - call with reason `unanswered` and stops the dialtone. -- **Status:** built on both platforms. iOS `CallKitManager.m:8-10` - (`kDefaultIncomingCallTimeout=45`, `kDefaultOutgoingCallTimeout=60`, - `kDefaultFulfillAnswerTimeout=10`, overridable via Info.plist keys - `VoipIncomingCallTimeout` / `VoipOutgoingCallTimeout` / `VoipFulfillAnswerTimeout`); - Android `CallManager.kt:54-56` (same 45/60/10 s defaults, `ringTimeoutJob` / - `waitingRingTimeoutJob`). Note: our fulfill default is **10 s**, not the 30 s above. - -### 4. `serverCallId` + opaque `metadata` in the push payload, with dedup — ⚠️ PARTIAL (verified 2026-07-15) -- **Ours:** payload hardwired to `roomName` / `displayName` / `isVideo`; any new field - requires native changes on both platforms. No dedup — a duplicate FCM send - double-reports the call on Android. -- **Theirs:** `{ eventId, serverCallId, hasVideo, startedAt, caller: {...}, metadata }` - wrapped under a canonical `incomingCall` key (snake_case `incoming_call` accepted for - back-compat). `metadata` is forwarded verbatim (auth token, chatId, room config…). - `eventId` drives a dedup window on Android (`ExpoCallKitTelecomMessagingService.kt`) - and `Equatable` on iOS. `startedAt` lets the call be backdated. Documented payload - shape in `docs/voip-push.md`. -- **Status — asymmetric, remaining work is Android + dedup:** - - ✅ **iOS opaque passthrough done:** `VoipManager.m:75` mutable-copies and forwards the - *entire* `payload.dictionaryPayload`, surfaced to JS via `getPendingIncomingCall()` - (`VoIP.ts:32`) — so `serverCallId` / arbitrary `metadata` already ride through with no - native change. - - ❌ **Android still hardwired:** `PushNotificationService.kt:27-31` extracts only - `roomName` / `displayName` / `handle` / `isVideo` into a typed - `VoipPushRegistry.Incoming`; any extra `metadata` field is dropped. Needs an - opaque-map passthrough to reach JS parity with iOS. - - ❌ **Dedup not implemented** on either platform (no `eventId` window); no canonical - `incomingCall` wrapper key. - -### 5. Killed-app decline broadcast (Android) -- **Ours:** if the user declines while the app process is dead, no JS ever runs and the - decline is silently dropped — the caller keeps ringing until a server timeout. -- **Theirs:** a package-internal broadcast carrying the full event JSON (embedding the - session, so `serverCallId` + push `metadata` like a decline auth token are available - with zero app-side state) fires when a call event can't reach a live JS observer. - The host app registers a manifest `BroadcastReceiver` (plugin prop - `androidEventReceiver`) and POSTs the decline to the backend. Their test-push script - even demos it: `--metadata '{"declineToken":"abc"}'`. See their `docs/platform-notes.md`. - -### 6. End reasons (`CallEndedReason`) — ✅ DONE (verified 2026-07-15) -- **Ours:** bare `endCall()`, no reasons anywhere. -- **Theirs:** `reportCallEnded(id, reason)` with - `failed | remoteEnded | unanswered | answeredElsewhere | declinedElsewhere`, mapped to - [CXCallEndedReason](https://developer.apple.com/documentation/callkit/cxcallendedreason) - — correct "missed" vs "declined" in Recents, and multi-device "answered elsewhere" - becomes expressible. -- **Status:** built. `endCall(reason?)` / `endCallKitSession(reason?)` take a - `CallEndedReason` (`Telecom.ts:40`), iOS maps it in `cxEndedReasonForReason` - (`CallKitManager.m:242`) and Android in `reasonToCause` / `causeToReason` - (`CallManager.kt:216-232`, both directions). Our shipped reason set is - `local | rejected | missed | remote | answeredElsewhere | failed` — the names differ - from theirs above (`missed`≈`unanswered`, `remote`≈`remoteEnded`, no separate - `declinedElsewhere`; `rejected` is Android-only, folds to `local` on iOS). - -### 7. FCM messaging-service coexistence (Android) — ⏸ DEFERRED -Implemented and verified (compiled) on 2026-07-09, then reverted — to be revisited. -The design below is ready to re-apply. **Extended plan** (research into Twilio / Stream / -expo-callkit-telecom approaches + multi-library support and payload interception): -see [FCM-COEXISTENCE-PLAN.md](./FCM-COEXISTENCE-PLAN.md). - -- **Problem:** `PushNotificationService` extends `FirebaseMessagingService` directly and - silently swallows non-VoIP data messages (`onMessageReceived` returns early). Android - routes `MESSAGING_EVENT` to **one** service per app — so a host app that also uses - `expo-notifications` / react-native-firebase / Notifee either loses its notifications - or our calls. A production blocker for any app with normal pushes. -- **Why routing is deterministic (not an unknown):** our service is declared in the - **app** manifest (bare setup + `withFishjamVoip.ts`), while notification libraries - declare theirs in their **library** manifests; the manifest merger puts app-declared - components first and FCM binds the first `MESSAGING_EVENT` match — so ours always - receives first. Adding `android:priority="1"` to our intent-filter makes this a hard - guarantee instead of a merge-order convention. We do NOT need to know which - notification library the user has. -- **Designed fix** (was in `PushNotificationService.kt`): - 1. VoIP pushes must carry `voip: "true"` in the FCM data (breaking: server must add - it — one line in `server/main.ts` + payload docs in both READMEs). - 2. Non-VoIP messages and `onNewToken` refreshes are relayed to the *next* - `MESSAGING_EVENT` service found via `packageManager.queryIntentServices` — - discovered at runtime, so it works with any notification library, no Expo - dependency (their `super`-delegation trick only works for expo-notifications). - The delegate is instantiated reflectively with the app context attached - (`ContextWrapper.attachBaseContext` via reflection — public SDK method). - 3. Companion helpers `handleVoipMessage(context, message): Boolean` / - `handleNewToken(token)` for the inverse setup where the host insists on keeping - its own service registered and delegates the VoIP path to us. -- **iOS verified, no change needed:** VoIP pushes ride PushKit (`apns-push-type: voip`, - topic `.voip`) — a channel separate from regular APNs; `VoipManager` registers - only `PKPushTypeVoIP` and the pod never touches `UNUserNotificationCenter` / - `didReceiveRemoteNotification`, so no cross-interception is possible in either - direction. A payload gate on iOS would actually be harmful: iOS 13+ kills apps that - receive a VoIP push without reporting a CallKit call. - ---- - -## P1 — User-visible polish 🟠 - -### 8. Rich caller identity: `CallParticipant` + avatar pictures -- **Theirs:** remote party is `{ id, displayName, avatarUrl, phoneNumber, email }`; - we pass a bare display-name string. -- **Avatar on the Android full-screen UI:** their `IncomingCallActivity` downloads - `avatarUrl` (5 s connect/read timeouts, circular crop via - `RoundedBitmapDrawableFactory`, silent initials fallback). Ours already has the - initials circle — drop-in spot for the photo. -- **Avatar in the Android notification (our idea — neither library does it):** set the - downloaded bitmap as the [`Person.Builder.setIcon`](https://developer.android.com/reference/androidx/core/app/Person.Builder#setIcon(androidx.core.graphics.drawable.IconCompat)) - on the CallStyle notification → caller's face in the shade + lock-screen banner, - WhatsApp-style. Needs an async fetch + `notify()` update once the bitmap lands - (post the text-only notification first to stay within Telecom's ~5 s window). -- **iOS caveat:** CallKit's system UI cannot render caller images — `avatarUrl` is still - worth carrying in the session for our own in-app UI. - -### 9. App logo in the iOS CallKit UI (our idea — neither library does it) -- Set [`CXProviderConfiguration.iconTemplateImageData`](https://developer.apple.com/documentation/callkit/cxproviderconfiguration/icontemplateimagedata) - — a 40×40 pt template (alpha-mask) PNG shown as the app button/badge in the native - incoming-call and in-call UI. Without it iOS shows a generic blank button. - Also worth setting `localizedName` explicitly. Ships as a bundled asset; could be a - config-plugin prop (`callkitIconIos`) later. -- Android equivalent exists in their full-screen activity: an app-branding row - (app icon + label from `PackageManager`) at the top of the incoming screen. - -### 10. Custom sounds: ringtone (iOS + Android) and outgoing dialtone -- **iOS ringtone:** bundled sound on - [`CXProviderConfiguration.ringtoneSound`](https://developer.apple.com/documentation/callkit/cxproviderconfiguration/ringtonesound). -- **Android ringtone:** set as the incoming channel sound; channel settings are immutable - after creation, so they embed the ringtone name in the channel id and rotate channels - on config change (`CallNotificationManager.kt` in their repo) — copy that trick. -- **Dialtone:** looping fade-in ringback while an outgoing call connects - (`DialtonePlayer` on both platforms), stopped on connect/timeout/end. We are silent. - -### 11. iOS Recents + Siri ("call X") integration -- `includesCallsInRecents = true` (we set `NO`), handles from `phoneNumber`/`email` as - typed [`CXHandle`](https://developer.apple.com/documentation/callkit/cxhandle)s - (enables Contacts matching → name + photo on the lock screen via the contact card), - [`INStartCallIntent`](https://developer.apple.com/documentation/sirikit/instartcallintent) - handling in the app delegate → `CallIntentReceivedEvent { handle, handleType, hasVideo }`, - `NSUserActivityTypes` registration, and an `outgoingSystem` session origin. - -### 12. Android notification lifecycle: dialing + ended states -- **Theirs:** four states — incoming (ring + FSI), **dialing** ("Dialing…" with hangup - for outgoing; we show nothing while dialing), ongoing (chronometer), and **ended** - (brief "Call ended", auto-dismiss after 2 s). We only do incoming → ongoing. -- Bonus neither has: i18n / copy overrides for channel names and notification strings - ("Incoming video call", "Dialing…") — all hardcoded English in both libraries. - -### 13. Incoming-call activity refinements (Android) — *found on second pass* -Our `IncomingCallActivity` (swipe-to-answer) is nicer visually, but theirs has behaviors -worth porting: -- **Keyguard dismissal:** answer → `KeyguardManager.requestDismissKeyguard` with a - callback, launching the main activity on success/cancel/error. The call is answered - *before* unlock ("audio connects before the device is unlocked", matching iOS). -- **Auto-dismiss via session state:** observes the call-store `Flow` and finishes when - status leaves `RINGING` (answered elsewhere, timeout, remote hangup) + re-checks in - `onResume`. Ours relies on a single `ACTION_CALL_ENDED` broadcast. -- **Video-call affordance:** answer button switches to a videocam icon for video calls. - ---- - -## P2 — API maturity 🟡 - -### 14. Call-session model + store events -- Native `CallStore` of `CallSession { id, options, origin, remoteParticipants, - incomingCallEvent, status, connectedAt, isMuted, isOnHold, dtmfDigits }` with lifecycle - `requesting → ringing/connecting → connected → ended`; - `onCallSessionAdded/Updated/Removed` events + `getActiveCallSession()`. - Ours: two booleans. -- Their example distills this into a ~25-line `useCallSession()` hook (hydrate from - `getActiveCallSession()`, then the three listeners) — the exact shape a `useVoIP` - SDK hook could return. -- Also: `providerDidReset` does full cleanup (stop dialtone, cancel all timeouts, - cancel pending fulfill requests, clear store); ours only nulls the call UUID. - -### 15. Event queueing with `meta: { flushed, timestamp }` -- Per-event queues with limits (1 for most, 0 = drop for stateful ones like mute), - flushed on `startObserving`, every event stamped with meta so JS can tell replayed - cold-start events from live ones. Ours: a single `pendingIncomingCall` slot plus - ad-hoc `isCallAnswered()` re-checks. - -### 16. Mid-call controls -- **Hold:** `setHeld(id, onHold)` + `SetHeldActionEvent` + `isOnHold` on the session. - On Android the core-telecom `onSetActive` / `onSetInactive` callbacks are mapped to - held-state events too — so a **cellular call taking over the device** properly puts - the VoIP call on hold and JS hears about it (`CallManager.kt:699-700` in their repo). - Our iOS delegate has `onCallHeld` but no JS initiator; our Android `CallAction.Hold` - is unreachable from JS and `onSetInactive` is an empty block. -- **DTMF:** `playDTMF(id, digits)` + `DTMFEvent` via - [CXPlayDTMFCallAction](https://developer.apple.com/documentation/callkit/cxplaydtmfcallaction). -- **Video upgrade/downgrade:** `reportVideo(id, enabled)` → session update + audio-session - re-prepare + [`CXCallUpdate`](https://developer.apple.com/documentation/callkit/cxcallupdate) - so the system UI flips audio↔video mid-call. Our `isVideo` is frozen at start. -- **Programmatic answer:** `answerCall(id)` drives a real `CXAnswerCallAction` so - answering from in-app UI keeps CallKit in sync (our iOS has no programmatic answer). -- **Programmatic mute:** `setMuted(id, muted)` via `CXSetMutedCallAction` with - state-dedup, so CallKit's mute button and the in-app one never diverge. - -### 17. Audio session APIs -- `prepareAudioSessionForCall(hasVideo)` / `restoreAudioSession()` — snapshot & restore - the pre-call [AVAudioSession](https://developer.apple.com/documentation/avfaudio/avaudiosession) - config (snapshot-once semantics so repeated prepares don't clobber the saved state; - video → speaker default via `.defaultToSpeaker` + `videoChat` mode, audio → earpiece - via `voiceChat` mode). -- `getAudioSession()` — one cross-platform snapshot: active flags, category/mode/options, - sample rate, mic permission, `isOtherAudioPlaying`, current + available routes with a - normalized port-type enum (`builtInReceiver`, `bluetoothHFP`, `carAudio`, `airPlay`…). -- `setAudioSessionPortOverride(speaker)` — one-call speakerphone toggle. -- `onAudioSessionActivated/Deactivated` (with the affected call ids) and - `onAudioRouteChanged` — driven by a `NotificationCenter` route-change observer, so - route events (AirPods in/out) flow even outside calls. -- We already have `RTCAudioSession.ts` / `useAudioOutput`; the gap is the snapshot/restore - pair, the unified snapshot, and call-linked activation events. - -### 18. VoIP token API shape -- `getVoIPPushToken()` returns `{ token, type: "APNS_VOIP" | "FCM" }` so the backend - knows the transport; a token-**invalidated** event (`token: undefined`) — our iOS - `didInvalidatePushTokenForType` nulls the token and JS never learns; and a small - `useVoIPPushToken()` hook. - -### 19. Malformed-push fallback (iOS) -- PushKit requires reporting a call for every VoIP push or the app is terminated. - On parse failure they report a placeholder "Invalid Call" and immediately end it - (`VoIPPushManager+PKPushRegistryDelegate.swift`). Ours defaults the display name but - JS-side `assertRoomName` can still leave a stuck session. - -### 20. Capture-session info -- `getCaptureSession()`: camera permission + - [`isMultitaskingCameraAccessSupported`](https://developer.apple.com/documentation/avfoundation/avcapturesession/ismultitaskingcameraaccesssupported) - (iOS 16+) — relevant for keeping video capture alive in PiP during CallKit calls. - ---- - -## P3 — DX & docs ⚪ - -### 21. Expo config plugin -- Automates what we document as manual steps: `aps-environment` entitlement, - `UIBackgroundModes` (`voip`, `audio`), mic/camera permission strings (customizable), - Siri intents, timeout constants, sound bundling into Xcode + `res/raw` (with raw-name - sanitization), FCM service manifest surgery (`tools:node="remove"`), `MANAGE_OWN_CALLS` - wiring, `androidEventReceiver` registration. -- Docs: [Expo config plugins](https://docs.expo.dev/config-plugins/introduction/) - -### 22. Test-push server script — *found on second pass* -- `example/server/send-test-push.ts`: zero-dependency (node:crypto + node:http2 + fetch) - script that signs an APNs JWT and an FCM OAuth token from key files and sends the same - `IncomingCallEvent` over both transports, with flags for `--video`, `--display`, - `--phoneNumber` (E.164 → contact matching), `--metadata '{...}'`, `--production`, - `--ios/--android` auto-detection. Our `examples/mobile-client/voip-call/server` could - adopt the flag ergonomics + shared event-builder (`lib/event.ts`) structure. - -### 23. Structured native logging -- Category-based [`os.Logger`](https://developer.apple.com/documentation/os/logger) on - iOS, tagged lazy-lambda logging on Android — vs our `NSLog` / silently-swallowed - exceptions. - -### 24. Docs & discoverability -- Typedoc-generated API reference, docs site, `llms.txt`, documented push-payload shape, - and a keep-alive note (pair with native timers, e.g. - [react-native-nitro-keepalive-timer](https://www.npmjs.com/package/react-native-nitro-keepalive-timer), - for background WebSocket signaling) — directly relevant to our deferred - WebSocket-signaling design. - ---- - -## Mapping to FCE-3435 — "Handle common VoIP patterns" - -Task: make the server aware of the call; production-ready example. - -| Task bullet | Relevant items above | -| --- | --- | -| **WebSocket connection to monitor the call** | Nothing in expo-callkit-telecom does signaling itself — but: background JS timers throttle once the screen locks, so the socket heartbeat needs native keep-alive timers (#24); `serverCallId` + `metadata` (#4) is how the push hands the socket URL/auth to the app; `onIncomingCallReported` fires *before* answer, which is the right moment to open the socket early. | -| **Management token to create rooms (drop sandbox)** | Server-side only — no library feature. Their `example/server` structure (#22) is the pattern: env-validated key files + a shared event builder producing the push payload with `serverCallId` minted at room-creation time. | -| **Inform user if call was rejected** | Callee-side rejection reaching the server even when the app is killed: decline broadcast (#5) with `metadata.declineToken`. Caller-side display of the rejection: `reportCallEnded(id, 'remoteEnded'/'declinedElsewhere')` with `CallEndedReason` (#6) so the system UI ends with the right reason, plus the transient "Call ended" notification state (#12). Multi-device: `answeredElsewhere`/`declinedElsewhere` (#6). | -| **Handle hang up** | The `endCall()` (local user) vs `reportCallEnded(reason)` (remote/server signal) split (#6); terminal events embed the full session (#14) so the hang-up handler has `serverCallId` without extra state; outgoing/incoming timeouts (#3) as the safety net when the socket dies mid-call. | -| **Handle call on-hold** | `setHeld` + `SetHeldActionEvent` + `isOnHold` on the session (#16) — including Android's `onSetActive`/`onSetInactive` mapping so a cellular call auto-holds the VoIP call and JS can pause media + tell the server. | -| **"Almost production-ready example"** | FCM service coexistence (#7) — without it, any consumer app that also shows normal notifications breaks; answer/connect handshake (#1) so a failed room-join doesn't leave a phantom "connected" call; dedup (#4); their example's `useCallSession` / pending-`requestId` hook patterns (#14, #1). | - ---- - -## Keep (things we have that expo-callkit-telecom lacks) - -Don't regress these while adopting the above: -- Swipe-to-answer custom full-screen `IncomingCallActivity`. -- Foreground service with `mediaProjection` type → screen share during calls. -- React warm-up on FCM push (`PushNotificationService.warmUpReact()`). -- Telecom-owned audio-routing replay for cold starts - (`CallManager.setAudioOutputManager`). - -Both Android sides use Jetpack -[core-telecom](https://developer.android.com/jetpack/androidx/releases/core-telecom), -so ports are mostly straightforward. diff --git a/examples/mobile-client/voip-call/HOLD-MULTICALL-RECENTS-PLAN.md b/examples/mobile-client/voip-call/HOLD-MULTICALL-RECENTS-PLAN.md deleted file mode 100644 index d48a23e95..000000000 --- a/examples/mobile-client/voip-call/HOLD-MULTICALL-RECENTS-PLAN.md +++ /dev/null @@ -1,482 +0,0 @@ -# Hold, multiple calls, Recents & Siri — research + implementation plan - -> Covers four related asks: (1) on-hold support, (2) multiple simultaneous incoming -> calls with the ability to dismiss one of them, (3) calls appearing in iOS Recents, -> (4) an assessment of expo-callkit-telecom's Siri integration. Verified against -> `~/Desktop/expo-callkit-telecom` source and our tree on 2026-07-13. Companion to -> [CALL-TIMEOUTS-RESEARCH.md](./CALL-TIMEOUTS-RESEARCH.md) / -> [CALL-TIMEOUTS-PLAN.md](./CALL-TIMEOUTS-PLAN.md) — Phase 3 below supersedes part of -> that plan's timer design (called out explicitly where it does). - -## TL;DR verdicts - -| Ask | Verdict | -|---|---| -| Hold | Do it — and it's not just a feature: **without it, media keeps flowing while the OS holds our call for a cellular interruption** (real bug on both platforms today). Phase 1, moderate effort. | -| Multiple incoming calls | Possible, but it is **not** `supportsGrouping`/`supportsUngrouping` — those gate conference-merge UI and must stay off. The real enablers are `maximumCallGroups=2` + hold + per-call identity through the whole stack. Phase 3, large, breaking API change. | -| Recents | `includesCallsInRecents = YES` is one line; making the Recents entry *tappable-to-call-back* requires the call-intent plumbing. Phase 2, small-to-moderate. | -| Siri | **Free byproduct of Phase 2, don't invest beyond that.** Their entire "Siri integration" is the same `INStartCallIntent` handler Recents uses + 3 Info.plist entries. Real Siri usefulness needs phone/email handles we don't have (FEATURE-IDEAS #4 dependency). | - ---- - -# Part I — Research - -## How expo-callkit-telecom does hold - -**iOS.** Their JS `setHeld(id, onHold)` (`ExpoCallKitTelecomModule.swift:463-471`) requests a -[`CXSetHeldCallAction`](https://developer.apple.com/documentation/callkit/cxsetheldcallaction) -transaction through `CXCallController` (`CallManager.swift:661-681`). The provider -delegate (`CallManager+CXProviderDelegate.swift:141-154`) then updates the session -store and emits `SetHeldActionEvent` to JS, and JS is responsible for actually pausing -media. Note their config sets `supportsHolding = false` (`CallManager.swift:27`) — a -deliberate choice: app-driven hold works via transactions regardless, but the *system* -never holds their call (an incoming cellular call offers only "End & Accept", never -"Hold & Accept"). - -**Android.** `setHeld(id, onHold)` (`CallManager.kt:631-645`) sends `setInactive`/ -`setActive` into the per-call action channel → -[`CallControlScope.setInactive()`](https://developer.android.com/reference/androidx/core/telecom/CallControlScope#setInactive()) / -`setActive()` (`:741, :738-740`), updates the store, and emits `SET_HELD_ACTION`. -Crucially, they also wire the **externally-initiated** direction: the `addCall` -lambdas `onSetActive = { setHeld(id, false) }` and `onSetInactive = { setHeld(id, true) }` -(`:699-700`) — this is what fires when *Telecom itself* holds the call because another -app's call went active. All their calls declare -[`CallAttributesCompat.SUPPORTS_SET_INACTIVE`](https://developer.android.com/reference/androidx/core/telecom/CallAttributesCompat#SUPPORTS_SET_INACTIVE) -(`:265, :374`). - -**Multiple calls: they don't.** Hard single-session guards on both platforms -(`CallManager.swift:216-220`, `CallManager.kt:318-325`), `maximumCallGroups = 1` -(`CallManager.swift:59-60`). So Phase 3 below goes beyond their library — but their -per-call plumbing (`activeCalls[UUID]` map, `launchCallScope`, `CallManager.kt:676-748`) -is the exact shape a multi-call refactor needs, which is presumably why it's built -that way. - -## How they do Recents + Siri (one mechanism, not two) - -1. **Recents entries**: `includesCallsInRecents = true` - ([`CXProviderConfiguration.includesCallsInRecents`](https://developer.apple.com/documentation/callkit/cxproviderconfiguration/includescallsinrecents), - `CallManager.swift:62`) plus *meaningful handles*: `makeHandle` prefers - `phoneNumber` → `email` → generic participant id (`CallManager.swift:166-174`). - Their TS docs are explicit that a phone-number handle is what "enables Recents and - Siri" (`Calls.types.ts:95`). -2. **Recents tap / Siri "call Jane using "**: both arrive as an - [`INStartCallIntent`](https://developer.apple.com/documentation/sirikit/instartcallintent) - inside `application(_:continue:restorationHandler:)`. Their - `AppDelegateSubscriber.swift:31-92` unwraps `userActivity.interaction?.intent`, - accepts `INStartCallIntent` plus the deprecated-but-still-delivered - `INStartAudioCallIntent`/`INStartVideoCallIntent`, extracts the first - [`INPerson`](https://developer.apple.com/documentation/sirikit/inperson)'s - `personHandle`, and emits `CallIntentReceivedEvent { handle, handleType, hasVideo }` - to JS (queue-buffered for cold start, `AppDelegateSubscriber.swift:20`). JS then - starts the outgoing call itself — sessions started this way get origin - `outgoingSystem` (`Calls.types.ts:76`, `CallManager.swift:208`). -3. **The config plugin part**: adds the three intent class names to - [`NSUserActivityTypes`](https://developer.apple.com/documentation/bundleresources/information-property-list/nsuseractivitytypes) - in Info.plist (`withExpoCallKitTelecomIos.ts:29-97`). That's the entire "Siri - integration" — no Intents app extension, no shortcut donations, no custom - vocabulary. CallKit auto-donates the `INInteraction`s for calls it reports, which - is what populates both Recents and Siri suggestions. - -**Siri assessment for us:** with our current generic handles (`CXHandleTypeGeneric` -carrying `displayName`, `CallKitManager.m:59, :94`), Siri's contact resolution has -nothing to match against — "Hey Siri, call Paweł using " works unreliably at -best. The plumbing is still worth having because Recents needs the identical code -path; the *quality* upgrade for both comes automatically if FEATURE-IDEAS **#4** -(richer push payload with `phoneNumber`/`email` in a caller object) ever lands. -Recommendation: implement Phase 2, mark Siri "works where handles allow", spend -nothing more. - -## `supportsGrouping` / `supportsUngrouping` — what they actually gate - -You asked to investigate these for multi-call. Result: **they are the wrong knob, and -should stay off.** - -- [`supportsGrouping`](https://developer.apple.com/documentation/callkit/cxcallupdate/supportsgrouping) - advertises that this call can be *merged* with another into one call group — it - makes the system UI show a "merge" affordance, which arrives as - [`CXSetGroupCallAction`](https://developer.apple.com/documentation/callkit/cxsetgroupcallaction). - That is conference calling (N parties, one mixed audio session). -- [`supportsUngrouping`](https://developer.apple.com/documentation/callkit/cxcallupdate/supportsungrouping) - advertises the reverse: splitting a call out of a group. -- What "two independent calls, switch between them, dismiss one" — i.e. call waiting — - actually needs is: - - [`maximumCallGroups`](https://developer.apple.com/documentation/callkit/cxproviderconfiguration/maximumcallgroups) - `= 2` (each independent call is its own group of 1; we have `1` today, - `CallKitManager.m:36`), - - `maximumCallsPerCallGroup = 1` (unchanged — forbids merging), - - `supportsHolding = true` on the calls — without it the second-call sheet offers - only **End & Accept / Decline**; with it you also get **Hold & Accept**, - - per-UUID bookkeeping, because "dismiss one of several" is just a - [`CXEndCallAction`](https://developer.apple.com/documentation/callkit/cxendcallaction) - targeted at that call's UUID. -- Both libraries already `fail` the group action (`CallKitManager.m:269-271`) — - keep that, keep both flags `NO`. - -Android equivalent: [`CallsManager`](https://developer.android.com/reference/androidx/core/telecom/CallsManager) -happily hosts multiple concurrent `addCall` scopes; the platform coordinates "only -one active at a time" through `onSetInactive` — the same hook hold uses. There is no -grouping flag to worry about; the work is entirely in our singleton-shaped manager. - -One more Android fact worth stating so nobody looks for parity: **self-managed calls -(`MANAGE_OWN_CALLS` / Core-Telecom) never appear in the system call log** — "Recents" -is an iOS-only concept here; Android apps keep in-app call history. - -## Current state of our code (audited) - -| Piece | iOS | Android | -|---|---|---| -| Hold action from system | `performSetHeldCallAction` exists and emits `held` (`CallKitManager.m:255-260`, `WebRTCModule+CallKit.m:38-40`) — but effectively **dead code**: `supportsHolding = NO` (`:97`) means the system never sends it | `onSetInactive = { }` — **silently swallowed** (`voip/CallManager.kt:219`). If a cellular call is answered, Telecom holds us and we keep streaming media. Latent bug. | -| Hold action from app | No bridge method | `CallAction.Hold → setInactive()` exists in the action loop (`:279`) but **nothing ever sends it** — no bridge method | -| Un-hold | — | `CallAction.Activate → setActive()` exists and is reachable (it's the "connected" signal) | -| Hold in TS types | `CallKitAction.held?: boolean` exists (`CallKit.ts:18`), `useCallKitEvent('held', …)` works | `TelecomEventType` has no hold member; `CallEventsListener` has no hold callback | -| Multi-call | Hard single call: `currentCallUUID`, `maximumCallGroups=1` | Hard single call: `hasActiveCall` guard (`:177`), all state is singleton fields | -| Recents | `includesCallsInRecents = NO` (`:37`) | n/a (no system call log for self-managed) | -| Call intents | Nothing handles `continueUserActivity` | n/a | -| `SUPPORTS_SET_INACTIVE` | n/a | already declared (`:196`) ✓ | - -So hold is *half-wired on both platforms in opposite halves*: iOS has the -system→JS event but no way to trigger or receive holds; Android has the app→Telecom -action but no trigger, no events, and drops external holds. - ---- - -# Part II — Implementation plan - -Phases are independently shippable and ordered by value/effort. 1 and 2 are additive; -3 is breaking. - -## Phase 1 — Hold (single call, both platforms) - -### 1a. iOS: enable holding + app-side API - -**`packages/react-native-webrtc/ios/RCTWebRTC/CallKitManager.m`** - -1. Flip the incoming-call update: `update.supportsHolding = YES` (`:97`). -2. Outgoing calls get no `CXCallUpdate` today, so their hold capability rides on - CallKit defaults. Make it explicit: in the `startCallWithDisplayName:` transaction - completion (`:79-85`), report an update — - - ```objc - CXCallUpdate *update = [[CXCallUpdate alloc] init]; - update.supportsHolding = YES; - update.supportsGrouping = NO; - update.supportsUngrouping = NO; - update.supportsDTMF = NO; - [weakSelf.provider reportCallWithUUID:uuid updated:update]; - ``` - - (Verify against the `CXCallUpdate.h` header during implementation: the - capability properties default to YES, which is exactly why every serious - integration sets all of them explicitly — silent defaults are how the grouping - button appears by accident.) -3. Track state + expose an app API: - - ```objc - @property(nonatomic, assign) BOOL isCallOnHold; // class extension - - - (void)setCallHeld:(BOOL)onHold { - if (self.currentCallUUID == nil) { - return; - } - CXSetHeldCallAction *action = - [[CXSetHeldCallAction alloc] initWithCallUUID:self.currentCallUUID onHold:onHold]; - CXTransaction *transaction = [[CXTransaction alloc] initWithAction:action]; - [self.callController requestTransaction:transaction completion:^(NSError *error) { - if (error) { - NSLog(@"[CallKitManager] Failed to set held: %@", error.localizedDescription); - } - // performSetHeldCallAction fires on success and emits the event — - // do not emit here or JS sees the change twice. - }]; - } - ``` -4. In `performSetHeldCallAction:` (`:255-260`) set `self.isCallOnHold = action.isOnHold` - before the existing `onCallHeld` emit. Reset `isCallOnHold` in `cleanup`. -5. Guard interplay: an on-hold call must not be killed by the answer path or timers — - ring timers (CALL-TIMEOUTS-PLAN) only run pre-answer, no interaction. But - `endCallWithReason:` works held ✓ (End button while held is legitimate). - -**`WebRTCModule+CallKit.m`** — export `setCallKitCallHeld:(BOOL)onHold` (promise), plus -`RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(isCallKitCallHeld)` for state rehydration. - -Audio note (why there is no native audio work): when CallKit holds the only call it -deactivates the AVAudioSession, and our delegate already forwards -`didActivate`/`didDeactivate` to `RTCAudioSession` (`CallKitManager.m:273-279`), so -the WebRTC audio unit stops/starts correctly. What native code *cannot* do is stop -the outbound video track or tell the remote party — that's JS (below). - -### 1b. Android: expose the action, emit the events, fix the swallow - -**`voip/CallManager.kt`** - -1. Add the missing listener member and an un-hold-capable API: - - ```kotlin - interface CallEventsListener { - // ... existing ... - fun onHoldChanged(onHold: Boolean) - } - - fun setCallHeld(onHold: Boolean) { - actions?.trySend(if (onHold) CallAction.Hold else CallAction.Activate) - } - ``` -2. Wire the **external** transitions (the bug fix) in `register`'s `addCall` lambdas - (`:213-219`): - - ```kotlin - onSetActive = { answered = true; listener?.onHoldChanged(false) }, - onSetInactive = { listener?.onHoldChanged(true) }, - ``` -3. Wire the **app-initiated** transitions in `processActions` (`:284-293`) — the - lambdas above do *not* fire for `scope.setInactive()`/`setActive()` calls we make - ourselves (same asymmetry as the answer path, `:286-289`): - - ```kotlin - } else if (action == CallAction.Hold) { - listener?.onHoldChanged(true) - } else if (action == CallAction.Activate) { - // Activate doubles as "outgoing connected" and "un-hold"; both mean not-held. - listener?.onHoldChanged(false) - } - ``` -4. Track `@Volatile var onHold = false` next to `answered` for the sync getter, reset - in the `finally` block. - -Caveat to encode in a comment: `CallAction.Activate` is also the outgoing-connected -signal (`setTelecomCallActive()` from JS), so JS will see a spurious -`holdChanged(false)` when an outgoing call connects. That is harmless (it's already -not held) but must not be "fixed" by splitting the action without also updating the -CALL-TIMEOUTS-PLAN Step 5 cancel site. - -**`TelecomController.java`** — implement `onHoldChanged` → emit -`telecomActionPerformed { event: 'holdChanged', held }`; add `setCallHeld`/`isOnHold` -pass-throughs. **`WebRTCModule.java`** — export `setTelecomCallHeld(boolean)` and -sync `isTelecomCallHeld()` next to the existing Telecom methods (`:1707-1747`). - -### 1c. TS + example - -- `Telecom.ts`: add `'holdChanged'` to `TelecomEventType`, `held?: boolean` to - `TelecomEvent`; export `setTelecomCallHeld(onHold)` / `isTelecomCallHeld()`. -- `CallKit.ts`: export `setCallKitCallHeld(onHold)` / `isCallKitCallHeld()` (the - `held` member of `CallKitAction` already exists). -- `useVoIPEvents.ts`: add optional `onHeldChanged?: (onHold: boolean) => void` to - `VoIPEventHandlers`; wire from `useCallKitEvent('held', …)` on iOS and the - `holdChanged` telecom event on Android — hold finally becomes cross-platform in the - one hook the example actually uses. -- Example `VoipProvider.tsx`: on `onHeldChanged(true)` → mute mic + disable outbound - video + (optionally) pause remote audio rendering; on `false` → restore. Media - policy is deliberately JS-owned, same as theirs: the SDK reports the state, the - app decides what "held media" means for its product. - -**QA (Phase 1):** the critical test needs a real SIM device: during an active VoIP -call, receive a cellular call → the sheet must show **Hold & Accept** (iOS) → accept -→ our call holds, mic/video stop, other party sees/hears nothing → end cellular call -→ tap our call → resumes. Plus app-driven hold/unhold round-trip on both platforms, -and hold→hang-up-while-held. - -## Phase 2 — Recents + call intents (iOS), Siri as byproduct - -1. **Config**: new Info.plist key `FishjamVoipIncludeCallsInRecents` (bool, - default **NO** — preserves current behavior and its privacy posture: Recents - entries persist in the system UI and sync via iCloud). Read it in - `CallKitManager` `init` and set - `providerConfiguration.includesCallsInRecents` accordingly (`:37`). Plugin prop - `voip.includeCallsInRecents` alongside the timeout props from CALL-TIMEOUTS-PLAN - Step 4. -2. **Intent handler** — `VoipManager.m` (it already owns the "buffer things for - cold-start JS" pattern): - - ```objc - + (BOOL)handleContinueUserActivity:(NSUserActivity *)userActivity; - ``` - - Implementation mirrors theirs (`AppDelegateSubscriber.swift:31-92`): unwrap - `userActivity.interaction.intent`, accept `INStartCallIntent` / - `INStartAudioCallIntent` / `INStartVideoCallIntent`, map - `INPersonHandleType` → `"phoneNumber" | "email" | "unknown"`, then (a) buffer as - `pendingCallIntent` (single slot, mirroring `pendingIncomingCall`, - `VoipManager.m:82`) and (b) fire a new `onCallIntent` block that - `WebRTCModule+PushKit`-style glue forwards as a `voipPushEvent` payload - `{ callIntent: { handle, handleType, isVideo } }`. Return `YES` iff handled, so - the host AppDelegate can chain. `#import ` — no extra target, - no extension. -3. **AppDelegate integration**: - - Bare RN (document in README): - - ```objc - - (BOOL)application:(UIApplication *)application - continueUserActivity:(NSUserActivity *)userActivity - restorationHandler:(void (^)(NSArray> *))restorationHandler { - if ([VoipManager handleContinueUserActivity:userActivity]) { - return YES; - } - return [RCTLinkingManager application:application - continueUserActivity:userActivity - restorationHandler:restorationHandler]; - } - ``` - - Expo: extend the config plugin with a - [`withAppDelegate`](https://docs.expo.dev/config-plugins/plugins-and-mods/#dangerous-mods) - source mod inserting the call before the existing `continueUserActivity` return. - It's a string-level "dangerous mod" — accept the maintenance cost, add a - defensive "already contains `handleContinueUserActivity`" check, and keep the - manual snippet documented as the fallback. -4. **Info.plist `NSUserActivityTypes`**: plugin appends - `INStartCallIntent`, `INStartAudioCallIntent`, `INStartVideoCallIntent` - (set-union like theirs, `withExpoCallKitTelecomIos.ts:89-97`), gated on - `voip.includeCallsInRecents || voip.enableCallIntents`. -5. **JS**: `VoIPEventHandlers.onCallIntent?: (intent: { handle: string; handleType: 'phoneNumber'|'email'|'unknown'; isVideo: boolean }) => void` - in `useVoIPEvents.ts` (both the live event and the cold-start - `getPendingCallIntent()` replay, mirroring the `pendingCall` replay at - `useVoIPEvents.ts:89-104`). Example: treat `handle` (our generic handle carries - `displayName`) as the callee and start an outgoing call to the mapped room — - demonstrating the loop *Recents tap → app opens → call re-dials*. -6. **Siri**: nothing further. Document the honest behavior: works where Siri can - resolve the spoken name to a handle CallKit reported. Revisit only after - FEATURE-IDEAS #4 introduces `phoneNumber`/`email` in the caller payload — - then `makeHandle`-style preference (theirs, `CallManager.swift:166-174`) slots - into `reportIncomingCallWithDisplayName:` and both Recents and Siri upgrade - without touching this phase's plumbing. - -**QA (Phase 2):** call with Recents enabled → entry appears with the display name; -tap it cold (app killed) and warm → `onCallIntent` fires exactly once with the right -handle; "Hey Siri, call using " on a device where the name is -unambiguous; verify default-off config leaves Recents empty. - -## Phase 3 — Multiple calls (call waiting) ⚠️ large, breaking - -Ship 1–2 first; take this only when the product actually needs a second simultaneous -call. It is the only phase that changes existing API shapes. - -### 3a. The prerequisite everything else hangs on: call identity in the JS contract - -Today **no event carries a call id** (`CallKitAction`, `TelecomEvent`, -`VoIPEventHandlers`). With two calls, "ended" / "answer(requestId)" / "holdChanged" -are ambiguous. First (mechanical, additive) step: - -- Native: iOS emits `callId` (`currentCallUUID.UUIDString`) in every - `kEventCallKitActionPerformed` payload; Android adds `callId` to every - `telecomActionPerformed` body (requires threading the UUID through - `CallEventsListener`). -- TS: add `callId?: string` to `TelecomEvent` and restructure `CallKitAction` - payloads to objects (`{ callId, requestId }`, `{ callId, reason }` …) — **this is - the breaking change**; do it in one release with `useVoIPEvents` absorbing the - difference so example-level code only gains an optional `callId` argument. -- Bridge methods gain id-taking variants: `endCallKitSession(callId, reason)`, - `endTelecomCall(callId, reason)`, `setCallHeld(callId, onHold)`; the id-less forms - stay as "the only/active call" conveniences. - -### 3b. iOS - -**`CallKitManager.m`** — replace the scalar state (`currentCallUUID`, -`isCallAnswered`, `pendingAnswerRequestId`, plus Phase 1's `isCallOnHold` and -CALL-TIMEOUTS-PLAN's single `ringTimeoutBlock`) with a registry: - -```objc -@interface FJCallRecord : NSObject -@property NSUUID *uuid; -@property NSString *displayName; -@property BOOL isVideo, answered, onHold, outgoing; -@property(copy, nullable) NSString *pendingAnswerRequestId; -@property(copy, nullable) dispatch_block_t ringTimeoutBlock; -@end -// CallKitManager: NSMutableDictionary *calls; -``` - -- `maximumCallGroups = 2` (`:36`); `maximumCallsPerCallGroup` stays 1; - grouping flags stay NO; `performSetGroupCallAction` keeps failing. -- `reportIncomingCallWithDisplayName:` drops the implicit single-call assumption; - refuse a third call (`calls.count >= 2` → report busy? No — CallKit itself - enforces `maximumCallGroups`; still guard and log). -- **This converts CALL-TIMEOUTS-PLAN Step 1's single ring timer into the per-UUID - design of the research doc** (`callTimeoutTasks`-equivalent): the timer block moves - into `FJCallRecord`, cancel sites become record-scoped. Note this in that plan when - Phase 3 starts. -- Delegate methods already receive `action.callUUID` everywhere — the changes are - lookups instead of assumptions. The interesting new flow, **Hold & Accept**, - arrives as one transaction containing `CXSetHeldCallAction(callA, onHold:YES)` + - `CXAnswerCallAction(callB)`; Phase 1's per-action handlers compose correctly as - long as both are record-scoped and JS gets `callId` on both events (3a). -- "Dismiss one of several": system UI does it natively (per-call End); the app API - is `endCallKitSession(callId, reason)` → `CXEndCallAction` with that UUID, or the - reported-reason branch of `endCallWithReason:` scoped to the UUID. -- `cleanup` becomes `cleanupCall:(NSUUID *)` + `cleanupAll` (provider reset); - `FulfillRequestManager` is already request-id-keyed and needs no change; - `VoipManager.pendingIncomingCall` single slot → small FIFO (two entries suffice). - -### 3c. Android - -**`voip/CallManager.kt`** — the structural refactor. Their `launchCallScope` + -`handleCallActions` (`CallManager.kt:676-748`) is the blueprint to copy: - -- Singleton fields that must become per-call: `actions` channel, `displayName`, - `videoCall`, `answered`, `pendingAnswerRequestId`, `endpointJob`/`availableJob`/ - `muteJob`, ring timer → a `CallController(job, actions, state…)` in - `activeCalls: MutableMap`; `hasActiveCall` becomes - `activeCalls.isNotEmpty()`. -- Remove the `if (hasActiveCall) return` guard in `register` (`:177`) — replace with - a two-call cap. `CallsManager.addCall` supports concurrent scopes; Telecom - coordinates activation: answering call B triggers `onSetInactive` on call A → - Phase 1's `onHoldChanged(true)` (now with `callId`) tells JS to pause A's media. -- `PushNotificationService` must not drop a push while a call exists (today `register` - early-returns) — and needs event dedup before this ships (FEATURE-IDEAS #4 overlap). -- Notifications: `CallNotificationManager` currently manages one notification; - per-call notification ids, and the incoming-call full-screen - (`IncomingCallActivity`) should only launch when the screen is locked *and* no - call is active — during an active call the second call must present as a heads-up - CallStyle notification (default behavior when the screen is on) with - answer/decline actions, which is exactly the "dismiss one of multiple" surface. -- Audio: `AudioOutputManager.setTelecomOwnsRouting` and the endpoint collectors - assume one call — scope them to the *active* call, switching on hold-swap. -- `ForegroundServiceController` state ("connecting"/"connected") follows the active - call only. - -### 3d. JS + example - -- `useVoIPEvents` handlers all gain `callId`; `VoipProvider` state becomes - `Map` with one `activeCallId`; the `pendingAnswerRequestIdRef` - /`activationInFlightRef` pair becomes per-call (the same one-shot semantics, keyed). -- Product decision to make explicit in the example: what a **held** call's media does. - Recommended default: stay connected to the held call's room with mic muted and - inbound audio disabled (fast resume, costs bandwidth/battery); alternative — leave - the room and re-join on resume (cheap, slow resume, loses in-room state). The SDK - ships events; the example demonstrates the first policy. - -**QA (Phase 3):** second incoming during active call → heads-up/system sheet on both -platforms; Hold & Accept → A held (media stopped), B live; decline B → A untouched; -end B → tap A → resumes; both ring timers independent (let B ring out while A active -→ only B ends, reason `missed`); cold-start with two queued pushes. - -## Interplay with the other plans - -- **CALL-TIMEOUTS-PLAN**: Phases 1–2 don't touch it. Phase 3 upgrades its single ring - timer to the per-UUID map (both platforms) — the research doc already describes the - target design; add a cross-reference line to that plan when Phase 3 is scheduled. - Also Phase 1's Android note about `Activate` doubling as un-hold applies to that - plan's Step 5 cancel site. -- **OUTGOING-CONNECT-PLAN (#2)**: independent, but its `reportCallWithUUID:updated:` - moment (outgoing display-name update) is the natural place to also set the - Phase 1a capability flags — coordinate to avoid two adjacent `CXCallUpdate` reports. -- **FEATURE-IDEAS #4 (payload)**: the handle-quality upgrade (`phoneNumber`/`email`) - that makes Recents entries properly attributed and Siri actually usable lives - there, not here. -- **FEATURE-IDEAS #6 (end reasons)**: declining call B while on call A surfaces as - `rejected` on Android but `local` on iOS (documented gap, `Telecom.ts:22-24`) — - more visible once two calls exist; unchanged by this plan. - -## Files touched (summary) - -| File | Phase 1 | Phase 2 | Phase 3 | -|---|---|---|---| -| `ios/RCTWebRTC/CallKitManager.m` | hold flags, `setCallHeld`, state | `includesCallsInRecents` read | registry refactor, `maximumCallGroups=2` | -| `ios/RCTWebRTC/WebRTCModule+CallKit.m` | 2 methods | — | id-taking variants, `callId` in events | -| `ios/RCTWebRTC/VoipManager.m` | — | intent handler + pending buffer | pending buffer → FIFO | -| `android/.../voip/CallManager.kt` | `setCallHeld`, lambda wiring, listener | — | per-call controller refactor | -| `android/.../TelecomController.java` + `WebRTCModule.java` | holdChanged event, 2 methods | — | `callId` everywhere, id-taking variants | -| `src/CallKit.ts`, `src/Telecom.ts`, `src/useVoIPEvents.ts` | hold APIs + `holdChanged` + `onHeldChanged` | `onCallIntent` + pending replay | breaking payload change, per-call handlers | -| `packages/mobile-client/plugin` | — | Recents/intents props, `NSUserActivityTypes`, AppDelegate mod | — | -| example `VoipProvider.tsx` | hold → mute wiring | Recents re-dial demo | multi-call state | - -No changes in any phase to: `FulfillRequestManager` (either platform), -`PushNotificationService` (until Phase 3), `IncomingCallActivity` (until Phase 3). diff --git a/examples/mobile-client/voip-call/IOS-VOIP-REGISTRATION-PLUGIN-PLAN.md b/examples/mobile-client/voip-call/IOS-VOIP-REGISTRATION-PLUGIN-PLAN.md deleted file mode 100644 index 2e5e5a661..000000000 --- a/examples/mobile-client/voip-call/IOS-VOIP-REGISTRATION-PLUGIN-PLAN.md +++ /dev/null @@ -1,147 +0,0 @@ -# iOS VoIP push registration via config plugin — implementation plan - -> Automates what the example app currently does by hand at -> [`AppDelegate.swift:25`](./app/ios/voipcall/AppDelegate.swift) — -> `VoipManager.registerForVoIPPushes()` — so consumers of -> `@fishjam-cloud/react-native-client` don't have to hand-edit their AppDelegate. -> Verified against the tree 2026-07-13. - -## Current state - -- Native PushKit registration lives in the webrtc fork: - `packages/react-native-webrtc/ios/RCTWebRTC/VoipManager.h:9` exposes - `+ (void)registerForVoIPPushes`, implemented at `VoipManager.m:24-36` - (lazily creates a `PKPushRegistry`, sets `desiredPushTypes` to `PKPushTypeVoIP`). -- Nothing calls it automatically. The example app calls it by hand at - `examples/mobile-client/voip-call/app/ios/voipcall/AppDelegate.swift:25`, - inside `didFinishLaunchingWithOptions`, right after `bindReactNativeFactory(factory)`. -- The Android side already has an equivalent opt-in config plugin - (`packages/mobile-client/plugin/src/withFishjamVoipAndroid.ts`, gated by - `android.enableVoip`) that injects manifest permissions/components. iOS has no - matching plugin — this plan closes that gap. -- `packages/mobile-client/plugin/src/withFishjamIos.ts` already composes several - `withInfoPlist`/`withXcodeProject`/`withPodfileProperties` mods behind `ios.*` flags - (screensharing, PiP, VoIP background mode, VoIP timeouts) — the new mod follows the - same pattern. - -## Decision: `withAppDelegate` + `mergeContents`, not AppDelegate Subscribers - -Expo docs steer native modules toward `ExpoAppDelegateSubscriber` and call -`withAppDelegate` string-patching "strongly discouraged." Subscribers were rejected -here because they require `expo-modules-core` as a **runtime** dependency — this SDK -must work in bare React Native with zero runtime Expo deps. `withAppDelegate` is -build-time only (`expo prebuild`), so it doesn't compromise that constraint. - -The "discouraged" risk (duplicate inserts across re-prebuilds, brittle anchors) is -mitigated by `@expo/config-plugins`' own `mergeContents` helper — it wraps the -inserted line in `// @generated begin/end ` markers so re-prebuilds **replace** -the block instead of duplicating it, and reports `didMerge: false` if the anchor -isn't found (fail loud, not silently). - -### Known fragility (accepted, not blocking) - -1. **Swift-only.** Expo's AppDelegate template moved objc → Swift in SDK 52. Consumers - on SDK ≤51 have an objc `AppDelegate.mm`; the mod must detect - `modResults.language !== 'swift'` and throw a clear error pointing at the manual - fallback, not silently no-op. -2. **`mergeContents` is an internal import** — `@expo/config-plugins/build/utils/generateCode`, - not part of the package's public `exports`. Widely relied on by other plugins - (this is the standard idempotent-injection pattern in the Expo ecosystem) but not a - stability contract. Most likely thing to break on an `@expo/config-plugins` major bump. -3. **Anchor drift.** If Expo reshuffles the AppDelegate template again, the merge fails - loud via `didMerge` — a build-time error, not a silent runtime gap. - -Net: acceptable for a one-line injection given the loud-failure behavior; not silently -broken in production if the anchor ever moves. - -## Files to change - -| File | Change | -|---|---| -| `packages/mobile-client/plugin/src/types.ts` | Add `ios.enableVoip?: boolean` to `FishjamPluginOptions['ios']`, gating the new mod (mirrors `android.enableVoip`) | -| `packages/mobile-client/plugin/src/withFishjamIos.ts` | Add `withFishjamVoipRegistration` mod (import `withAppDelegate` + `mergeContents`); wire it into `withFishjamIos`'s composition | -| `packages/mobile-client/README.md` | Document `ios.enableVoip`; add a bare-RN manual-line fallback section (mirrors the existing Android bare-RN section) | -| `examples/mobile-client/voip-call/app/ios/voipcall/AppDelegate.swift` | Once the plugin injects the call, remove the hand-added `VoipManager.registerForVoIPPushes()` at line 25 and set `ios.enableVoip: true` in the example's Expo config, proving the plugin works end-to-end | - -No changes needed in `packages/react-native-webrtc` — `VoipManager` already ships as-is. - -## Sketch - -```ts -// withFishjamIos.ts -import { withAppDelegate, type ConfigPlugin } from '@expo/config-plugins'; -import { mergeContents } from '@expo/config-plugins/build/utils/generateCode'; - -/** - * Registers PushKit VoIP at launch by injecting one call into the iOS AppDelegate. - * Opt in with `ios.enableVoip`. Uses tagged markers so repeated prebuilds replace - * (not duplicate) the block. - */ -const withFishjamVoipRegistration: ConfigPlugin = (config, props) => { - if (!props?.ios?.enableVoip) { - return config; - } - - return withAppDelegate(config, (configuration) => { - const { language, contents } = configuration.modResults; - - if (language !== 'swift') { - throw new Error( - `withFishjamVoipRegistration only supports a Swift AppDelegate, found "${language}". ` + - `Add "VoipManager.registerForVoIPPushes()" to didFinishLaunchingWithOptions manually.`, - ); - } - - if (contents.includes('VoipManager.registerForVoIPPushes()') && - !contents.includes('@generated begin fishjam-voip-register')) { - // Already present by hand (e.g. a consumer who added it before adopting the - // plugin). Don't double-inject. - return configuration; - } - - const merged = mergeContents({ - tag: 'fishjam-voip-register', - src: contents, - newSrc: ' VoipManager.registerForVoIPPushes()', - anchor: /bindReactNativeFactory\(factory\)/, - offset: 1, - comment: '//', - }); - - if (!merged.didMerge) { - throw new Error( - 'withFishjamVoipRegistration could not find the AppDelegate anchor ' + - '"bindReactNativeFactory(factory)". The Expo AppDelegate template may have changed — ' + - 'file an issue or add the call manually.', - ); - } - - configuration.modResults.contents = merged.contents; - return configuration; - }); -}; - -const withFishjamIos: ConfigPlugin = (config, props) => { - // ...existing screensharing + podfile + PiP + background mode + timeouts... - config = withFishjamVoipRegistration(config, props); // <-- add - return config; -}; -``` - -### Anchor choice - -`bindReactNativeFactory\(factory\)` — matches the example app's AppDelegate exactly, -inserts right after RN bootstrap. Alternative `didFinishLaunchingWithOptions` is more -template-agnostic but lands at the method signature, requiring a deeper offset into -the body — slightly more fragile. Go with `bindReactNativeFactory` first; fall back to -the method-signature anchor only if real-world Expo templates diverge from the example. - -## Open questions for whoever picks this up - -- Should `ios.enableVoip` also gate the existing `ios.enableVoIPBackgroundMode` Info.plist - flag (`withFishjamVoIPBackgroundMode`, `withFishjamIos.ts:298`), or stay independent? - Today they're separate flags; bundling might simplify the README but reduces flexibility - for consumers who want the background mode without PushKit registration (unlikely, but - worth deciding deliberately rather than accidentally). -- Confirm minimum supported Expo SDK for this package before deciding whether the - Swift-only guard needs an objc fallback (`AppDelegate.mm` regex path) or can just throw. diff --git a/examples/mobile-client/voip-call/KILLED-APP-DECLINE-PLAN.md b/examples/mobile-client/voip-call/KILLED-APP-DECLINE-PLAN.md deleted file mode 100644 index 311309d7e..000000000 --- a/examples/mobile-client/voip-call/KILLED-APP-DECLINE-PLAN.md +++ /dev/null @@ -1,258 +0,0 @@ -# Plan: a decline reaches the caller even when the app is killed - -Expands [FEATURE-IDEAS.md](./FEATURE-IDEAS.md) item **#5 "Killed-app decline broadcast (Android)"**, -and widens it: the same class of bug exists on iOS, where expo-callkit-telecom has no answer either. - -Depends on item **#4** (`serverCallId` + opaque `metadata` in the push payload) — without a way to -identify the call and authenticate the decline with zero app-side state, none of this can talk to a -backend. - ---- - -## Problem - -The user's phone rings while our app is force-stopped / swiped away. They decline from the -system UI. The caller keeps ringing until their own 60-second outgoing timeout fires, because -nothing ever told the server the call was rejected. - -### Android: the event is guaranteed to be lost - -1. FCM data push → [`PushNotificationService.onMessageReceived`](../../../packages/react-native-webrtc/android/src/main/java/com/oney/WebRTCModule/voip/PushNotificationService.kt) → - `CallManager.reportIncomingCall` → Core-Telecom rings and `IncomingCallActivity` shows. All native, - no JS needed. Good. -2. `warmUpReact()` starts the React **context**, so the JS bundle loads and `WebRTCModule` is - constructed (which is what attaches `TelecomController` as the `CallEventsListener`). But nothing - mounts an RN **root view** — `IncomingCallActivity` is a plain `Activity`, not a `ReactActivity` — - so the app's component tree never renders and the `useVoIPEvents` call inside - [`VoipProvider.tsx:233`](./app/src/voip/VoipProvider.tsx) never subscribes. -3. User declines → Telecom `onDisconnect` → `CallManager` → `TelecomController.onEnded("rejected")` → - `webRTCModule.sendEvent("telecomActionPerformed", …)` → emitted into a live JS runtime with **zero - subscribers**. Silently dropped. - -So warm-up buys us a fast answer path (the answer launches the host activity, which mounts React); -it does nothing for a decline, where no activity is ever launched. This is the worst shape of bug: -everything looks healthy, the event just evaporates. - -### iOS: mostly works, with a real race - -PushKit launches the app process for a VoIP push, and RN builds and mounts the root view during -`didFinishLaunchingWithOptions` even for a background launch — so the React tree **does** mount and -`useVoIPEvents` **does** subscribe. Decline → -[`performEndCallAction`](../../../packages/react-native-webrtc/ios/RCTWebRTC/CallKitManager.m) → -`onCallEnded(@"local")` → JS hears it. Two holes remain: - -- **Cold-start race:** the JS bundle takes ~1–3 s. A decline inside that window hits an - `onCallEnded` block whose JS listener isn't subscribed yet. Dropped, no queue. -- **No channel to the server:** even when JS hears it, our only path to the backend is the signaling - WebSocket opened by the logged-in UI ([`useCallSignaling.ts:91`](./app/src/signaling/useCallSignaling.ts) - sends `call-rejected`). A cold-started, logged-out-looking process has no socket and no auth state, - and iOS suspends it seconds after the call ends. - -### The server is also blind - -`POST /call` in [`server/main.ts:165`](./server/main.ts) fires the push and forgets. The WebSocket is a -dumb relay: the caller only learns of a rejection because the **callee's** app sends `call-rejected` -over its own socket. No server-side call state ⇒ no server-side fallback. Fixing the client without -giving the server a decline endpoint fixes nothing. - ---- - -## How expo-callkit-telecom does it - -### Android: broadcast the events that JS can never receive - -[`CallEventEmitter.kt`](~/Desktop/expo-callkit-telecom/android/src/main/java/expo/modules/callkittelecom/events/CallEventEmitter.kt) -is a single chokepoint for every native→JS event, and it has three tiers: - -1. JS is observing this event → send it. -2. JS is not observing, but the event has a queue limit > 0 → queue it, flush on `startObserving` - with `meta: { flushed: true }`. -3. JS is not observing **and** the queue limit is `0` → the event is **dropped** and will never reach - JS. Exactly these events get a package-internal broadcast instead. - -Terminal events (`onCallEnded`, `onCallReportedEnded`) sit in tier 3 deliberately: replaying a stale -"call ended" on the next app launch is useless, so they're never queued — which is precisely what -makes them broadcastable without risking double-delivery. - -The broadcast is `Intent(ACTION_CALL_EVENT).setPackage(pkg)` with two extras: `eventName`, and -`payload`, a JSON string of the exact body JS would have received. For terminal events that body -embeds the **full session**, including `serverCallId` and the push `metadata` — so the receiver has -the backend ids and any auth token with **zero app-side state**. Failures are warn-logged and -swallowed; a dead broadcast must never break event emission. - -The host app registers a manifest `` whose class name comes from the config-plugin prop -`androidEventReceiver`. Their example -([`CallEndedReceiver.kt`](~/Desktop/expo-callkit-telecom/example/client/plugins/CallEndedReceiver.kt)) -filters `eventName == "onCallEnded"`, digs out `session.incomingCallEvent.serverCallId` + `metadata`, -and calls `goAsync()` so the (possibly freshly cold-started) process survives past `onReceive` while -it does its work. Their test-push script demos the auth story directly: -`--metadata '{"declineToken":"abc"}'`. - -Documented in their [`docs/platform-notes.md`](~/Desktop/expo-callkit-telecom/docs/platform-notes.md) -under "Call ended while the app is killed". - -### iOS: they don't solve it - -`onCallEnded` is queue-limit `0` on iOS too -([`AppDelegateSubscriber.swift:19-25`](~/Desktop/expo-callkit-telecom/ios/AppDelegateSubscriber.swift) only -raises limits for `CallIntentReceived`, `AudioSessionActivated/Deactivated`, `IncomingCallReported`, -`CallAnswered`, `VoIPPushTokenUpdated`), and there is no broadcast path on iOS. They rely on PushKit -having launched the app so JS is up in time. The fast-decline race is unhandled — same as ours. - ---- - -## Design for our repo - -Guiding constraint: **the SDK must not do networking.** It doesn't know the backend, and taking a URL -out of a push payload and POSTing to it would be an SSRF footgun. The SDK's job is to surface the -event to app-owned native code that can run without JS; the app does the HTTP call. - -Three layers. Layer 0 is a hard prerequisite; layers 1 and 2 are independent of each other. - -### Layer 0 — give the decline something to say (needs #4) - -- **Push payload** (#4): server mints `serverCallId` and a short-lived `declineToken` at - `POST /call` time and puts both in the FCM data / APNs payload under the opaque `metadata`. Native - keeps them on the call so they're readable with no JS and no app state. -- **Server:** new `POST /call/:serverCallId/decline` (bearer `declineToken`), which looks up the - caller and pushes `call-rejected` down the **caller's** socket. The server, not the callee's - process, becomes the thing that notifies the caller. Make it **idempotent on `serverCallId`** — - layers 1/2 can race with a live JS handler. -- **Server-side safety net (recommended anyway):** once the server knows a call exists, it can also - expire it on its own. That is the real backstop; the decline path just makes it _instant_ instead - of _eventually_. - -### Layer 1 (Android) — broadcast terminal events that no JS listener can hear - -The decision has to be "**is a JS listener subscribed?**", not "is React alive?" — our warm-up makes -React alive while the tree is unmounted, which is exactly the case that currently loses the event. -`WebRTCModule.sendEvent` already bails on `!ctx.hasActiveReactInstance()` -([`WebRTCModule.java:182-184`](../../../packages/react-native-webrtc/android/src/main/java/com/oney/WebRTCModule/WebRTCModule.java)), -which is necessary but not sufficient. - -Luckily the hook is already there and empty: RN requires `addListener(String)` / -`removeListeners(Integer)` on any `NativeEventEmitter` module, and ours are no-op stubs at -[`WebRTCModule.java:1687`](../../../packages/react-native-webrtc/android/src/main/java/com/oney/WebRTCModule/WebRTCModule.java) -and `:1702`. Make them maintain a per-event subscriber count. - -1. **Subscriber counting** — `addListener` increments, `removeListeners(count)` decrements. RN's - `removeListeners` only passes a _count_, not the event name, so keep a total-per-emitter counter - plus a per-event map populated in `addListener`; on `removeListeners` decrement the total and, if - it hits 0, clear the map. Coarse but sufficient: the only question we ask is "could anyone hear a - `telecomActionPerformed`?" -2. **New `voip/CallEventBroadcaster.kt`** — `broadcast(context, eventName, payloadJson)` building - `Intent(ACTION_CALL_EVENT).setPackage(pkg)` + `eventName`/`payload` extras. Swallow + log failures. - Action constant: `com.oney.WebRTCModule.ACTION_CALL_EVENT`. -3. **Wire it in `TelecomController.onEnded` / `onFailed` only.** Terminal events are the only ones - worth broadcasting: `started`/`answer`/`muteChanged`/`holdChanged` are meaningless to a JS-less - process (and `answer` already launches the host app, which mounts React). If there's no live - subscriber → broadcast instead of `sendEvent`. Never both. -4. **Payload:** `{ event: "ended", reason, roomName, displayName, serverCallId, metadata }` — same - shape JS gets, plus the #4 fields, so the receiver needs no state. -5. **Config plugin:** new `androidEventReceiver` prop in - [`plugin/src/types.ts`](../../../packages/mobile-client/plugin/src/types.ts) → - `withFishjamVoipAndroid` writes - ``. -6. **Example app:** ship `plugins/CallEndedReceiver.kt` + a `withCallEndedReceiver` plugin that copies - it into the generated project. In `onReceive`: filter `reason == "rejected"` (and `"missed"`, so the - caller stops ringing on a timeout too), then **enqueue a `WorkManager` job** rather than POSTing - inline. A `BroadcastReceiver` — even with `goAsync()` — only gets ~10 s and no retry; a - killed-app decline is exactly when the network is likeliest to be cold. WorkManager gives us - retry/backoff for free and survives the process dying again. (Their example POSTs inline from - `goAsync()`; fine for a demo, not for "almost production-ready".) - -**Rejected alternative — HeadlessJS.** `HeadlessJsTaskService` would let the decline be handled in TS -instead of Kotlin. Rejected: a much heavier runtime dependency for one HTTP POST, it must start -within the FCM/foreground-service start window, and its standing on bridgeless/new-arch needs -verifying (see Open questions). Not worth it when the receiver is ~30 lines. - -### Layer 2 (iOS) — close the cold-start race, symmetrically - -Queue-and-replay is the wrong tool: replaying a "call ended" on the next launch tells the app -something it can no longer act on. Mirror the Android design instead — surface the event to -app-owned native code when JS can't hear it. - -`WebRTCModule` is an `RCTEventEmitter` -([`WebRTCModule.h:34`](../../../packages/react-native-webrtc/ios/RCTWebRTC/WebRTCModule.h)), so we get -subscriber tracking **for free**: `RCTEventEmitter` flips `self.hasListeners` in -`startObserving`/`stopObserving`. No new plumbing. - -1. In the `onCallEnded` path, if `!self.hasListeners` → post an - `NSNotification` (`FishjamVoipCallEventUndelivered`) carrying the same dictionary, instead of the - JS event. The app observes it from its `AppDelegate` (or a small local module) and does the POST. -2. Wrap it in - [`beginBackgroundTaskWithExpirationHandler`]() - before posting and end it when the app calls a new `voipEventHandled()` bridge method (or after a - short deadline) — otherwise iOS suspends the process the moment the call tears down and the - request never leaves the device. -3. Same for the **live-JS** path, honestly: JS `fetch()` after a decline in a background-launched app - is equally racing suspension. The background-task wrapper should cover both — begin it on - `onCallEnded` regardless, end it when the app confirms. - -This gives one conceptual shape on both platforms: _terminal event + no JS listener → hand it to -app-owned native code + keep the process alive long enough to deliver._ - ---- - -## What the user actually sees, after - -Killed app, decline on the lock screen → receiver/notification fires within ~100 ms → app POSTs the -decline (token from the push) → server pushes `call-rejected` to the caller's socket → the caller's -ring stops immediately instead of after 60 s. - ---- - -## Implementation checklist - -**Prerequisite (#4)** - -- [ ] `serverCallId` + opaque `metadata` in the push payload, kept on the native call -- [ ] Server: mint `serverCallId` + `declineToken` in `POST /call` -- [ ] Server: `POST /call/:serverCallId/decline`, idempotent, notifies the caller's socket - -**Android** - -- [ ] Subscriber counting in `WebRTCModule.addListener` / `removeListeners` -- [ ] `voip/CallEventBroadcaster.kt` + `ACTION_CALL_EVENT` -- [ ] `TelecomController.onEnded` / `onFailed`: broadcast when no subscriber, else `sendEvent` (never both) -- [ ] Config plugin: `androidEventReceiver` prop → manifest `` -- [ ] Example: `CallEndedReceiver.kt` → WorkManager job → decline POST - -**iOS** - -- [ ] `FishjamVoipCallEventUndelivered` notification when `!self.hasListeners` -- [ ] Background task around the `onCallEnded` path + `voipEventHandled()` bridge method -- [ ] Example: `AppDelegate` observer → decline POST - -**Docs** - -- [ ] README: killed-app decline section; push-payload fields; the `androidEventReceiver` prop - -## Verification - -- **Android, force-stopped:** `adb shell am force-stop `, send a push, decline from the - full-screen activity **and** (separately) from the notification. Watch - `adb logcat -s CallEndedReceiver` + the server log. Repeat with the app swiped from Recents and - with the screen locked. -- **iOS, killed:** kill from the app switcher, push, decline from the CallKit lock-screen UI. Confirm - the server sees the decline. Then the race: decline **within ~1 s** of the ring starting — this is - the case that fails today. -- **Regression (both):** app in the foreground → decline must go through JS only. The server must see - exactly **one** decline, not two. Assert on the server, not just the client. -- **Missed call:** let it ring out to the 45 s native timeout with the app killed — the caller should - also stop ringing (this is why the receiver handles `missed`, not just `rejected`). - -## Open questions - -- **Does `removeListeners(count)` give us enough to avoid a stuck non-zero count** across an RN - reload? If the count leaks upward we'd stop broadcasting (we'd think JS is listening). Consider - resetting it in `TelecomController.detach()` / on catalyst-instance destroy. -- **HeadlessJS on new arch / bridgeless** — viable or not? Only matters if we ever want the decline - handled in TS. -- **Does the broadcast survive Doze / battery restrictions** when the process was started by a - high-priority FCM push? Expected yes (same process, delivered right after `onDisconnect`, and the - FCM start exemption is still in effect), but it needs a real device test on a locked, dozing phone. -- **Should the server own call lifecycle entirely** (FCE-3435 asks for "make the server aware of the - call")? If it does, a server-side ring timeout is a strictly better backstop, and the client-side - decline becomes a latency optimization rather than the only mechanism. Worth deciding before - building layer 1. diff --git a/examples/mobile-client/voip-call/OUTGOING-CONNECT-PLAN.md b/examples/mobile-client/voip-call/OUTGOING-CONNECT-PLAN.md deleted file mode 100644 index b3b15ff31..000000000 --- a/examples/mobile-client/voip-call/OUTGOING-CONNECT-PLAN.md +++ /dev/null @@ -1,447 +0,0 @@ -# Plan: real outgoing-call connection reporting - -> Companion to [FEATURE-IDEAS.md](./FEATURE-IDEAS.md) item #2 (P0 🔴), ported from -> `~/Desktop/expo-callkit-telecom` (source verified 2026-07-10) and mapped onto our -> ObjC/Kotlin/Java code. -> -> **The dialtone is explicitly out of scope** — see [Deliberately omitted](#deliberately-omitted-the-dialtone) -> for what that costs and where it would hook in later. -> -> Sibling plan: [ANSWER-HANDSHAKE-PLAN.md](./ANSWER-HANDSHAKE-PLAN.md) (item #1). The two -> converge on a shared "mark the call connected" path — read the [Symmetry](#symmetry-with-the-answer-handshake-1) -> section before implementing either in isolation. - -## Problem - -### iOS: the timer starts when we dial, not when they answer - -```objc -// packages/react-native-webrtc/ios/RCTWebRTC/CallKitManager.m:80-81 -[weakSelf.provider reportOutgoingCallWithUUID:uuid startedConnectingAtDate:[NSDate date]]; -[weakSelf.provider reportOutgoingCallWithUUID:uuid connectedAtDate:[NSDate date]]; -``` - -Both reports fire back-to-back inside the `requestTransaction` completion — i.e. the instant -CallKit *accepts* the call, not when the remote party picks up. -[`reportOutgoingCall(with:connectedAt:)`](https://developer.apple.com/documentation/callkit/cxprovider/reportoutgoingcall(with:connectedat:)) -is what starts the system call timer, so the dynamic island / status bar / lock screen counts -from dial time. A 20-second ring becomes 20 seconds of phantom call duration. - -Separately, `provider:performStartCallAction:` (`CallKitManager.m:148-150`) is a bare -`[action fulfill]`. The `startedConnecting` report belongs *there* — that is where Apple's own -sample code and the reference put it. - -### Android: Telecom is honest, the notification is not - -Our Core-Telecom state is already close to correct. `setActive()` is only sent when JS calls -`setTelecomCallActive()` (`voip/CallManager.kt:84`), which the example does from its -`remotePeers.length > 0` effect. Nothing marks the call active at dial time. - -The lie is in the notification. `register()` posts the ongoing notification immediately for -outgoing calls: - -```kotlin -// voip/CallManager.kt:172-173 -if (isIncoming) callNotificationManager.showIncoming(...) -else showOngoingNotification() // ← at dial time -``` - -`showOngoingNotification()` → `ForegroundServiceController.onCallStarted(...)`, which sets -`callConnectedAtMs = System.currentTimeMillis()` -(`foregroundService/ForegroundServiceController.java:123-128`). That timestamp reaches -`CallNotificationManager.ongoingBuilder`, which does -`.setUsesChronometer(true).setWhen(connectedAtMs)` (`voip/CallNotificationManager.kt:137-138`). -So the shade shows a running call timer and the text "Ongoing call" while the callee's phone is -still ringing. - -So: **iOS reports connected too early to CallKit; Android renders connected too early in the -notification.** Same bug, two surfaces, two different fixes. - -## How expo-callkit-telecom does it - -### iOS - -`startedConnecting` is reported from the delegate, and the timeout starts there too -(`CallManager+CXProviderDelegate.swift:30-54`): - -```swift -func provider(_ provider: CXProvider, perform action: CXStartCallAction) { - provider.reportOutgoingCall(with: action.callUUID, startedConnectingAt: Date()) - startCallTimeout(for: action.callUUID, timeout: Self.outgoingCallTimeout) - Task { - await store.updateStatus(for: action.callUUID, status: .connecting) - await MainActor.run { CallEventEmitter.shared.send(OutgoingCallStartedEvent(id: action.callUUID)) } - } - action.fulfill() -} -``` - -`connectedAt` is reported only when the app asks (`CallManager.swift:502-520`): - -```swift -func reportOutgoingCallConnected(for id: UUID) async { - DialtonePlayer.shared.stop() - cancelCallTimeout(for: id) - let now = Date() - provider.reportOutgoingCall(with: id, connectedAt: now) - await store.update(for: id) { $0.status = .connected; $0.connectedAt = now } -} -``` - -### Android - -`startOutgoingCall` posts a **dialing** notification inside the `addCall` scope -(`CallManager.kt:275-296`): status `CONNECTING`, `CallNotificationManager.showDialingCall(...)`, -speaker endpoint for video, `DialtonePlayer.play(context)`, emit `OUTGOING_CALL_STARTED`, then -`startCallTimeout(id, outgoingCallTimeoutMs)`. No `setActive()`. - -`reportOutgoingCallConnected(id)` (`CallManager.kt:437-451`) is what promotes it: stop dialtone, -cancel timeout, status `CONNECTED` + `connectedAt`, `actions.setActive.trySend(Unit)`, and -`showOngoingCall(context, id, callerName, now.toEpochMilli())` — the chronometer notification, -posted with the *real* connect timestamp. - -Note the Android `reportOutgoingCallConnected` body is **line-for-line the same shape** as their -`fulfillIncomingCallConnected` (`CallManager.kt:417-433`): `setActive` + swap to the ongoing -notification. That is the symmetry our plan should preserve. - -The JS surface is a single `reportOutgoingCallConnected(id)` (`src/Calls.ts:580-582`) that hits -the identically-named native function on both platforms. - -## Design for our repo - -We are single-call, so no id parameter is needed — `reportOutgoingCallConnected()` operates on -`currentCallUUID` (iOS) / the one active `CallManager` call (Android), exactly as -`endCallKitSession()` / `endTelecomCall()` already do. - -### JS API (new) - -In `packages/react-native-webrtc/src/VoIP.ts`, re-exported from `src/index.ts` and -`packages/mobile-client/src/index.ts`: - -```ts -/** - * Reports that an outgoing call's media is connected — the remote party answered. - * Until this is called, the OS shows the call as "Calling…" / "Dialing…" and no - * call timer runs. No-op for incoming calls. - */ -export function reportOutgoingCallConnected(): Promise; -``` - -`setTelecomCallActive()` / `useTelecom().setCallActive` become an implementation detail of this -function. Both are currently public (`src/Telecom.ts:32-37`, re-exported via -`packages/mobile-client/src/index.ts`), so **deprecate rather than delete**: keep the export, -have it delegate to the new native path, and mark it `@deprecated — use reportOutgoingCallConnected()`. -It is Android-only and no-ops on iOS today, which is precisely the platform seam this change -removes. - -### iOS implementation - -**Track the direction.** `CallKitManager` sets `currentCallUUID` from *both* -`startCallWithDisplayName:` (line 53) and `reportIncomingCallWithDisplayName:` (line 86), and -nothing distinguishes them. Calling `reportOutgoingCall(with:connectedAt:)` on an incoming call -is CallKit misuse, so add the flag first: - -```objc -// CallKitManager.h — internal state, exposed read-only for the guard -@property(nonatomic, readonly) BOOL isOutgoingCall; -- (void)reportOutgoingCallConnected; -``` - -Set `_isOutgoingCall = YES` in `startCallWithDisplayName:`, `NO` in -`reportIncomingCallWithDisplayName:`, and `NO` in `cleanup` (alongside `isCallAnswered`). - -**Move `startedConnecting` into the delegate** (`CallKitManager.m:148-150`): - -```objc -- (void)provider:(CXProvider *)provider performStartCallAction:(CXStartCallAction *)action { - [provider reportOutgoingCallWithUUID:action.callUUID startedConnectingAtDate:[NSDate date]]; - [action fulfill]; -} -``` - -**Delete line 81** (the premature `connectedAtDate:`) and line 80 (now redundant — it moved into -the delegate). The completion block keeps only its error handling and `onCallStarted()`. - -**Add the reporter:** - -```objc -- (void)reportOutgoingCallConnected { - NSUUID *uuid = self.currentCallUUID; - if (uuid == nil || !self.isOutgoingCall) { - NSLog(@"[CallKitManager] No outgoing call to report as connected"); - return; - } - [self.provider reportOutgoingCallWithUUID:uuid connectedAtDate:[NSDate date]]; -} -``` - -**Bridge** (`WebRTCModule+CallKit.m`, next to `endCallKitSession`): - -```objc -RCT_EXPORT_METHOD(reportOutgoingCallConnected:(RCTPromiseResolveBlock)resolve - rejecter:(RCTPromiseRejectBlock)reject) -``` - -**Ordering constraint.** CallKit ignores a `connectedAt` report for a call it has not seen -`startedConnectingAt` for, and ignores both before the `CXStartCallAction` is fulfilled. Our JS -flow is safe by construction: `VoipProvider.startCall()` awaits `startCallKitSession(to)` — whose -promise resolves in the transaction completion, i.e. *after* the delegate ran and fulfilled — and -only then joins the room. The `currentCallUUID == nil` guard covers the rest. - -### Android implementation - -**Track the direction.** `CallManager.register()` already receives `direction`; store it: -`@Volatile private var isOutgoing = false`, set from -`direction == CallAttributesCompat.DIRECTION_OUTGOING`, cleared in the `finally` block (line 200) -next to `hasActiveCall = false`. - -**Dial with a connecting notification** (`CallManager.kt:172-173`): - -```kotlin -if (isIncoming) callNotificationManager.showIncoming(ctx.applicationContext, displayName, isVideo) -else showConnectingNotification() // "Dialing…", no chronometer -``` - -**Promote on connect** — one shared private helper, because this is the exact body that -[ANSWER-HANDSHAKE-PLAN.md](./ANSWER-HANDSHAKE-PLAN.md)'s `fulfillAnswered()` also needs: - -```kotlin -/** Telecom goes active and the shade switches to the chronometer notification. */ -private fun markConnected() { - setCallActive() // actions.trySend(CallAction.Activate) - showOngoingNotification() // ForegroundServiceController.onCallConnected() -} - -fun reportOutgoingCallConnected() { - if (!hasActiveCall || !isOutgoing) return - markConnected() -} -``` - -**The foreground service must learn a second state.** `ForegroundServiceController.onCallStarted` -conflates "a call exists" with "the call connected at *now*" (it sets `callConnectedAtMs` in the -same breath, line 126). Split it: - -```java -// ForegroundServiceController.java -public synchronized void onCallStarted(String displayName, boolean isVideo) { - callActive = true; - callDisplayName = displayName != null ? displayName : ""; - callIsVideo = isVideo; - callConnectedAtMs = 0L; // ← 0 == not connected yet - applyState(); -} - -public synchronized void onCallConnected() { - if (!callActive) return; - callConnectedAtMs = System.currentTimeMillis(); - applyState(); -} -``` - -`applyState()` already re-issues `startService(...)` with fresh extras, and -`WebRTCForegroundService.onStartCommand` re-`startForeground()`s with a rebuilt notification, so -this is an in-place notification update with no new plumbing. The FGS type set -(`microphone`, plus `camera` for video) is unchanged across the transition — the service starts -at dial time and simply swaps its notification, which keeps us clear of -[foreground-service background-start restrictions](https://developer.android.com/develop/background-work/services/fgs/restrictions-bg-start). - -`WebRTCForegroundService.java:58-62` then branches on the sentinel: - -```java -long connectedAt = intent.getLongExtra("voipConnectedAt", 0L); -Notification notification = connectedAt > 0 - ? callNotificationManager.buildOngoing(this, voipDisplayName, connectedAt) - : callNotificationManager.buildConnecting(this, voipDisplayName, /* isOutgoing */ ...); -``` - -**`CallNotificationManager.buildConnecting(...)`** reuses `callNotificationBuilder(ctx, -CHANNEL_ONGOING, displayName, text)` with `text = "Dialing…"` and -[`CallStyle.forOngoingCall(person, hangupPendingIntent)`](https://developer.android.com/reference/androidx/core/app/NotificationCompat.CallStyle#forOngoingCall(androidx.core.app.Person,android.app.PendingIntent)) -— there is no dedicated "dialing" CallStyle; `forOngoingCall` is the correct one, it just gets a -hangup action and **no** `setUsesChronometer(true)` / `setWhen(...)`. Omitting the chronometer is -the entire fix. (Item #12 wants "Dialing…" for outgoing and "Connecting…" for a freshly answered -incoming call; pass the string in so both plans share this builder.) - -**Bridge**: `WebRTCModule.java` gains `reportOutgoingCallConnected(Promise)` next to the existing -telecom block (lines 1707-1733), routed through `TelecomController.reportOutgoingCallConnected()` -with the usual `Build.VERSION.SDK_INT >= O` guard. - -**Side effect worth noting:** `onSetActive = { answered = true }` (`CallManager.kt:168`) means the -Android `answered` flag now flips at real connect time for outgoing calls rather than whenever JS -happened to call `setTelecomCallActive()`. That is strictly more correct and matches the flag's -`isTelecomCallAnswered()` contract. - -### Symmetry with the answer handshake (#1) - -After both plans land, "the call is really up" has exactly one meaning per platform and one -entry point per direction: - -| | incoming | outgoing | -| --- | --- | --- | -| JS calls | `fulfillIncomingCallConnected(requestId)` | `reportOutgoingCallConnected()` | -| iOS does | `action.fulfill()` on the parked `CXAnswerCallAction` | `reportOutgoingCall(with:connectedAt:)` | -| Android does | `markConnected()` | `markConnected()` | - -Land #1 first if you are doing both: it introduces `showConnectingNotification()` and the -`markConnected()` split, and this plan then reduces to the iOS reporting fix plus the `isOutgoing` -guard. Landing #2 first is also fine — it introduces the same two helpers — but do not build them -twice. - -### Example app (`app/src/voip/VoipProvider.tsx`) - -The `remotePeers` effect (lines 184-201) is already the "media is live" signal, and for an -outgoing call `remotePeers.length > 0` means precisely *the callee joined the room* — the answer -event we never had. It becomes direction-aware and stops being platform-conditional: - -```tsx -if (status === 'connecting' && remotePeers.length > 0) { - const call = currentCallRef.current; - if (call?.isOutgoing) { - await reportOutgoingCallConnected(); - } else if (pendingRequestIdRef.current) { // from ANSWER-HANDSHAKE-PLAN - const connected = await fulfillIncomingCallConnected(pendingRequestIdRef.current); - pendingRequestIdRef.current = null; - if (!connected) { await endCall(); return; } - } - setStatus('active'); - // ...startedAt bookkeeping unchanged — it now agrees with the OS timer -} -``` - -The Android-only `setTelecomCallActive()` call goes away. `currentCall.isOutgoing` already exists -(`VoipContext.ts`, set in `startCall`), and `OutgoingCallScreen` already renders for -`status === 'connecting'`, so the app-side "ringing" UI needs no work. - -`startedAt` is currently stamped in this effect and drives the in-app call duration; it now lines -up with the OS timer to within a render, instead of trailing it by the whole ring duration. - -## Deliberately omitted: the dialtone - -Per the request, no `DialtonePlayer`. What that costs and where it would go: - -- **Cost:** during `status === 'connecting'` on an outgoing call the earpiece is silent. The user - gets no audible confirmation that the callee's phone is ringing — only `OutgoingCallScreen`. - This makes the in-app ringing UI load-bearing rather than decorative, and it makes the missing - outgoing timeout (below) more noticeable, since silence and "callee never picked up" are - indistinguishable to the ear. -- **Where it would hook, if added later:** iOS from `provider:didActivateAudioSession:` - (`CallKitManager.m:186-188`) gated on `isOutgoingCall && !isConnected` — the reference gates on - exactly that predicate (`session.origin == .outgoingApp && session.status == .connecting`) — - and stopped in `reportOutgoingCallConnected`, `endCall`, and the timeout handler. Android from - the `addCall` scope at dial time, stopped in `markConnected()` and the `finally` block. -- The `isOutgoingCall` flag and the connecting/connected split this plan introduces **are** that - predicate. Nothing here needs redoing to add sound later; it is a `play()`/`stop()` pair at four - call sites. - -## Regression risk: an unanswered outgoing call now hangs forever - -Read this before shipping. Today the instant `connectedAt` report at least made the system UI -settle into a stable (wrong) state. After this change, an outgoing call that is never answered -displays "Calling…" indefinitely: CallKit will not time it out, and our Android side has no timer -either. - -The example app doesn't save us. Its only teardown paths are `status === 'active' && remotePeers.length === 0` -(never reached, since we never became active) and the user hanging up. - -The reference handles this with **FEATURE-IDEAS item #3** — a 60 s outgoing timeout started in -`performStartCallAction` / the `addCall` scope, ending the call with reason `unanswered`. That -item is a much smaller job than #3's full three-timeout story: this plan only needs the outgoing -one, and it needs nothing from the dialtone. - -**Recommendation: land [CALL-TIMEOUTS-PLAN.md](./CALL-TIMEOUTS-PLAN.md) Steps 1–5 first, then -ship this plan together with its Step 6.** That plan (written after this one) carries the full -timeout design — cancellable `dispatch_block_create` timer with a wall clock on iOS, coroutine -`Job` on Android, `FishjamVoip*Timeout` config keys (same key name in Info.plist *and* manifest -meta-data), default 60 s — and its Step 6 is exactly the iOS outgoing timer this section needs: -start in `provider:performStartCallAction:`, cancel in `reportOutgoingCallConnected` + `cleanup`. -With Steps 1–5 already merged, Step 6 is a ~15-line addition to this PR because the timer -infrastructure, config reads, and the Android outgoing timeout all exist. - -Two corrections to what this section previously claimed: -- No dependency on item #6 for the *unanswered* label: `endCallWithReason:@"missed"` already maps - to `CXCallEndedReasonUnanswered` (`CallKitManager.m:123-124`), so expiry via the existing - end-call path labels the call correctly today. -- The config reader helpers come from CALL-TIMEOUTS-PLAN Step 3, not ANSWER-HANDSHAKE-PLAN (the - handshake shipped with a hardcoded 10 s deadline and no readers). - -If you nevertheless ship #2 without any timeout, say so in the example's README and file #3 as a -follow-up — do not leave it undiscovered. - -## Scope note: Recents - -FEATURE-IDEAS #2 cites "correct call duration in the dynamic island / status bar / Recents". -Recents is **not** a benefit you will observe from this change alone: we set -`providerConfiguration.includesCallsInRecents = NO` (`CallKitManager.m:33`), so our calls never -reach the Recents list. Flipping that flag is item #11. The immediately visible wins here are the -dynamic island, the status-bar pill, the lock-screen call UI, and the Android shade. - -## Implementation checklist - -- [ ] `ios/RCTWebRTC/CallKitManager.h` — `isOutgoingCall` readonly property; - `- (void)reportOutgoingCallConnected;` -- [ ] `ios/RCTWebRTC/CallKitManager.m` — set/clear `_isOutgoingCall` in - `startCallWithDisplayName:` / `reportIncomingCallWithDisplayName:` / `cleanup`; move - `startedConnectingAtDate:` into `provider:performStartCallAction:`; **delete both report - lines from the transaction completion (80-81)**; add `reportOutgoingCallConnected` with the - `currentCallUUID` + `isOutgoingCall` guard -- [ ] `ios/RCTWebRTC/WebRTCModule+CallKit.m` — `RCT_EXPORT_METHOD(reportOutgoingCallConnected:…)` -- [ ] `android/.../voip/CallManager.kt` — `isOutgoing` flag (set in `register`, cleared in - `finally`); dial posts `showConnectingNotification()`; `markConnected()` helper; - `reportOutgoingCallConnected()` guarded on `hasActiveCall && isOutgoing` -- [ ] `android/.../voip/CallNotificationManager.kt` — `buildConnecting(ctx, displayName, text)`: - `CallStyle.forOngoingCall` + hangup action, **no** `setUsesChronometer`/`setWhen` -- [ ] `android/.../foregroundService/ForegroundServiceController.java` — `onCallStarted` sets - `callConnectedAtMs = 0`; new `onCallConnected()` stamps it and re-applies state -- [ ] `android/.../foregroundService/WebRTCForegroundService.java` — branch on - `voipConnectedAt > 0` → `buildOngoing` else `buildConnecting` -- [ ] `android/.../TelecomController.java` + `WebRTCModule.java` — - `reportOutgoingCallConnected(Promise)` with the API-26 guard -- [ ] `src/Telecom.ts` — `reportOutgoingCallConnected()` wrapper; mark `setTelecomCallActive` - `@deprecated` and delegate to it -- [ ] `src/useTelecom.ts` — same deprecation note on `setCallActive` -- [ ] `src/VoIP.ts` — cross-platform `reportOutgoingCallConnected()` -- [ ] `src/index.ts` + `packages/mobile-client/src/index.ts` — re-export -- [ ] `examples/.../app/src/voip/VoipProvider.tsx` — direction-aware `remotePeers` effect; drop - the Android-only `setTelecomCallActive()` -- [ ] `examples/mobile-client/voip-call/README.md` — document the outgoing lifecycle - (`startCallKitSession` → "Calling…" → `reportOutgoingCallConnected()` → timer starts) -- [ ] **Strongly recommended, same PR:** outgoing timeout (see the regression-risk section) -- [ ] `FEATURE-IDEAS.md` #2 — link here; mark done when it lands - -### Verification - -- Compile: `JAVA_HOME=~/.sdkman/candidates/java/17.0.18-zulu ./gradlew - :fishjam-cloud_react-native-webrtc:compileDebugKotlin` (per project memory — the default sdkman - JDK 26 breaks gradle), plus an iOS build of `examples/mobile-client/voip-call/app`. -- Device checks, two phones, both platforms as caller: - 1. **The headline** — call B from A, let it ring ~15 s, then answer. A's dynamic island / status - bar timer must read ~0:00 at the moment B answers, not ~0:15. Compare against the in-app - `startedAt` duration on `InCallScreen`; they should agree. This is the whole point of the - change and it is only observable on a real device. - 2. **Android shade** — while ringing, pull down the shade: "Dialing…", no running timer, hangup - button works. After B answers: chronometer starts from 0. - 3. **Declined / never answered** — confirm the caller's UI never shows a running timer, and - (if the timeout ships) that the call self-terminates at ~60 s. - 4. **Caller hangs up while ringing** — exactly one `ended` event; no chronometer ever appeared; - FGS stops. - 5. **Incoming calls unaffected** — `reportOutgoingCallConnected()` must be a no-op; verify the - `isOutgoingCall` / `isOutgoing` guards by calling it from JS during an incoming call and - confirming nothing changes. - 6. **Android FGS types** — video outgoing call: confirm `camera` + `microphone` types are held - across the dialing → connected notification swap (`adb shell dumpsys activity services`), - i.e. the swap does not restart the service into a narrower type set. - -## Open questions - -- **Does `reportOutgoingCall(with:connectedAt:)` accept a backdated `Date`?** It does for - `startedConnectingAt`. If it does for `connectedAt` too, the connect timestamp could come from - the peer-joined event rather than from when the JS round-trip completes, shaving the bridge - latency off the reported duration. Worth measuring; not required for correctness. -- **Should `reportOutgoingCallConnected()` reject instead of silently no-op'ing** when there is no - outgoing call? Silent no-op matches `endCallKitSession()`'s existing behaviour - (`CallKitManager.m:114-117` just logs), so this plan keeps it consistent. Revisit if #14's call - session model gives JS a way to know the direction before calling. -- **Android "Dialing…" copy** is hardcoded English, like everything else in - `CallNotificationManager`. Item #12 flags i18n for the whole file; don't solve it here, but put - the string next to the others so there is one place to fix. From 58aa59d28c7876129abdcb8d7f09b188d7fa4f46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Thu, 16 Jul 2026 19:47:45 +0200 Subject: [PATCH 45/52] Use new fish avatar icons on server Co-Authored-By: Claude Fable 5 --- .../voip-call/server/avatars/coral.png | Bin 10686 -> 10720 bytes .../voip-call/server/avatars/mint.png | Bin 10943 -> 11036 bytes .../voip-call/server/avatars/ocean.png | Bin 10811 -> 10867 bytes .../voip-call/server/avatars/orchid.png | Bin 10888 -> 10897 bytes .../voip-call/server/avatars/sunny.png | Bin 10109 -> 10136 bytes 5 files changed, 0 insertions(+), 0 deletions(-) diff --git a/examples/mobile-client/voip-call/server/avatars/coral.png b/examples/mobile-client/voip-call/server/avatars/coral.png index f3fb417e653ff297b7d373b472fe9bf518cdf790..db8b92bdecc2e01de4971041a873ac8dc18ae531 100644 GIT binary patch literal 10720 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 literal 10686 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 diff --git a/examples/mobile-client/voip-call/server/avatars/mint.png b/examples/mobile-client/voip-call/server/avatars/mint.png index 3fce0bcca721dbbdc3650b286c10c0db731f6236..5a8dfd4298a3ebd638a6b78dab4b6b04d5f555a1 100644 GIT binary patch literal 11036 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 literal 10943 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 h00006Nkl diff --git a/examples/mobile-client/voip-call/server/avatars/ocean.png b/examples/mobile-client/voip-call/server/avatars/ocean.png index 835b7c5d4d1a2e815a3ace5e85c6aaa2fbbefa84..67df1936941a9a24cf7b43f429df9863b9883509 100644 GIT binary patch literal 10867 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 B_+kJ6 literal 10811 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 diff --git a/examples/mobile-client/voip-call/server/avatars/sunny.png b/examples/mobile-client/voip-call/server/avatars/sunny.png index be807d25112c4878dabe205a957656813181102e..39fd08500d37d654ad5022b38fbec5cece2cbb22 100644 GIT binary patch literal 10136 zcmYj%bx>U0?>Dk7OL2F%;#MeDmPJbO0>#~n>*Bh2ad&8OcXungxH}YzyE{MneCM5c z|F|-DCMP#1Imssy`c+;M?H%zuI5;>osW0M6ug`&h7x3-ty-dA46Alg&PD&j7%@zJI z10|kR&28tTEVC*gFQ&7vA8=S_j1|n;8=zWwp?S=@}l87h^TZt@ClM!o65U z+3R58YX5jLMFDak-2Slga`SYss-?1WRCuk&2u$ulDG+u(y*4XjIr1_;IsIdMiO#Cs z;_!XL^k)3ejw(94_ll0@qpGd>iHk=tKKU`(>O{>PHac?ULsNN(-%V(dCO{ez!ggYFQ zX^8XzbM0&ehi01y3B*rv-zpClN9R++W!m<|O#cTb8Nc4i|F`}-r|dLg?Y%JJwss75 zZ`nyT!#Ms{yL}O}w1GeondtD!6q>M|8+10^_UhVGM&#ZWWcmKWF`4mp1?aUJvayuy zXrc4vzNYQ0sE}3k^IEXnd{9JYnIU(5)arzj`FG^lbz8^7(Sn=AYu!I=M^7lo^}#x7 zD@+&14|S6rEPjnVzucaFYua8}L8c$7R!9(yVmLYL@6oL4->`yNo;hV6vY7pxtZmKh zvmWNpy*1vge2;gl!py!;=`vjVqQN!gYNWXRxv!6qh}no->Lh@dErxP4aQ^&P+t@KH z>oVmznc7u5t#PC1=Sn%_$(MRg)jrs~gbiGn6ou2`nH&!o*3)2{z`QFce$Bt=DZ%Jf zG+=IF@nTNY(vHj0nd@SFMyCIv&TgQyAeNFj`1saMZFa+^z_CHUQpPy#%R>XcpY@PR zODC|j)5`kHDP0Z?4D-{Pb-W2>&Y2xGL-qKvcX~FE17PMr6yyL5$0S6CLPHsVV+igt z9&3LyAA3(6Z=@b4NAVFvM?0+bfr>iMt2Y!w|4qpBAA}P;^K8^z-@=0x9sWYzGgKjLN+UDB52u$@!*c{y`kY^CmZeONTDHxE)tQv3 z@Vi0fI<27MGmd!vCPq{Fy%&*s&Jpgz~7ZH{)7Z^?L-c4js-V8Lj4uZGJ=AaY zS6~CLiCqL`Nc5(=_x`$)(l2raGYFooLQ8n`-I|u0R}%0A`H2`A!LX)52PSKxFS2PX-GzKSw%dh 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 literal 10109 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 From 734ec5aaa7a77f0595e84212c4a98319e25094df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gadomski?= Date: Fri, 17 Jul 2026 11:43:38 +0200 Subject: [PATCH 46/52] Bump --- packages/react-native-webrtc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native-webrtc b/packages/react-native-webrtc index ae4db4f35..c1fb53571 160000 --- a/packages/react-native-webrtc +++ b/packages/react-native-webrtc @@ -1 +1 @@ -Subproject commit ae4db4f3562c09b7c23aaf8703dae3490ee36cee +Subproject commit c1fb5357120ab7b4a585ba3b04cb32e8d687a57b From 2b66d5b611dc8807002b5b46030a2b3d246b4be9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Fri, 17 Jul 2026 12:07:52 +0200 Subject: [PATCH 47/52] Address Copilot review feedback - server: restore optional FCM/APNs credentials via readIfPresent helper, guard sendFcmPush/sendApnsPush with clear errors when creds are missing - app: drop stale setTelecomCallActive from effect dependency array - app: route call-cancelled/call-rejected signals by stable handle instead of displayName - docs: rename includeCallsInRecents -> enableCallIntents and FishjamVoip* timeout keys -> Voip* to match the implementation Co-Authored-By: Claude Fable 5 --- examples/mobile-client/voip-call/README.md | 36 +++--- examples/mobile-client/voip-call/app/App.tsx | 2 +- .../app/src/signaling/useCallSignaling.ts | 4 +- .../voip-call/app/src/voip/VoipProvider.tsx | 2 +- .../mobile-client/voip-call/server/main.ts | 120 ++++++++++-------- packages/mobile-client/README.md | 10 +- 6 files changed, 97 insertions(+), 77 deletions(-) diff --git a/examples/mobile-client/voip-call/README.md b/examples/mobile-client/voip-call/README.md index 5e5c2877f..7fa77cb1c 100644 --- a/examples/mobile-client/voip-call/README.md +++ b/examples/mobile-client/voip-call/README.md @@ -124,11 +124,11 @@ which writes this for you). Already present in this app: Optional native timeout values are numeric `Info.plist` entries: ```xml -FishjamVoipIncomingCallTimeout +VoipIncomingCallTimeout 45 -FishjamVoipOutgoingCallTimeout +VoipOutgoingCallTimeout 60 -FishjamVoipFulfillAnswerTimeout +VoipFulfillAnswerTimeout 10 ``` @@ -195,7 +195,7 @@ With the Fishjam Expo plugin, use: ```json { "voip": { - "includeCallsInRecents": true + "enableCallIntents": true } } ``` @@ -317,7 +317,7 @@ outgoing calls from the same place `fulfillIncomingCallConnected` is called for incoming ones. If the callee never answers, the native outgoing timeout (default 60 seconds, -configurable via `FishjamVoipOutgoingCallTimeout` / the `voip.outgoingCallTimeout` +configurable via `VoipOutgoingCallTimeout` / the `voip.outgoingCallTimeout` plugin option — see [section 9.1](#91-enable-the-plugin-option)) ends the call as `missed` on both platforms, so the caller never sees an indefinitely "Calling…" screen. @@ -345,7 +345,7 @@ re-applied on every prebuild. the app is backgrounded or killed. 4. Tap **Answer** / **End** on the system UI and confirm the `answer` / `ended` events log in Metro. -5. With `includeCallsInRecents` enabled, complete a call and confirm that it +5. With `enableCallIntents` enabled, complete a call and confirm that it appears in Phone → Recents. Tap its entry while the app is backgrounded and after it has been terminated; the app should reopen and invoke `onCallIntent` exactly once. @@ -384,7 +384,7 @@ permissions, `IncomingCallActivity`, `EndCallNotificationReceiver`, and the "incomingCallTimeout": 45, "outgoingCallTimeout": 60, "fulfillAnswerCallTimeout": 10, - "includeCallsInRecents": true + "enableCallIntents": true } } ] @@ -436,7 +436,7 @@ push wake a killed app and reach `PushNotificationService`. No JS Firebase SDK a The file is **gitignored** (`app/.gitignore`), so it never arrives via `git clone` and each developer must fetch their own. In the [Firebase console](https://console.firebase.google.com/), open the same project the server's `fcm-credentials.json` came from (see -[`server/README.md`](./server/README.md)) → *Project settings* → *Your apps* → add an +[`server/README.md`](./server/README.md)) → _Project settings_ → _Your apps_ → add an **Android** app if none exists → download `google-services.json` → save it at `app/google-services.json`. @@ -446,12 +446,12 @@ exactly (`io.fishjam.example.voipcall`), or the Gradle plugin fails the build wi ### 9.4 What goes wrong if you set only one -| `enableVoip` | `googleServicesFile` | Result | -| --- | --- | --- | -| `true` | set, file present | Works. | -| `true` | set, file missing | `expo prebuild` fails: `Cannot copy google-services.json`. | -| `true` | absent | Prebuild **succeeds**, Firebase is never wired up, pushes never arrive. | -| `false` | absent | Fine — no Firebase, no VoIP. This is the opt-out. | +| `enableVoip` | `googleServicesFile` | Result | +| ------------ | -------------------- | ----------------------------------------------------------------------- | +| `true` | set, file present | Works. | +| `true` | set, file missing | `expo prebuild` fails: `Cannot copy google-services.json`. | +| `true` | absent | Prebuild **succeeds**, Firebase is never wired up, pushes never arrive. | +| `false` | absent | Fine — no Firebase, no VoIP. This is the opt-out. | The third row is the dangerous one: it fails silently at runtime rather than at build time. If Android calls never ring, check this first. @@ -483,7 +483,7 @@ apply plugin: 'com.google.gms.google-services' ``` Then place `google-services.json` at `android/app/google-services.json` (in a bare -project this *is* the durable location — nothing regenerates the directory). +project this _is_ the durable location — nothing regenerates the directory). Second, the manifest entries. Add to `android/app/src/main/AndroidManifest.xml`: @@ -522,13 +522,13 @@ Second, the manifest entries. Add to `android/app/src/main/AndroidManifest.xml`: