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"