Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/mobile-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ npm install @fishjam-cloud/react-native-client
yarn add @fishjam-cloud/react-native-client
```

## Browser globals (breaking change)

The SDK no longer installs the browser-compatibility globals it used to register at import time. Only three WebRTC engine classes remain global: `RTCPeerConnection`, `RTCIceCandidate`, and `MediaStream`.

Removed: `navigator.mediaDevices` (with `getUserMedia`/`getDisplayMedia`/`enumerateDevices`), `localStorage`, `EventTarget`, `MediaStreamTrack`, `MediaStreamTrackEvent`, `RTCSessionDescription`, `RTCCertificate`, `RTCErrorEvent`, `RTCRtpSender`, `RTCRtpReceiver`, `RTCRtpTransceiver`.

If your app used any of these globals directly, use the SDK hooks instead, or import what you need from `@fishjam-cloud/react-native-webrtc` (e.g. `import { mediaDevices } from '@fishjam-cloud/react-native-webrtc'`).

## 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.
Expand Down
44 changes: 44 additions & 0 deletions packages/mobile-client/src/FishjamProvider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { FishjamProvider as ReactClientFishjamProvider } from '@fishjam-cloud/react-client';
import { describe, expect, it, vi } from 'vitest';

import { ReactNativeDeviceManager } from './devices/ReactNativeDeviceManager';
import { FishjamProvider } from './FishjamProvider';

const nativeMediaDevices = vi.hoisted(() => ({
enumerateDevices: vi.fn(),
getDisplayMedia: vi.fn(),
getUserMedia: vi.fn(),
}));

vi.mock('@fishjam-cloud/react-native-webrtc', () => ({
mediaDevices: nativeMediaDevices,
permissions: { query: vi.fn() },
MediaStream: class {},
}));

type RenderedProviderProps = {
clientType?: string;
deviceManager?: unknown;
fishjamId?: string;
};

const renderedProps = (element: ReturnType<typeof FishjamProvider>): RenderedProviderProps =>
element.props as RenderedProviderProps;

describe('FishjamProvider (mobile)', () => {
it('always runs on the native device manager with the mobile client type', () => {
const element = FishjamProvider({ fishjamId: 'test-fishjam-id' });

expect(element.type).toBe(ReactClientFishjamProvider);
expect(renderedProps(element).clientType).toBe('mobile');
expect(renderedProps(element).deviceManager).toBeInstanceOf(ReactNativeDeviceManager);
expect(renderedProps(element).fishjamId).toBe('test-fishjam-id');
});

it('reuses one device manager across provider instances', () => {
const first = FishjamProvider({ fishjamId: 'a' });
const second = FishjamProvider({ fishjamId: 'b' });

expect(renderedProps(first).deviceManager).toBe(renderedProps(second).deviceManager);
});
});
24 changes: 24 additions & 0 deletions packages/mobile-client/src/FishjamProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import {
FishjamProvider as ReactClientFishjamProvider,
type FishjamProviderProps as ReactClientFishjamProviderProps,
} from '@fishjam-cloud/react-client';
import React from 'react';

import { ReactNativeDeviceManager } from './devices/ReactNativeDeviceManager';

// The native device manager owns persistence (in-memory for the app session),
// so persistLastDevice does not apply on mobile.
const deviceManager = new ReactNativeDeviceManager();

export type FishjamProviderProps = Omit<
ReactClientFishjamProviderProps,
'persistLastDevice' | 'fishjamClient' | 'deviceManager' | 'clientType'
>;

export function FishjamProvider(props: FishjamProviderProps) {
return React.createElement(ReactClientFishjamProvider, {
...props,
clientType: 'mobile',
deviceManager,
});
}
102 changes: 85 additions & 17 deletions packages/mobile-client/src/devices/ReactNativeDeviceManager.test.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,46 @@
import type { MediaStream as ReactNativeMediaStream } from '@fishjam-cloud/react-native-webrtc';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { InMemoryDevicePersistence } from './InMemoryDevicePersistence';
import { ReactNativeDeviceManager } from './ReactNativeDeviceManager';

const nativeMediaDevices = vi.hoisted(() => ({
addEventListener: vi.fn(),
enumerateDevices: vi.fn(),
getDisplayMedia: vi.fn(),
getUserMedia: vi.fn(),
removeEventListener: vi.fn(),
}));

vi.mock('@fishjam-cloud/react-native-webrtc', () => ({ mediaDevices: nativeMediaDevices }));
const nativePermissions = vi.hoisted(() => ({
query: vi.fn(),
}));

const FakeNativeMediaStream = vi.hoisted(
() =>
class {
constructor(public readonly tracks: unknown[]) {}
getTracks() {
return this.tracks;
}
},
);

vi.mock('@fishjam-cloud/react-native-webrtc', () => ({
mediaDevices: nativeMediaDevices,
permissions: nativePermissions,
MediaStream: FakeNativeMediaStream,
}));

beforeEach(() => {
nativePermissions.query.mockResolvedValue('granted');
});

afterEach(() => {
vi.clearAllMocks();
});

// react-native-webrtc's MediaStreamError shape: a plain object, NOT an Error.
const nativeError = (name: string) => ({ name, message: name });

describe('ReactNativeDeviceManager', () => {
it('has inert construction', () => {
const persistence = {
Expand All @@ -30,7 +53,6 @@ describe('ReactNativeDeviceManager', () => {
expect(nativeMediaDevices.enumerateDevices).not.toHaveBeenCalled();
expect(nativeMediaDevices.getUserMedia).not.toHaveBeenCalled();
expect(nativeMediaDevices.getDisplayMedia).not.toHaveBeenCalled();
expect(nativeMediaDevices.addEventListener).not.toHaveBeenCalled();
});

it('shares session-scoped persistence between manager instances by default', () => {
Expand Down Expand Up @@ -66,29 +88,75 @@ describe('ReactNativeDeviceManager', () => {
expect(nativeMediaDevices.getUserMedia).toHaveBeenCalledWith(userConstraints);
});

it('uses native display-media defaults instead of forwarding incompatible browser options', async () => {
it('classifies the native SecurityError (a non-Error object) as permission denial', async () => {
nativeMediaDevices.getUserMedia.mockRejectedValue(nativeError('SecurityError'));
const manager = new ReactNativeDeviceManager();

await expect(manager.getUserMedia({ video: true })).rejects.toMatchObject({ name: 'NotAllowedError' });
});

it('classifies other native rejections through the shared name mapping', async () => {
nativeMediaDevices.getUserMedia.mockRejectedValue(nativeError('OverconstrainedError'));
const manager = new ReactNativeDeviceManager();

await expect(manager.getUserMedia({ video: true })).rejects.toMatchObject({ name: 'OverconstrainedError' });

nativeMediaDevices.getUserMedia.mockRejectedValue('total garbage');
await expect(manager.getUserMedia({ video: true })).rejects.toMatchObject({ name: 'UNHANDLED_ERROR' });
});

it('warns when acquiring media without granted permissions, but still acquires', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
nativePermissions.query.mockResolvedValue('denied');
nativeMediaDevices.getUserMedia.mockResolvedValue({} as ReactNativeMediaStream);
const manager = new ReactNativeDeviceManager();

await manager.getUserMedia({ video: true, audio: true });

expect(warn).toHaveBeenCalledWith('Attempting to access camera with permission status: "denied".');
expect(warn).toHaveBeenCalledWith('Attempting to access microphone with permission status: "denied".');
expect(nativeMediaDevices.getUserMedia).toHaveBeenCalled();
warn.mockRestore();
});

it('queries permissions only for the requested kinds', async () => {
nativeMediaDevices.getUserMedia.mockResolvedValue({} as ReactNativeMediaStream);
const manager = new ReactNativeDeviceManager();

await manager.getUserMedia({ audio: true });

expect(nativePermissions.query).toHaveBeenCalledTimes(1);
expect(nativePermissions.query).toHaveBeenCalledWith({ name: 'microphone' });
});

it('forwards constructor display-media options to the native call', async () => {
const displayStream = {} as ReactNativeMediaStream;
nativeMediaDevices.getDisplayMedia.mockResolvedValue(displayStream);
const manager = new ReactNativeDeviceManager();
const displayMediaOptions = { android: { resolutionScale: 0.5 } };
const manager = new ReactNativeDeviceManager({ displayMediaOptions });

await expect(manager.getDisplayMedia({ video: true })).resolves.toBe(displayStream);

expect(nativeMediaDevices.getDisplayMedia).toHaveBeenCalledWith();
expect(nativeMediaDevices.getDisplayMedia).toHaveBeenCalledWith(displayMediaOptions);
});

it('wraps tracks in a native stream', () => {
const manager = new ReactNativeDeviceManager();
const track = { id: 'track-1' };

const stream = manager.createMediaStream([track as never]);

expect(stream).toBeInstanceOf(FakeNativeMediaStream);
expect(stream.getTracks()).toEqual([track]);
});

it('removes a device-change listener exactly once', () => {
const callback = vi.fn();
it('returns an inert cleanup from onDeviceChange without touching the native module', () => {
const manager = new ReactNativeDeviceManager();

const cleanup = manager.onDeviceChange(callback);
const listener = nativeMediaDevices.addEventListener.mock.calls[0][1] as () => void;
listener();
const cleanup = manager.onDeviceChange(vi.fn());
cleanup();
cleanup();

expect(callback).toHaveBeenCalledOnce();
expect(nativeMediaDevices.addEventListener).toHaveBeenCalledWith('devicechange', listener);
expect(nativeMediaDevices.removeEventListener).toHaveBeenCalledOnce();
expect(nativeMediaDevices.removeEventListener).toHaveBeenCalledWith('devicechange', listener);
expect(nativeMediaDevices.getUserMedia).not.toHaveBeenCalled();
});
});
102 changes: 70 additions & 32 deletions packages/mobile-client/src/devices/ReactNativeDeviceManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,32 @@ import {
mediaDevices,
MediaStream as ReactNativeMediaStream,
type MediaStreamTrack as ReactNativeMediaStreamTrack,
permissions,
} from '@fishjam-cloud/react-native-webrtc';
import type {
DeviceItem,
DeviceType,
IDeviceManager,
IDevicePersistence,
PlatformMediaStreamTrack,
import {
classifyDeviceError,
type DeviceError,
type DeviceItem,
DevicePermissionDeniedError,
type DeviceType,
type IDeviceManager,
type IDevicePersistence,
type PlatformMediaStreamTrack,
} from '@fishjam-cloud/tsunami';

import { InMemoryDevicePersistence } from './InMemoryDevicePersistence';

export type ReactNativeDeviceManagerOptions = {
persistence?: IDevicePersistence;
export type ReactNativeDisplayMediaOptions = {
android?: {
createConfigForDefaultDisplay?: boolean;
resolutionScale?: number;
};
};

type NativeMediaDevices = typeof mediaDevices & {
addEventListener(type: 'devicechange', listener: () => void): void;
removeEventListener(type: 'devicechange', listener: () => void): void;
export type ReactNativeDeviceManagerOptions = {
persistence?: IDevicePersistence;
/** Forwarded to react-native-webrtc's getDisplayMedia (Android screen-capture tuning). */
displayMediaOptions?: ReactNativeDisplayMediaOptions;
};

type NativeDeviceInfo = {
Expand All @@ -35,20 +43,46 @@ const inputDeviceKinds: Partial<Record<string, DeviceType>> = {

const defaultPersistence = new InMemoryDevicePersistence();

// TODO: FCE-3689 Fix react-native-webrtc's bundled EventTarget declarations and remove this workaround.
// The runtime object extends EventTarget, but react-native-webrtc's bundled
// declaration does not currently expose the inherited listener methods.
const nativeMediaDevices = mediaDevices as NativeMediaDevices;
// react-native-webrtc rejects with MediaStreamError, which does not extend
// Error and reports permission denial as "SecurityError" — classify by the
// name field before deferring to the shared web-name mapping.
const classifyNativeDeviceError = (error: unknown): DeviceError => {
const name = typeof error === 'object' && error !== null && 'name' in error ? String(error.name) : '';
if (name === 'SecurityError' || name === 'NotAllowedError') {
return new DevicePermissionDeniedError({ cause: error });
}
return classifyDeviceError(error instanceof Error ? error : Object.assign(new Error(name), { name }));
};

const warnWhenPermissionMissing = async (constraints: MediaStreamConstraints): Promise<void> => {
try {
const [cameraStatus, microphoneStatus] = await Promise.all([
constraints.video ? permissions.query({ name: 'camera' }) : null,
constraints.audio ? permissions.query({ name: 'microphone' }) : null,
]);

if (cameraStatus && cameraStatus !== 'granted') {
console.warn(`Attempting to access camera with permission status: "${cameraStatus}".`);
}
if (microphoneStatus && microphoneStatus !== 'granted') {
console.warn(`Attempting to access microphone with permission status: "${microphoneStatus}".`);
}
} catch (error) {
console.warn('Failed to check permissions before getUserMedia', error);
}
};

export class ReactNativeDeviceManager implements IDeviceManager<ReactNativeMediaStream> {
public readonly persistence: IDevicePersistence;
private readonly displayMediaOptions?: ReactNativeDisplayMediaOptions;

public constructor({ persistence = defaultPersistence }: ReactNativeDeviceManagerOptions = {}) {
public constructor({ persistence = defaultPersistence, displayMediaOptions }: ReactNativeDeviceManagerOptions = {}) {
this.persistence = persistence;
this.displayMediaOptions = displayMediaOptions;
}

public async enumerateDevices(): Promise<DeviceItem[]> {
const devices = (await nativeMediaDevices.enumerateDevices()) as NativeDeviceInfo[];
const devices = (await mediaDevices.enumerateDevices()) as NativeDeviceInfo[];

return devices.flatMap((device) => {
const kind = inputDeviceKinds[device.kind];
Expand All @@ -59,29 +93,33 @@ export class ReactNativeDeviceManager implements IDeviceManager<ReactNativeMedia
}

public async getUserMedia(constraints: MediaStreamConstraints): Promise<ReactNativeMediaStream> {
const nativeConstraints = constraints as Parameters<typeof nativeMediaDevices.getUserMedia>[0];
return nativeMediaDevices.getUserMedia(nativeConstraints);
await warnWhenPermissionMissing(constraints);

const nativeConstraints = constraints as Parameters<typeof mediaDevices.getUserMedia>[0];
try {
return await mediaDevices.getUserMedia(nativeConstraints);
} catch (error) {
throw classifyNativeDeviceError(error);
}
}

// The DOM options shape is disjoint from react-native-webrtc's constraint
// shape, so it is ignored; screen-capture tuning comes from the constructor.
public async getDisplayMedia(_options?: DisplayMediaStreamOptions): Promise<ReactNativeMediaStream> {
return nativeMediaDevices.getDisplayMedia();
try {
return await mediaDevices.getDisplayMedia(this.displayMediaOptions);
} catch (error) {
throw classifyNativeDeviceError(error);
}
}

public createMediaStream(tracks: PlatformMediaStreamTrack[]): ReactNativeMediaStream {
return new ReactNativeMediaStream(tracks as ReactNativeMediaStreamTrack[]);
}

// react-native-webrtc does not currently emit devicechange; this method exists to satisfy IDeviceManager.
public onDeviceChange(callback: () => void): () => void {
const listener = () => callback();
let subscribed = true;

nativeMediaDevices.addEventListener('devicechange', listener);

return () => {
if (!subscribed) return;
subscribed = false;
nativeMediaDevices.removeEventListener('devicechange', listener);
};
// react-native-webrtc never emits devicechange, so there is nothing to
// subscribe to; device lists refresh on explicit operations instead.
public onDeviceChange(_callback: () => void): () => void {
return () => {};
}
}
Loading