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
5 changes: 5 additions & 0 deletions .changeset/fix-sctp-snap-configuration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@livekit/react-native': patch
---

Preserve the initial SCTP SNAP option when updating peer connection ICE servers.
136 changes: 136 additions & 0 deletions src/__tests__/registerGlobals.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import {
beforeAll,
beforeEach,
describe,
expect,
it,
jest,
} from '@jest/globals';
import type { RTCPeerConnection } from '@livekit/react-native-webrtc';
import { NativeModules } from 'react-native';
import { registerGlobals } from '../index';

type Configuration = ConstructorParameters<typeof RTCPeerConnection>[0];

jest.mock('react-native-url-polyfill', () => ({ setupURLPolyfill: jest.fn() }));

jest.mock('../LKNativeModule', () => ({
__esModule: true,
default: { addListener: jest.fn(), removeListeners: jest.fn() },
}));

jest.mock('@livekit/react-native-webrtc', () => {
const { NativeModules: nativeModules } =
jest.requireActual<typeof import('react-native')>('react-native');

nativeModules.WebRTCModule = {
peerConnectionInit: jest.fn(() => true),
peerConnectionSetConfiguration: jest.fn(),
peerConnectionClose: jest.fn(),
audioDeviceModuleSetEngineCreatedActive: jest.fn(),
audioDeviceModuleSetWillEnableEngineActive: jest.fn(),
audioDeviceModuleSetWillStartEngineActive: jest.fn(),
audioDeviceModuleSetDidStopEngineActive: jest.fn(),
audioDeviceModuleSetDidDisableEngineActive: jest.fn(),
audioDeviceModuleSetWillReleaseEngineActive: jest.fn(),
addListener: jest.fn(),
removeListeners: jest.fn(),
};

return jest.requireActual('@livekit/react-native-webrtc');
});

const webRTCModule = NativeModules.WebRTCModule as {
peerConnectionInit: jest.Mock<
(configuration: Configuration, id: number) => boolean
>;
peerConnectionSetConfiguration: jest.Mock<
(configuration: Configuration, id: number) => void
>;
};
let PeerConnection: typeof RTCPeerConnection;

beforeAll(() => {
registerGlobals({ autoConfigureAudioSession: false });
PeerConnection = (
globalThis as unknown as { RTCPeerConnection: typeof RTCPeerConnection }
).RTCPeerConnection;
});

beforeEach(() => {
jest.clearAllMocks();
});

describe('registerGlobals SCTP SNAP shim', () => {
it.each([
[undefined, true],
[{ enableSctpSnap: true }, true],
[{ enableSctpSnap: false }, false],
])(
'preserves the initial option from %j during ICE updates',
(configuration, expected) => {
const pc = new PeerConnection(configuration);

try {
expect(
webRTCModule.peerConnectionInit.mock.calls[0]?.[0]?.enableSctpSnap
).toBe(expected);

for (const url of [
'stun:first.example.org',
'stun:second.example.org',
]) {
const update = Object.freeze({ iceServers: [{ urls: url }] });
pc.setConfiguration(update);

const nativeConfiguration =
webRTCModule.peerConnectionSetConfiguration.mock.calls.at(-1)?.[0];
expect(nativeConfiguration?.iceServers).toEqual(update.iceServers);
// Native RTCConfiguration defaults an omitted option to false.
expect(nativeConfiguration?.enableSctpSnap ?? false).toBe(expected);
}
} finally {
pc.close();
}
}
);

it.each([
[true, false],
[false, true],
])(
'forwards an explicit change from %s to %s for native validation',
(initial, updated) => {
const pc = new PeerConnection({ enableSctpSnap: initial });

try {
pc.setConfiguration({ enableSctpSnap: updated });
expect(
webRTCModule.peerConnectionSetConfiguration.mock.calls[0]?.[0]
?.enableSctpSnap
).toBe(updated);
} finally {
pc.close();
}
}
);

it('retains independent values for separate connections', () => {
const enabled = new PeerConnection();
const disabled = new PeerConnection({ enableSctpSnap: false });

try {
enabled.setConfiguration({ iceServers: [] });
disabled.setConfiguration({ iceServers: [] });

const [enabledCall, disabledCall] =
webRTCModule.peerConnectionSetConfiguration.mock.calls;
expect(enabledCall?.[0]?.enableSctpSnap ?? false).toBe(true);
expect(disabledCall?.[0]?.enableSctpSnap ?? false).toBe(false);
expect(enabledCall?.[1]).not.toBe(disabledCall?.[1]);
} finally {
enabled.close();
disabled.close();
}
});
});
20 changes: 17 additions & 3 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,27 @@ export function registerGlobals(options?: RegisterGlobalsOptions) {
function shimSctpSnap() {
// The SCTP half of WARP. livekit-client owns the RTCConfiguration it hands to
// `new RTCPeerConnection`, so there's no way to reach it from here. Default the
// flag on in the global constructor instead; an explicit `enableSctpSnap` in
// the app's rtcConfig still wins.
// flag on in the global constructor and retain it through ICE updates. An
// explicit `enableSctpSnap` in the app's rtcConfig still wins.
class SctpSnapPeerConnection extends RTCPeerConnection {
private readonly initialSctpSnap: boolean;

constructor(
configuration?: ConstructorParameters<typeof RTCPeerConnection>[0]
) {
super({ enableSctpSnap: true, ...configuration });
const rtcConfig = { enableSctpSnap: true, ...configuration };
super(rtcConfig);
this.initialSctpSnap = rtcConfig.enableSctpSnap ?? false;
}

setConfiguration(
configuration: ConstructorParameters<typeof RTCPeerConnection>[0]
) {
// Preserve the immutable value when livekit-client updates ICE servers.
super.setConfiguration({
enableSctpSnap: this.initialSctpSnap,
...configuration,
});
}
}

Expand Down
Loading