diff --git a/examples/mobile-client/voip-call/README.md b/examples/mobile-client/voip-call/README.md index 647c0d2d7..9e6a8e573 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 @@ -117,9 +121,110 @@ 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 +VoipIncomingCallTimeout +45 +VoipOutgoingCallTimeout +60 +VoipFulfillAnswerTimeout +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**. +### 4.1 iOS Recents and tap-to-redial + +Every VoIP call is recorded in the system Phone app's Recents — the SDK always +reports calls with `includesCallsInRecents` enabled, and there is no opt-out. +Note that Recents entries persist on the device and may sync through iCloud. + +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": { + "enableCallIntents": true + } +} +``` + +The plugin writes the `NSUserActivityTypes` entries 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 @@ -151,16 +256,68 @@ Voice over IP** and **Push Notifications**. ## 6. JS side — subscribe to events ```ts -import { useCallKitEvent } from "./src/callkit"; +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. + +### 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"; -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)); +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 `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. + --- ## 7. Expo caveat @@ -184,3 +341,215 @@ 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 `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. +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. + +--- + +## 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 + }, + "voip": { + "incomingCallTimeout": 45, + "outgoingCallTimeout": 60, + "fulfillAnswerCallTimeout": 10, + "enableCallIntents": true + } + } + ] +] +``` + +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: + +```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/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 +`@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..4479cfdd0 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, @@ -15,11 +15,13 @@ import { } from 'react-native'; import { SafeAreaProvider } from 'react-native-safe-area-context'; +import type { VoipIncomingPayload } from '@fishjam-cloud/react-native-client'; 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 +30,35 @@ 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, + 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.handle, + roomName: payload.roomName, + }); + }, []); + const { getSandboxPeerToken } = useSandbox({ sandboxApiUrl: SANDBOX_API_URL, }); @@ -64,13 +93,31 @@ function VoipWrapper({ children }: PropsWithChildren) { + onWaitingCallDeclined={onWaitingCallDeclined} + isVideo={true} + canStartOutgoingCall={Boolean(username)}> + + {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(); @@ -136,7 +183,7 @@ function AppScreens() { return ; } - return ; + return ; } const App = () => ( @@ -145,7 +192,7 @@ const App = () => ( - + diff --git a/examples/mobile-client/voip-call/app/app.json b/examples/mobile-client/voip-call/app/app.json index d39514542..6a37f8b89 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", @@ -37,8 +46,20 @@ }, "ios": { "enableVoIPBackgroundMode": true + }, + "voip": { + "incomingCallTimeout": 20, + "outgoingCallTimeout": 20 } } + ], + [ + "expo-splash-screen", + { + "image": "./assets/images/splash.png", + "resizeMode": "contain", + "backgroundColor": "#F1FAFE" + } ] ] } diff --git a/examples/mobile-client/voip-call/app/assets/images/adaptive-icon.png b/examples/mobile-client/voip-call/app/assets/images/adaptive-icon.png new file mode 100644 index 000000000..4053315ea Binary files /dev/null and b/examples/mobile-client/voip-call/app/assets/images/adaptive-icon.png differ diff --git a/examples/mobile-client/voip-call/app/assets/images/favicon.png b/examples/mobile-client/voip-call/app/assets/images/favicon.png new file mode 100644 index 000000000..408bd7466 Binary files /dev/null and b/examples/mobile-client/voip-call/app/assets/images/favicon.png differ diff --git a/examples/mobile-client/voip-call/app/assets/images/fishjam-logo.png b/examples/mobile-client/voip-call/app/assets/images/fishjam-logo.png new file mode 100644 index 000000000..6f4d3c4a1 Binary files /dev/null and b/examples/mobile-client/voip-call/app/assets/images/fishjam-logo.png differ diff --git a/examples/mobile-client/voip-call/app/assets/images/icon.png b/examples/mobile-client/voip-call/app/assets/images/icon.png new file mode 100644 index 000000000..31be37b2c Binary files /dev/null and b/examples/mobile-client/voip-call/app/assets/images/icon.png differ diff --git a/examples/mobile-client/voip-call/app/assets/images/splash.png b/examples/mobile-client/voip-call/app/assets/images/splash.png new file mode 100644 index 000000000..f064caf2d Binary files /dev/null and b/examples/mobile-client/voip-call/app/assets/images/splash.png differ diff --git a/examples/mobile-client/voip-call/app/package.json b/examples/mobile-client/voip-call/app/package.json index beb18f242..ff2a6ea7c 100644 --- a/examples/mobile-client/voip-call/app/package.json +++ b/examples/mobile-client/voip-call/app/package.json @@ -13,6 +13,7 @@ "@fishjam-cloud/react-native-client": "workspace:*", "@react-native-async-storage/async-storage": "^3.1.1", "expo": "~54.0.30", + "expo-splash-screen": "~31.0.13", "expo-status-bar": "~3.0.9", "react": "19.1.0", "react-native": "0.81.5", 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/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/components/VideoCallView.tsx b/examples/mobile-client/voip-call/app/src/components/VideoCallView.tsx index b578819d5..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) { /> ) : ( - + )} @@ -47,13 +54,14 @@ export function VideoCallView({ remoteName, localName }: VideoCallViewProps) { {localStream ? ( ) : ( - + )} @@ -75,8 +83,8 @@ const styles = StyleSheet.create({ right: 16, width: 108, height: 156, - borderRadius: 16, - overflow: 'hidden', + // Square corners on purpose: RTCView is a SurfaceView on Android and cannot + // be clipped by borderRadius/overflow, so a rounded PiP overshoots there. backgroundColor: BrandColors.seaBlue60, borderWidth: 1, borderColor: 'rgba(255, 255, 255, 0.6)', 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..cc28d7160 100644 --- a/examples/mobile-client/voip-call/app/src/screens/InCallScreen.tsx +++ b/examples/mobile-client/voip-call/app/src/screens/InCallScreen.tsx @@ -1,16 +1,19 @@ import { + setCallMuted, useAudioOutput, useCamera, useMicrophone, usePeers, useVAD, } from '@fishjam-cloud/react-native-client'; -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, 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'; 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 }; @@ -40,9 +43,17 @@ function useElapsed(startedAt: number | null): number { } export function InCallScreen() { - const { currentCall, endCall } = useVoip(); + const { currentCall, endCall, isOnHold, setCallHeld } = useVoip(); + const { username, avatarUrlFor } = useUser(); const { isMicrophoneOn, toggleMicrophone } = useMicrophone(); + // Mute the mic AND drive the system mute indicator (CallKit on iOS). The + // resulting onMuteChanged is idempotent, so this doesn't loop. + const handleToggleMute = useCallback(async () => { + const willBeMuted = isMicrophoneOn; + await toggleMicrophone(); + await setCallMuted(willBeMuted); + }, [isMicrophoneOn, toggleMicrophone]); const { isCameraOn, toggleCamera } = useCamera(); const { currentAudioOutput, availableAudioOutputs, ios, android } = useAudioOutput(); @@ -74,13 +85,41 @@ export function InCallScreen() { } }; + const bluetoothDevice = availableAudioOutputs.find( + (device) => device.type === 'bluetooth', + ); + const isBluetooth = currentAudioOutput?.type === 'bluetooth'; + + const selectBluetooth = () => { + if (Platform.OS === 'ios') { + // iOS has no public API to force a specific Bluetooth route; restoring + // the default route sends audio back to the connected Bluetooth device. + ios + .overrideAudioOutput('none') + .catch((err) => console.warn('Failed to switch audio output:', err)); + return; + } + if (bluetoothDevice) { + android + .selectAudioOutput(bluetoothDevice.id) + .catch((err) => console.warn('Failed to switch audio output:', err)); + } + }; + + const toggleHold = () => { + setCallHeld(!isOnHold).catch((err) => + console.warn('Failed to change held state:', err), + ); + }; + const controls = ( {isVideo && ( )} + {bluetoothDevice && ( + + )} + endCall('local')} accessibilityLabel="End call" /> @@ -108,7 +164,14 @@ export function InCallScreen() { if (isVideo) { return ( - + {/* Dark video background needs light status bar icons, unlike every other (light) screen. */} + + On call · {formatDuration(elapsed)} {remotePeers.length === 0 ? ( - + {displayName} ) : ( @@ -139,7 +206,12 @@ export function InCallScreen() { const isTalking = speaking[peer.id] ?? false; return ( - + {name} ); @@ -198,9 +270,9 @@ const styles = StyleSheet.create({ // shared control bar controls: { flexDirection: 'row', - gap: 14, + gap: 10, backgroundColor: 'rgba(255, 255, 255, 0.92)', - paddingHorizontal: 18, + paddingHorizontal: 12, paddingVertical: 14, borderRadius: 40, alignItems: 'center', diff --git a/examples/mobile-client/voip-call/app/src/screens/LoginScreen.tsx b/examples/mobile-client/voip-call/app/src/screens/LoginScreen.tsx index d0ac02706..da3ea1fb0 100644 --- a/examples/mobile-client/voip-call/app/src/screens/LoginScreen.tsx +++ b/examples/mobile-client/voip-call/app/src/screens/LoginScreen.tsx @@ -2,6 +2,7 @@ import { MaterialCommunityIcons } from '@expo/vector-icons'; import { useState } from 'react'; import { ActivityIndicator, + Image, KeyboardAvoidingView, Platform, StyleSheet, @@ -15,6 +16,9 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { AdditionalColors, BrandColors, TextColors } from '../theme/colors'; import { useUser } from '../user'; +// eslint-disable-next-line @typescript-eslint/no-require-imports +const FishjamLogo = require('../../assets/images/fishjam-logo.png'); + export function LoginScreen() { const { register } = useUser(); const [input, setInput] = useState(''); @@ -40,6 +44,12 @@ export function LoginScreen() { style={styles.flex} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}> + + { @@ -42,7 +44,11 @@ export function OutgoingCallScreen() { Calling - + {displayName} @@ -50,7 +56,7 @@ export function OutgoingCallScreen() { endCall('local')} accessibilityLabel="Cancel call" /> 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 92% 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..106f55a48 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()} @@ -62,7 +62,7 @@ export function DirectoryScreen() { 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/signaling/useCallSignaling.ts b/examples/mobile-client/voip-call/app/src/signaling/useCallSignaling.ts new file mode 100644 index 000000000..5078040b4 --- /dev/null +++ b/examples/mobile-client/voip-call/app/src/signaling/useCallSignaling.ts @@ -0,0 +1,112 @@ +import { useCallback, useEffect, useRef, type MutableRefObject } from 'react'; + +import { useVoip, type CurrentCall, type VoipCallStatus } 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, + sendSignalRef, +}: 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(() => { + sendSignalRef.current = sendSignal; + }, [sendSignal, sendSignalRef]); + + 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 — from + // our side this incoming call rang and was never answered. + if (msg.type === 'call-cancelled' && !currentCall.isOutgoing) { + void endCall('missed'); + } + // The callee rejected while we (the caller) are still ringing out — the + // other party ended it, not us. + else if (msg.type === 'call-rejected' && currentCall.isOutgoing) { + void endCall('remote'); + } + }; + + 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.handle, + roomName: prevCall.roomName, + }); + } + // Callee rejected an incoming call before answering. + else if (prevStatus === 'incoming' && !prevCall.isOutgoing) { + sendSignal({ + type: 'call-rejected', + to: prevCall.handle, + roomName: prevCall.roomName, + }); + } + } + + prevRef.current = { status, call: currentCall }; + }, [status, currentCall, sendSignal]); +} 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/app/src/voip/VoipContext.ts b/examples/mobile-client/voip-call/app/src/voip/VoipContext.ts index 23af187c2..b7eb5d15f 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'; /** @@ -18,10 +19,18 @@ 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. */ startedAt: number | null; + /** `true` when this device initiated the call, `false` when receiving it. */ + isOutgoing: boolean; }; /** @@ -34,6 +43,14 @@ 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; + /** Whether the native CallKit/Core-Telecom session is currently held. */ + isOnHold: boolean; /** * Starts an outgoing call to `to` in the given `roomName` — reports it to * CallKit and joins the room. @@ -42,10 +59,13 @@ 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; + /** 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 3287e9e7b..cc0002e8d 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,9 @@ import { + type CallEndedReason, + failIncomingCallConnected, + fulfillIncomingCallConnected, + reportOutgoingCallConnected, + setCallHeld as setVoipCallHeld, useCallKit, useCamera, useConnection, @@ -6,6 +11,7 @@ import { usePeers, useTelecom, useVoIPEvents, + type VoipCallIntent, type VoipIncomingPayload, } from '@fishjam-cloud/react-native-client'; import { @@ -40,13 +46,28 @@ 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). */ 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 @@ -57,38 +78,81 @@ type VoipProviderProps = PropsWithChildren & { export function VoipProvider({ getPeerToken, requestCall, + onWaitingCallDeclined, isVideo = false, + canStartOutgoingCall = true, children, }: VoipProviderProps) { const [voipToken, setVoipToken] = useState(null); const [status, setStatus] = useState('available'); const [currentCall, setCurrentCall] = useState(null); + const [lastEndedReason, setLastEndedReason] = + useState(null); + const [isOnHold, setIsOnHold] = useState(false); const currentCallRef = useRef(null); - const { startCamera, stopCamera } = useCamera(); - const { startMicrophone, stopMicrophone } = useMicrophone(); + const pendingAnswerRequestIdRef = useRef(null); + const activationInFlightRef = useRef(false); + const isCallOnHoldRef = useRef(false); + const heldMediaStateRef = useRef({ + microphoneEnabled: false, + cameraEnabled: false, + }); + const pendingCallIntentRef = useRef(null); + /** Fishjam room the client is currently joined to, if any. */ + const connectedRoomRef = useRef(null); + /** + * 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 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) => { + const run = callTransitionRef.current.then(op); + callTransitionRef.current = run.catch(() => {}); + return run; + }, []); + + 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, - setCallActive: setTelecomCallActive, - } = useTelecom(); + 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({ displayName: to, handle: to, isVideo }) + : startTelecomSession({ displayName: to, handle: to, isVideo }), [startCallKitSession, startTelecomSession, isVideo], ); const endNativeCallSession = useCallback( - () => (Platform.OS === 'ios' ? endCallKitSession() : endTelecomSession()), + (reason?: CallEndedReason) => + Platform.OS === 'ios' + ? endCallKitSession(reason) + : endTelecomSession(reason), [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); @@ -97,35 +161,81 @@ 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 endCall = useCallback(async () => { - await endNativeCallSession(); - await handleLeaveRoom(); - currentCallRef.current = null; - setCurrentCall(null); - setStatus('available'); - }, [endNativeCallSession, handleLeaveRoom]); + const resetCallState = useCallback( + 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); + } + + if (connectedRoomRef.current === roomName) { + await handleLeaveRoom(); + } + }, + [handleLeaveRoom], + ); + + const endCall = useCallback( + async ( + reason: CallEndedReason = 'local', + options?: { fromNative?: boolean }, + ) => { + const endedCall = currentCallRef.current; + if (!endedCall) return; + + const resetPromise = resetCallState(reason, endedCall.roomName); + try { + // 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; + } + }, + [endNativeCallSession, resetCallState], + ); const startCall = useCallback( async (to: string, roomName: string) => { const call: CurrentCall = { roomName, displayName: to, + handle: to, isVideo, startedAt: null, + isOutgoing: true, }; currentCallRef.current = call; setCurrentCall(call); setStatus('connecting'); + setLastEndedReason(null); try { await requestCall({ to, roomName, isVideo }); @@ -133,80 +243,303 @@ 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], ); - 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(); - } - }, [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], + ); + + 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); }, []), - onIncoming: useCallback((payload: VoipIncomingPayload) => { - const call: CurrentCall = { - roomName: payload.roomName, - displayName: payload.displayName, - isVideo: payload.isVideo, - startedAt: null, - }; - currentCallRef.current = call; - setCurrentCall(call); - setStatus('incoming'); - }, []), + // 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 isAcceptedWaitingCall = + connectedRoom != null && + connectedRoom !== payload.roomName && + currentCallRef.current != null; + + if (isAcceptedWaitingCall) { + swapInProgressRef.current = true; + } + + try { + 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; + 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 (isAcceptedWaitingCall) { + swapInProgressRef.current = false; + } + } + }); + }, + [answerCall, enqueueCallTransition, handleLeaveRoom], + ), + + onAnswered: useCallback( + (requestId: string) => { + enqueueCallTransition(async () => { + if (pendingAnswerRequestIdRef.current) { + 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; + return; + } + + await answerCall(requestId); + }); + }, + [answerCall, enqueueCallTransition], + ), + + onEnded: useCallback( + (reason?: CallEndedReason) => { + enqueueCallTransition(async () => { + await endCall(reason ?? 'remote', { fromNative: true }); + }); + }, + [endCall, enqueueCallTransition], + ), - onAnswered: useCallback(async () => { - await answerCall(); - }, [answerCall]), + onHeldChanged: useCallback( + async (onHold: boolean) => { + if (!currentCallRef.current?.startedAt) { + return; + } - onEnded: useCallback(async () => { - await endCall(); - }, [endCall]), + 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], + ), + + 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) { + pendingCallIntentRef.current = intent; + return; + } + await startCallFromIntent(intent); + }, + [canStartOutgoingCall, startCallFromIntent], + ), + + onWaitingCallDeclined, }); useEffect(() => { - if (status === 'connecting' && remotePeers.length > 0) { - if (currentCallRef.current) { - const call = { ...currentCallRef.current, startedAt: Date.now() }; + if (!canStartOutgoingCall || !pendingCallIntentRef.current) { + return; + } + const intent = pendingCallIntentRef.current; + pendingCallIntentRef.current = null; + startCallFromIntent(intent); + }, [canStartOutgoingCall, startCallFromIntent]); + + useEffect(() => { + 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 (currentCallRef.current?.isOutgoing) { + await reportOutgoingCallConnected(); + } + + 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), - ); - } - } else if (status === 'active' && remotePeers.length === 0) { - endCall().catch((err) => console.error('Failed to end 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 && + !swapInProgressRef.current + ) { + endCall('remote').catch((err) => + console.error('Failed to end call:', err), + ); } - }, [remotePeers.length, status, endCall, setTelecomCallActive]); + }, [remotePeers.length, status, endCall]); const voipValue = useMemo( () => ({ voipToken, status, currentCall, + lastEndedReason, + isOnHold, startCall, answerCall, endCall, + setCallHeld, }), - [voipToken, status, currentCall, startCall, answerCall, endCall], + [ + voipToken, + status, + currentCall, + lastEndedReason, + isOnHold, + startCall, + answerCall, + endCall, + setCallHeld, + ], ); return ( diff --git a/examples/mobile-client/voip-call/server/README.md b/examples/mobile-client/voip-call/server/README.md index 789a448f6..3d734d120 100644 --- a/examples/mobile-client/voip-call/server/README.md +++ b/examples/mobile-client/voip-call/server/README.md @@ -50,11 +50,46 @@ 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, avatarUrl? }` | 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. `platform` must be `"ios"` or `"android"`; anything else is rejected with `400`. @@ -66,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 @@ -84,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 000000000..db8b92bde Binary files /dev/null and b/examples/mobile-client/voip-call/server/avatars/coral.png differ 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 000000000..5a8dfd429 Binary files /dev/null and b/examples/mobile-client/voip-call/server/avatars/mint.png differ 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 000000000..67df19369 Binary files /dev/null and b/examples/mobile-client/voip-call/server/avatars/ocean.png differ diff --git a/examples/mobile-client/voip-call/server/avatars/orchid.png b/examples/mobile-client/voip-call/server/avatars/orchid.png new file mode 100644 index 000000000..0e0f011d8 Binary files /dev/null and b/examples/mobile-client/voip-call/server/avatars/orchid.png differ 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 000000000..39fd08500 Binary files /dev/null and b/examples/mobile-client/voip-call/server/avatars/sunny.png differ diff --git a/examples/mobile-client/voip-call/server/main.ts b/examples/mobile-client/voip-call/server/main.ts index e0eef28d3..e09d72355 100644 --- a/examples/mobile-client/voip-call/server/main.ts +++ b/examples/mobile-client/voip-call/server/main.ts @@ -1,30 +1,61 @@ -import { Database } from "@db/sqlite"; -import { JWT } from "google-auth-library"; +import { Database } from '@db/sqlite'; +import { JWT } from 'google-auth-library'; -const db = new Database("voip.db"); +const db = new Database('voip.db'); -type DevicePlatform = "ios" | "android"; +type DevicePlatform = 'ios' | 'android'; const isDevicePlatform = (value: unknown): value is DevicePlatform => - value === "ios" || value === "android"; + 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')), + 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; }; -// 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); @@ -42,38 +73,38 @@ type ServiceAccount = { project_id: string; }; -const fcmCredentials = await readIfPresent("./fcm-credentials.json"); +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"], - }) + 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"); + 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"); + throw new Error('Android push requires ./fcm-credentials.json'); } const accessToken = await getAccessToken(authClient); const res = await fetch( `https://fcm.googleapis.com/v1/projects/${serviceAccount.project_id}/messages:send`, { - method: "POST", + method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", + 'Content-Type': 'application/json', }, body: JSON.stringify({ message: { @@ -82,8 +113,9 @@ async function sendFcmPush(params: PushParams): Promise { roomName: params.roomName, displayName: params.displayName, isVideo: String(params.isVideo), + ...(params.avatarUrl ? { avatarUrl: params.avatarUrl } : {}), }, - android: { priority: "high" }, + android: { priority: 'high' }, }, }), }, @@ -97,30 +129,31 @@ async function sendFcmPush(params: PushParams): Promise { // --- APNs VoIP push (certificate-based) --- -const BUNDLE_ID = "io.fishjam.example.voipcall"; -const APNS_HOST = "api.development.push.apple.com"; +const BUNDLE_ID = 'io.fishjam.example.voipcall'; +const APNS_HOST = 'api.development.push.apple.com'; -const apnsPem = await readIfPresent("./apns.pem"); +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"); + throw new Error('iOS push requires ./apns.pem'); } const res = await fetch(`https://${APNS_HOST}/3/device/${params.token}`, { client: apnsClient, - method: "POST", + method: 'POST', headers: { - "apns-push-type": "voip", - "apns-topic": `${BUNDLE_ID}.voip`, - "content-type": "application/json", + 'apns-push-type': 'voip', + 'apns-topic': `${BUNDLE_ID}.voip`, + 'content-type': 'application/json', }, body: JSON.stringify({ roomName: params.roomName, displayName: params.displayName, isVideo: params.isVideo, + ...(params.avatarUrl ? { avatarUrl: params.avatarUrl } : {}), }), }); @@ -138,21 +171,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) => { @@ -160,71 +186,135 @@ Deno.serve({ port: 4400 }, async (req) => { console.log(`${req.method} ${url.pathname}`); // POST /register { username, voipToken, platform } - if (req.method === "POST" && url.pathname === "/register") { + if (req.method === 'POST' && url.pathname === '/register') { const { username, voipToken, platform } = (await req.json()) as { username: string; voipToken: string; platform: string; }; if (!username || !voipToken) { - return json({ error: "username and voipToken are required" }, 400); + return json({ error: 'username and voipToken are required' }, 400); } if (!isDevicePlatform(platform)) { return json({ error: 'platform must be "ios" or "android"' }, 400); } + const existing = db.sql<{ avatar: string | null }>` + SELECT avatar FROM users WHERE username = ${username} + `; + const avatar = existing[0]?.avatar ?? assignLeastUsedAvatar(); db.exec( - `INSERT 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()], + `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 + if (req.method === 'GET' && url.pathname === '/users') { + const exclude = url.searchParams.get('exclude') ?? ''; + const rows = db.sql<{ username: string; avatar: string | null }>` + SELECT username, avatar FROM users WHERE username != ${exclude} ORDER BY username `; - return json(rows.map((r) => r.username)); + return json( + rows.map((r) => ({ + username: r.username, + avatarUrl: r.avatar ? avatarUrl(req, r.avatar) : null, + })), + ); } // POST /call { from, to, roomName } - if (req.method === "POST" && url.pathname === "/call") { + if (req.method === 'POST' && url.pathname === '/call') { const { from, to, roomName, isVideo } = (await req.json()) as { from: string; to: string; roomName: string; isVideo: boolean; }; - if (!from || !to || !roomName) - return json({ error: "from, to and roomName are required" }, 400); + 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); + 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); } + const callerRows = db.sql<{ avatar: string | null }>` + SELECT avatar FROM users WHERE username = ${from} + `; + const callerAvatar = callerRows[0]?.avatar; + try { await sendPush[platform]({ token: voipToken, roomName: roomName, displayName: from, isVideo: isVideo, + avatarUrl: callerAvatar ? avatarUrl(req, callerAvatar) : undefined, }); } catch (err) { console.error(`Failed to send ${platform} VoIP push:`, err); - return json({ error: "failed to send VoIP push" }, 502); + return json({ error: 'failed to send VoIP push' }, 502); } return json({ ok: true }); } - return new Response("Not found", { status: 404 }); + // GET /ws?username= — bidirectional signaling socket + if (req.method === 'GET' && url.pathname === '/ws') { + const username = url.searchParams.get('username'); + if (!username) return json({ error: 'username required' }, 400); + + const { socket, response } = Deno.upgradeWebSocket(req); + socket.onopen = () => { + sockets.set(username, socket); + console.log(`${username} connected`); + }; + socket.onclose = () => { + if (sockets.get(username) === socket) sockets.delete(username); + console.log(`${username} disconnected`); + }; + socket.onmessage = (e) => { + let msg: { type?: string; to?: string; [key: string]: unknown }; + try { + msg = JSON.parse(e.data); + } catch { + return; + } + if (!msg.to) return; + const target = sockets.get(msg.to); + if (target?.readyState === WebSocket.OPEN) { + target.send(JSON.stringify({ ...msg, from: username })); + } + }; + return response; + } + + // GET /avatars/.png — serve the bundled avatar images + if (req.method === 'GET' && url.pathname.startsWith('/avatars/')) { + const name = url.pathname.slice('/avatars/'.length); + if (!/^[a-z0-9_-]+\.png$/.test(name)) { + return new Response('Not found', { status: 404 }); + } + try { + const file = await Deno.readFile(`./avatars/${name}`); + return new Response(file, { + headers: { + 'content-type': 'image/png', + 'cache-control': 'public, max-age=86400', + }, + }); + } catch { + return new Response('Not found', { status: 404 }); + } + } + + return new Response('Not found', { status: 404 }); }); diff --git a/packages/mobile-client/README.md b/packages/mobile-client/README.md index 92624e798..f5ac21fd3 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: +`VoipIncomingCallTimeout`, `VoipOutgoingCallTimeout`, and +`VoipFulfillAnswerTimeout`. + ## 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..3964b74a4 100644 --- a/packages/mobile-client/plugin/src/types.ts +++ b/packages/mobile-client/plugin/src/types.ts @@ -16,5 +16,12 @@ export type FishjamPluginOptions = iphoneDeploymentTarget?: string; enableVoIPBackgroundMode?: boolean; }; + voip?: { + incomingCallTimeout?: number; + outgoingCallTimeout?: number; + fulfillAnswerCallTimeout?: number; + enableCallIntents?: boolean; + notificationIcon?: string; + }; } | 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..e7cd0bf71 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'; @@ -307,6 +313,90 @@ const withFishjamVoIPBackgroundMode: ConfigPlugin = (confi return configuration; }); +const withFishjamVoipTimeouts: ConfigPlugin = (config, props) => + withInfoPlist(config, (configuration) => { + const timeouts = [ + ['VoipIncomingCallTimeout', 'incomingCallTimeout'], + ['VoipOutgoingCallTimeout', 'outgoingCallTimeout'], + ['VoipFulfillAnswerTimeout', '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 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[]) + : [], + ); + // 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; + }); + + 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) { @@ -330,6 +420,8 @@ 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/plugin/src/withFishjamVoipAndroid.ts b/packages/mobile-client/plugin/src/withFishjamVoipAndroid.ts new file mode 100644 index 000000000..49db68587 --- /dev/null +++ b/packages/mobile-client/plugin/src/withFishjamVoipAndroid.ts @@ -0,0 +1,182 @@ +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. + * 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. + * - 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 = { + $: { + '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, + }, +}; + +// 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' } }], + }, + ], +}; + +const INSTALLATION_ID_META = { + $: { + 'android:name': 'firebase_messaging_installation_id_enabled', + 'android:value': 'true', + }, +}; + +const VOIP_TIMEOUTS = [ + ['VoipIncomingCallTimeout', 'incomingCallTimeout'], + ['VoipOutgoingCallTimeout', 'outgoingCallTimeout'], + ['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.`); + } +} + +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); + } + + 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); + } + + const metadataEntries = mainApplication['meta-data'] || []; + mainApplication['meta-data'] = metadataEntries; + const metaName = INSTALLATION_ID_META.$['android:name']; + const existingMetaIndex = metadataEntries.findIndex((meta) => meta.$['android:name'] === metaName); + if (existingMetaIndex !== -1) { + metadataEntries[existingMetaIndex] = INSTALLATION_ID_META; + } else { + 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); + } + }); + + // 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/mobile-client/src/index.ts b/packages/mobile-client/src/index.ts index 9fc68bc58..8ee922aca 100644 --- a/packages/mobile-client/src/index.ts +++ b/packages/mobile-client/src/index.ts @@ -26,11 +26,19 @@ export { useVoIPEvents, useTelecom, useTelecomEvent, + fulfillIncomingCallConnected, + failIncomingCallConnected, + getPendingAnswerRequestId, + reportOutgoingCallConnected, + setCallHeld, + setCallMuted, + isCallHeld, } 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, TelecomConfig, TelecomEvent, TelecomEventType, diff --git a/packages/react-native-webrtc b/packages/react-native-webrtc index 5576ef4cc..9a7eab99a 160000 --- a/packages/react-native-webrtc +++ b/packages/react-native-webrtc @@ -1 +1 @@ -Subproject commit 5576ef4cc8e8782f70f5a5aaa533ae1a1668d549 +Subproject commit 9a7eab99ab0fb5b65b29f1fb7ba06993d4e42ee5 diff --git a/yarn.lock b/yarn.lock index c0c0e1683..3cff70475 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,10 +18945,10 @@ __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" + 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" @@ -19666,13 +19016,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 +19044,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"