diff --git a/packages/client/index.ts b/packages/client/index.ts index 5e71ea5c23..4a0c38e34a 100644 --- a/packages/client/index.ts +++ b/packages/client/index.ts @@ -26,6 +26,7 @@ export * from './src/helpers/sound-detector'; export * from './src/helpers/loopback'; export * from './src/helpers/MediaStreamRecorder'; export * from './src/helpers/participantUtils'; +export * from './src/helpers/TypedEventEmitter'; export * from './src/rtc/e2ee/E2EEManager'; export * from './src/rtc/e2ee/EncryptionManager'; export * as Browsers from './src/helpers/browsers'; diff --git a/packages/client/src/Call.ts b/packages/client/src/Call.ts index 3ecd25eccb..6a049d4709 100644 --- a/packages/client/src/Call.ts +++ b/packages/client/src/Call.ts @@ -790,6 +790,11 @@ export class Call { await this.dynascaleManager?.dispose(); this.state.setCallingState(CallingState.LEFT); + // `ringingSubject` is cleared further down, so this still reads true for + // a call that was ringing. + if (this.ringing) { + globalThis.streamRNVideoSDK?.ringingCallLifecycle?.onLeave(this); + } this.state.setParticipants([]); this.state.dispose(); @@ -1133,6 +1138,10 @@ export class Call { /** * Will start to watch for call related WebSocket events and initiate a call session with the server. * + * One instance is one call flow: discard it after leaving, cancelling, or a + * failed join, and create a fresh one for a later flow. Reconnection inside a + * live call is handled here and needs no new instance. + * * @returns a promise which resolves once the call join-flow has finished. */ join = singleFlight( @@ -1152,6 +1161,8 @@ export class Call { } = {}): Promise => { const callingState = this.state.callingState; + // Ahead of the failure boundary below on purpose: a duplicate join on a + // live call is refused without tearing that call down. if ([CallingState.JOINED, CallingState.JOINING].includes(callingState)) { throw new Error(`Illegal State: call.join() shall be called only once`); } @@ -1171,43 +1182,63 @@ export class Call { this.ringingSubject.next(true); } + // A ringing call is joined by the SDK rather than by app code, so React + // Native prepares it from in here - there is no earlier point at which the + // app holds the call. Read after `data.ring` above, which is what makes an + // outgoing call ringing in the first place. + const ringingLifecycle = this.ringing + ? globalThis.streamRNVideoSDK?.ringingCallLifecycle + : undefined; const callingX = globalThis.streamRNVideoSDK?.callingX; - if (callingX) { - // for Android/iOS, we need to start the call in the callingx library as soon as possible - await callingX.joinCall(this, this.clientState.calls); + try { + if (ringingLifecycle) { + await ringingLifecycle.beforeJoin(this); + if (supersededByLeave()) { + this.logger.debug('Join superseded by leave; not joining'); + return; + } + } + + if (callingX) { + // for Android/iOS, we need to start the call in the callingx library as soon as possible + await callingX.joinCall( + this, + this.clientState.calls, + supersededByLeave, + ); + if (supersededByLeave()) { + this.logger.debug('Join superseded by leave; not setting up'); + return; + } + } + + await this.setup(); if (supersededByLeave()) { - this.logger.debug('Join superseded by leave; not setting up'); + this.logger.debug('Join superseded by leave; not registering'); return; } - } - - await this.setup(); - if (supersededByLeave()) { - this.logger.debug('Join superseded by leave; not registering'); - return; - } - this.clientEventReporter.registerCall(this.cid, { - callType: this.type, - callId: this.id, - getCallSessionId: () => this.state.session?.id ?? '', - getSfuId: () => this.credentials?.server.edge_name ?? '', - }); + this.clientEventReporter.registerCall(this.cid, { + callType: this.type, + callId: this.id, + getCallSessionId: () => this.state.session?.id ?? '', + getSfuId: () => this.credentials?.server.edge_name ?? '', + }); - this.joinResponseTimeout = joinResponseTimeout; - this.rpcRequestTimeout = rpcRequestTimeout; - // we will count the number of join failures per SFU. - // once the number of failures reaches 2, we will piggyback on the `migrating_from` - // field to force the coordinator to provide us another SFU - const sfuJoinFailures = new Map(); - const joinData: JoinCallData = data; - maxJoinRetries = Math.max(maxJoinRetries, 1); - try { + this.joinResponseTimeout = joinResponseTimeout; + this.rpcRequestTimeout = rpcRequestTimeout; + // we will count the number of join failures per SFU. + // once the number of failures reaches 2, we will piggyback on the `migrating_from` + // field to force the coordinator to provide us another SFU + const sfuJoinFailures = new Map(); + const joinData: JoinCallData = data; + maxJoinRetries = Math.max(maxJoinRetries, 1); await this.clientEventReporter.withJoinLifecycle( this.cid, { joinReason: 'first-attempt', joinSource }, async () => { for (let attempt = 0; attempt < maxJoinRetries; attempt++) { + // Also covers a leave that lands during the backoff below. if (supersededByLeave()) { this.logger.debug('Join superseded by leave; not attempting'); return; @@ -1263,6 +1294,9 @@ export class Call { ); } catch (error) { callingX?.endCall(this, 'error'); + // Ends the failed ringing flow and releases what its setup installed. + // Never rejects, so `error` is what the caller sees. + await ringingLifecycle?.onJoinFailed(this); throw error; } }, @@ -2601,10 +2635,11 @@ export class Call { * Must be called before {@link join} so the RTCPeerConnection can be * configured for E2EE. * - * The manager is kept across {@link leave} so a rejoin of this same instance - * stays encrypted: do not dispose it while this call may be joined again. - * A disposed manager throws from `encrypt`/`decrypt` rather than silently - * publishing nothing, so re-attach a fresh one instead of reusing it. + * The application owns the manager's lifetime - the SDK never disposes it, and + * closing the peer connections does not release it. Discard the manager + * together with this call instance once its flow has ended, and create a fresh + * pair for a later flow: a disposed manager throws from `encrypt`/`decrypt` + * rather than silently publishing nothing. * * @param e2ee - Any `E2EEManager`. Use `EncryptionManager.create()` for the * built-in AES-GCM scheme, or pass your own implementation. diff --git a/packages/client/src/__tests__/Call.ringingLifecycle.test.ts b/packages/client/src/__tests__/Call.ringingLifecycle.test.ts new file mode 100644 index 0000000000..ae31798813 --- /dev/null +++ b/packages/client/src/__tests__/Call.ringingLifecycle.test.ts @@ -0,0 +1,242 @@ +/** + * @vitest-environment happy-dom + */ + +import '../rtc/__tests__/mocks/webrtc.mocks'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Call } from '../Call'; +import { CallingState } from '../store'; +import { StreamClient } from '../coordinator/connection/client'; +import { ClientEventReporter } from '../reporting'; +import { generateUUIDv4 } from '../coordinator/connection/utils'; +import { ClientState } from '../store'; + +/** + * The core's whole part in the React Native ringing lifecycle: await RN's + * preparation inside the join, tell RN when a ringing join failed terminally or + * the call ended, and stop a join that a `leave()` overtook. What preparation + * and release actually do is RN's, and is tested there. + */ + +const createCall = (ringing: boolean) => { + const streamClient = new StreamClient('abc'); + return new Call({ + type: 'test', + id: generateUUIDv4(), + ringing, + streamClient, + clientEventReporter: new ClientEventReporter({ + streamClient, + enabled: false, + }), + clientState: new ClientState(), + }); +}; + +const install = (overrides: Record = {}) => { + const beforeJoin = vi.fn(() => Promise.resolve()); + const onJoinFailed = vi.fn(() => Promise.resolve()); + const onLeave = vi.fn(); + const callingX = { joinCall: vi.fn(), endCall: vi.fn() }; + globalThis.streamRNVideoSDK = { + ringingCallLifecycle: { beforeJoin, onJoinFailed, onLeave }, + callingX, + // `leave()` reaches this unconditionally; the globals object is all-or-nothing + callManager: { setup: vi.fn(), start: vi.fn(), stop: vi.fn() }, + ...overrides, + } as any; + return { beforeJoin, onJoinFailed, onLeave, callingX }; +}; + +describe('ringing call lifecycle integration', () => { + beforeEach(() => { + globalThis.streamRNVideoSDK = undefined; + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + headers: { get: () => 'AMS1-P2' }, + } as Response); + }); + + afterEach(() => { + globalThis.streamRNVideoSDK = undefined; + vi.restoreAllMocks(); + }); + + it('prepares a ringing call before it joins', async () => { + const { beforeJoin, callingX } = install(); + const call = createCall(true); + const doJoin = vi.spyOn(call as any, 'doJoin').mockResolvedValue(undefined); + + await call.join(); + + expect(beforeJoin).toHaveBeenCalledTimes(1); + expect(beforeJoin).toHaveBeenCalledBefore(callingX.joinCall); + expect(doJoin).toHaveBeenCalledTimes(1); + }); + + it('leaves ordinary calls to join themselves', async () => { + const { beforeJoin, onJoinFailed } = install(); + const call = createCall(false); + vi.spyOn(call as any, 'doJoin').mockResolvedValue(undefined); + + await call.join(); + + expect(beforeJoin).not.toHaveBeenCalled(); + expect(onJoinFailed).not.toHaveBeenCalled(); + }); + + it('does not join when preparation fails, and reports the failure', async () => { + const { beforeJoin, onJoinFailed } = install(); + beforeJoin.mockRejectedValue(new Error('no key')); + const call = createCall(true); + const doJoin = vi.spyOn(call as any, 'doJoin').mockResolvedValue(undefined); + + await expect(call.join()).rejects.toThrow('no key'); + + expect(doJoin).not.toHaveBeenCalled(); + expect(onJoinFailed).toHaveBeenCalledWith(call); + }); + + it('reports a terminal join failure, keeping the original error', async () => { + const { onJoinFailed } = install(); + const call = createCall(true); + vi.spyOn(call as any, 'doJoin').mockRejectedValue(new Error('sfu down')); + + await expect(call.join({ maxJoinRetries: 1 })).rejects.toThrow('sfu down'); + + expect(onJoinFailed).toHaveBeenCalledWith(call); + }); + + it('refuses a duplicate join on a live call without tearing it down', async () => { + const { beforeJoin, onJoinFailed, onLeave } = install(); + const call = createCall(true); + vi.spyOn(call as any, 'doJoin').mockImplementation(async () => { + call.state.setCallingState(CallingState.JOINED); + }); + + await call.join(); + await expect(call.join()).rejects.toThrow('Illegal State'); + + expect(beforeJoin).toHaveBeenCalledTimes(1); + // the guard sits ahead of the failure boundary, so nothing is released + expect(onJoinFailed).not.toHaveBeenCalled(); + expect(onLeave).not.toHaveBeenCalled(); + }); + + it('tells the owner when a ringing call ends', async () => { + const { onLeave } = install(); + const call = createCall(true); + + await call.leave(); + + expect(onLeave).toHaveBeenCalledWith(call); + expect(call.state.callingState).toBe(CallingState.LEFT); + }); + + it('does not tell the owner about an ordinary call', async () => { + const { onLeave } = install(); + + await createCall(false).leave(); + + expect(onLeave).not.toHaveBeenCalled(); + }); + + it('keeps an app-owned manager across leave, as setE2EEManager promises', async () => { + install(); + const call = createCall(true); + const manager = { encrypt: vi.fn(), decrypt: vi.fn() } as any; + call.setE2EEManager(manager); + + await call.leave(); + + expect(call.e2eeManager).toBe(manager); + }); + + it('abandons a join that a leave overtook during native registration', async () => { + let finishRegistration: () => void = () => {}; + const { callingX, onLeave, onJoinFailed } = install(); + callingX.joinCall.mockImplementation( + () => new Promise((resolve) => (finishRegistration = resolve)), + ); + const call = createCall(true); + const doJoin = vi.spyOn(call as any, 'doJoin').mockResolvedValue(undefined); + + const joining = call.join(); + await vi.waitFor(() => expect(callingX.joinCall).toHaveBeenCalled()); + await call.leave({ reject: false }); + finishRegistration(); + + // A superseding leave is not a join failure: the join settles quietly rather + // than rejecting, and the error path stays shut. + await expect(joining).resolves.toBeUndefined(); + expect(onJoinFailed).not.toHaveBeenCalled(); + expect(callingX.endCall).not.toHaveBeenCalledWith(call, 'error'); + // Release still happens - `leave()` itself fires it, which is why the join + // does not need to. + expect(onLeave).toHaveBeenCalledTimes(1); + // `doJoin` captures the generation itself, so this check has to be here + expect(doJoin).not.toHaveBeenCalled(); + }); + + it('does not resume the retry loop after a leave during backoff', async () => { + vi.useFakeTimers(); + try { + install(); + const call = createCall(true); + const doJoin = vi + .spyOn(call as any, 'doJoin') + .mockRejectedValue(new Error('recoverable')); + + const joining = call.join().catch(() => {}); + await vi.waitFor(() => expect(doJoin).toHaveBeenCalledTimes(1)); + await call.leave({ reject: false }); + await vi.advanceTimersByTimeAsync(30_000); + await joining; + + expect(doJoin).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + it('prepares an outgoing call that this join makes ringing', async () => { + const { beforeJoin } = install(); + // a fresh instance, not ringing until its first join says so + const call = createCall(false); + vi.spyOn(call as any, 'doJoin').mockResolvedValue(undefined); + + await call.join({ ring: true }); + + // reading `call.ringing` before applying `options.ring` would skip + // preparation entirely on this path + expect(beforeJoin).toHaveBeenCalledTimes(1); + }); + + it('stops a join that a leave overtook during preparation', async () => { + let releaseHook: () => void = () => {}; + const { beforeJoin, onLeave, onJoinFailed } = install(); + beforeJoin.mockImplementation( + () => new Promise((resolve) => (releaseHook = resolve)), + ); + const call = createCall(true); + const doJoin = vi.spyOn(call as any, 'doJoin').mockResolvedValue(undefined); + + const joining = call.join(); + await vi.waitFor(() => expect(beforeJoin).toHaveBeenCalled()); + await call.leave({ reject: false }); + releaseHook(); + + await expect(joining).resolves.toBeUndefined(); + expect(onJoinFailed).not.toHaveBeenCalled(); + // Whatever `beforeJoin` installed is released exactly once, by the `leave()` + // that overtook the join rather than by the join itself. + expect(onLeave).toHaveBeenCalledTimes(1); + expect(doJoin).not.toHaveBeenCalled(); + expect(call.state.callingState).toBe(CallingState.LEFT); + }); + + it('is inert when no React Native globals are registered', async () => { + const call = createCall(true); + await expect(call.leave()).resolves.not.toThrow(); + }); +}); diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts index a7584ab0b0..58066977bb 100644 --- a/packages/client/src/types.ts +++ b/packages/client/src/types.ts @@ -458,7 +458,17 @@ type StreamRNVideoSDKEndCallReason = | 'unknown'; type StreamRNVideoSDKCallingX = { - joinCall: (call: Call, activeCalls: Call[]) => Promise; + /** + * @param isCancelled - polled around the bridge's waits: registration is + * skipped, or undone, when it returns true. Supplied by the join attempt + * because the call's own state cannot distinguish a fresh join starting + * from `LEFT` from an abandoned one that reached `LEFT` while waiting. + */ + joinCall: ( + call: Call, + activeCalls: Call[], + isCancelled?: () => boolean, + ) => Promise; endCall: ( call: Call, reason?: StreamRNVideoSDKEndCallReason, @@ -468,8 +478,34 @@ type StreamRNVideoSDKCallingX = { unwireAudioEngineSubscription: () => void; }; +/** + * React Native's preparation and cleanup for a ringing call's join. + * + * A ringing call is joined by the SDK rather than by app code, so there is no + * point at which the app holds the call and can still set it up. These are that + * point, and the matching release. + */ +type StreamRNVideoSDKRingingCallLifecycle = { + /** + * Runs the app's pre-join setup and resolves once it is done, bounded by a + * deadline. Rejecting fails the join closed rather than joining with nothing + * installed. + */ + beforeJoin: (call: Call) => Promise; + + /** + * The join failed terminally. Ends the ringing flow and releases what the + * setup installed. Never rejects, so the original join error survives. + */ + onJoinFailed: (call: Call) => Promise; + + /** The call has ended; release whatever the pre-join hook installed. */ + onLeave: (call: Call) => void; +}; + export type StreamRNVideoSDKGlobals = { callingX: StreamRNVideoSDKCallingX; + ringingCallLifecycle: StreamRNVideoSDKRingingCallLifecycle; callManager: { /** * Sets up the in call manager. diff --git a/packages/react-native-sdk/__mocks__/@stream-io/react-native-webrtc.tsx b/packages/react-native-sdk/__mocks__/@stream-io/react-native-webrtc.tsx index f2719a8bfd..3aa42eff36 100644 --- a/packages/react-native-sdk/__mocks__/@stream-io/react-native-webrtc.tsx +++ b/packages/react-native-sdk/__mocks__/@stream-io/react-native-webrtc.tsx @@ -17,3 +17,71 @@ export const RTCView = (props: MockProps) => ( ); export const registerGlobals = () => {}; + +export enum RTCEncryptionAlgorithm { + AES_128_GCM = 0, + AES_256_GCM = 1, +} + +export enum RTCEncryptionTrackType { + AUDIO = 0, + VIDEO = 1, + SCREEN_SHARE = 2, + SCREEN_SHARE_AUDIO = 3, +} + +type Listener = (data: any) => void; + +/** + * Stand-in for the native manager. Records calls, and lets a test push an event + * through the same listener registry the real bridge uses. + */ +export class RTCEncryptionManager { + static supported = true; + static instances: RTCEncryptionManager[] = []; + + static isSupported = jest.fn(() => RTCEncryptionManager.supported); + static create = jest.fn( + (userId: string, options?: { algorithm?: number }) => + new RTCEncryptionManager(userId, options), + ); + + userId: string; + options?: { algorithm?: number }; + listeners = new Map>(); + + setKey = jest.fn(); + setSharedKey = jest.fn(); + removeKey = jest.fn(); + removeAllKeys = jest.fn(); + removeSharedKey = jest.fn(); + encrypt = jest.fn(); + decrypt = jest.fn(); + enablePerformanceReporting = jest.fn(() => Promise.resolve()); + requestKeyState = jest.fn(() => Promise.resolve()); + dispose = jest.fn(); + + constructor(userId: string, options?: { algorithm?: number }) { + this.userId = userId; + this.options = options; + RTCEncryptionManager.instances.push(this); + } + + on = jest.fn((type: string, listener: Listener) => { + let listeners = this.listeners.get(type); + if (!listeners) { + listeners = new Set(); + this.listeners.set(type, listeners); + } + listeners.add(listener); + }); + + off = jest.fn((type: string, listener: Listener) => { + this.listeners.get(type)?.delete(listener); + }); + + /** Test hook: emit a native event payload to every listener of its type. */ + emitNative(data: { type: string; [key: string]: unknown }) { + this.listeners.get(data.type)?.forEach((listener) => listener(data)); + } +} diff --git a/packages/react-native-sdk/__tests__/StreamVideoRN.test.ts b/packages/react-native-sdk/__tests__/StreamVideoRN.test.ts new file mode 100644 index 0000000000..d852e18527 --- /dev/null +++ b/packages/react-native-sdk/__tests__/StreamVideoRN.test.ts @@ -0,0 +1,110 @@ +/** + * Tests for the app-level (process-wide) opt-out of the Android communication-mode keep-alive. + * It is set once at app start and lands on the native module directly; the native field is + * sticky for the process, so there is no join-time re-application. + */ + +const makeInCallManager = () => ({ + setDisableCommunicationModeWorkaround: jest.fn(), +}); + +/** Load StreamVideoRN with the given platform + mocked native module. */ +const loadStreamVideoRN = ({ + os, + inCallManager, +}: { + os: 'android' | 'ios'; + inCallManager: ReturnType | undefined; +}) => { + let StreamVideoRN!: typeof import('../src/utils/StreamVideoRN').StreamVideoRN; + jest.isolateModules(() => { + jest.doMock('react-native', () => ({ + Platform: { OS: os, select: (o: any) => o[os] }, + NativeModules: { + StreamInCallManager: inCallManager, + StreamVideoReactNative: {}, + }, + NativeEventEmitter: class { + addListener() { + return { remove: jest.fn() }; + } + }, + })); + // keep the push/callingx runtime out of this test + jest.doMock('../src/utils/push/setupIosVoipPushEvents', () => ({ + setupIosVoipPushEvents: jest.fn(), + })); + jest.doMock('../src/utils/push/setupAndroidPushEvents', () => ({ + setupAndroidPushEvents: jest.fn(), + })); + jest.doMock('../src/utils/push/setupCallingExpEvents', () => ({ + setupCallingExpEvents: jest.fn(), + })); + jest.doMock('../src/utils/push/libs/callingx', () => ({ + extractCallingExpOptions: jest.fn(), + getCallingxLib: jest.fn(), + getCallingxLibIfAvailable: jest.fn(), + })); + StreamVideoRN = require('../src/utils/StreamVideoRN').StreamVideoRN; + }); + return StreamVideoRN; +}; + +describe('StreamVideoRN.setDisableCommunicationModeWorkaround', () => { + afterEach(() => jest.resetModules()); + + it('forwards true to the native module on Android', () => { + const inCallManager = makeInCallManager(); + const StreamVideoRN = loadStreamVideoRN({ os: 'android', inCallManager }); + + StreamVideoRN.setDisableCommunicationModeWorkaround(true); + + expect( + inCallManager.setDisableCommunicationModeWorkaround, + ).toHaveBeenCalledWith(true); + }); + + it('forwards false to the native module on Android', () => { + const inCallManager = makeInCallManager(); + const StreamVideoRN = loadStreamVideoRN({ os: 'android', inCallManager }); + + StreamVideoRN.setDisableCommunicationModeWorkaround(false); + + expect( + inCallManager.setDisableCommunicationModeWorkaround, + ).toHaveBeenCalledWith(false); + }); + + it('is a no-op on iOS', () => { + const inCallManager = makeInCallManager(); + const StreamVideoRN = loadStreamVideoRN({ os: 'ios', inCallManager }); + + StreamVideoRN.setDisableCommunicationModeWorkaround(true); + + expect( + inCallManager.setDisableCommunicationModeWorkaround, + ).not.toHaveBeenCalled(); + }); + + it('survives a native module that predates the method (version skew)', () => { + const inCallManager = makeInCallManager(); + delete (inCallManager as Partial) + .setDisableCommunicationModeWorkaround; + const StreamVideoRN = loadStreamVideoRN({ os: 'android', inCallManager }); + + expect(() => + StreamVideoRN.setDisableCommunicationModeWorkaround(true), + ).not.toThrow(); + }); + + it('survives the native module being absent entirely', () => { + const StreamVideoRN = loadStreamVideoRN({ + os: 'android', + inCallManager: undefined, + }); + + expect(() => + StreamVideoRN.setDisableCommunicationModeWorkaround(true), + ).not.toThrow(); + }); +}); diff --git a/packages/react-native-sdk/__tests__/call-manager/CallManager.test.ts b/packages/react-native-sdk/__tests__/call-manager/CallManager.test.ts index e4d23cc439..43eb250c55 100644 --- a/packages/react-native-sdk/__tests__/call-manager/CallManager.test.ts +++ b/packages/react-native-sdk/__tests__/call-manager/CallManager.test.ts @@ -82,6 +82,10 @@ const loadCallManager = ({ const { registerSDKGlobals, } = require('../../src/utils/internal/registerSDKGlobals'); + // registerSDKGlobals() is a no-op once globalThis.streamRNVideoSDK is set, and that + // global outlives jest.resetModules(). Clear it so each test binds the internal call + // manager to its own mocked native module instead of the first test's. + delete (globalThis as { streamRNVideoSDK?: unknown }).streamRNVideoSDK; registerSDKGlobals(); internalCallManager = globalThis.streamRNVideoSDK!.callManager; }); diff --git a/packages/react-native-sdk/__tests__/callingx/cancelledJoinCleanup.test.ts b/packages/react-native-sdk/__tests__/callingx/cancelledJoinCleanup.test.ts new file mode 100644 index 0000000000..79a79d8963 --- /dev/null +++ b/packages/react-native-sdk/__tests__/callingx/cancelledJoinCleanup.test.ts @@ -0,0 +1,140 @@ +import { CallingState, StreamVideoClient } from '@stream-io/video-client'; + +/** + * A native registration can outlive the join that asked for it: `leave()` lands + * while the OS is still bringing the call up, and core then aborts the join at + * its next cancellation check - before reaching the failure path that would end + * the native call. Ringing calls have a lifecycle owner that cleans up after + * them; ordinary calls with ongoing-call integration do not, so the bridge has + * to close its own registration. + */ + +const mockTracked = new Set(); +const mockCallingxModule = { + isSetup: true, + isOngoingCallsEnabled: true, + isCallTracked: jest.fn((cid: string) => mockTracked.has(cid)), + displayIncomingCall: jest.fn(async (cid: string) => { + mockTracked.add(cid); + }), + answerIncomingCall: jest.fn().mockResolvedValue(undefined), + startCall: jest.fn(async (cid: string) => { + mockTracked.add(cid); + }), + endCallWithReason: jest.fn(async (cid: string) => { + mockTracked.delete(cid); + }), +}; + +jest.mock('../../src/utils/push/libs/callingx', () => ({ + getCallingxLibIfAvailable: () => mockCallingxModule, + getCallingxLib: () => mockCallingxModule, +})); + +// required rather than imported: the bridge reads the callingx module at import +// time, and an `import` would run before the mock object above is assigned +const { + joinCallingxCall, + endCallingxCall, +} = require('../../src/utils/internal/callingx/callingx'); +const { + beforeJoin, + onJoinFailed, + onLeave, +} = require('../../src/utils/internal/ringingCallLifecycle'); + +const createCall = () => { + const client = new StreamVideoClient({ + apiKey: 'abc', + // no network from these fixtures - see ringingJoinIntegration.test.ts + options: { clientEventsReportingEnabled: false, logLevel: 'error' }, + }); + const call = client.call( + 'test', + `cancel-${Math.random().toString(36).slice(2)}`, + ); + // stop short of the network; what happens before the join is the subject + jest.spyOn(call as any, 'doJoin').mockResolvedValue(undefined); + jest.spyOn(call as any, 'setup').mockResolvedValue(undefined); + return call; +}; + +/** Holds the native registration open until the test releases it. */ +const deferRegistration = (method: 'startCall' | 'displayIncomingCall') => { + let release: () => void = () => {}; + mockCallingxModule[method].mockImplementationOnce( + (cid: string) => + new Promise((resolve) => { + release = () => { + mockTracked.add(cid); + resolve(); + }; + }), + ); + return () => release(); +}; + +const tick = () => new Promise((r) => setImmediate(r)); + +beforeEach(() => { + jest.clearAllMocks(); + mockTracked.clear(); + (globalThis as any).streamRNVideoSDK = { + callingX: { joinCall: joinCallingxCall, endCall: endCallingxCall }, + ringingCallLifecycle: { beforeJoin, onJoinFailed, onLeave }, + callManager: { setup: jest.fn(), start: jest.fn(), stop: jest.fn() }, + }; +}); + +afterEach(() => { + (globalThis as any).streamRNVideoSDK = undefined; +}); + +describe('a join cancelled while its native registration is pending', () => { + it('leaves no tracked call behind for an ordinary ongoing call', async () => { + const call = createCall(); + const release = deferRegistration('startCall'); + + const joining = call.join(); + await tick(); + await call.leave(); + release(); + + // The cancelled join settles quietly; what matters is that it stopped short + // of doJoin and left nothing registered natively. + await expect(joining).resolves.toBeUndefined(); + expect((call as any).doJoin).not.toHaveBeenCalled(); + expect(call.state.callingState).toBe(CallingState.LEFT); + expect(mockCallingxModule.endCallWithReason).toHaveBeenCalledWith( + call.cid, + 'canceled', + ); + expect(mockTracked.has(call.cid)).toBe(false); + }); + + it('leaves no tracked call behind for a ringing call', async () => { + const call = createCall(); + const release = deferRegistration('displayIncomingCall'); + + const joining = call.join({ ring: true }).catch((e: Error) => e); + await tick(); + await call.leave({ reject: false }); + release(); + + await joining; + expect((call as any).doJoin).not.toHaveBeenCalled(); + // answering a call nobody is waiting for is exactly what this prevents + expect(mockCallingxModule.answerIncomingCall).not.toHaveBeenCalled(); + expect(mockTracked.has(call.cid)).toBe(false); + }); + + it('keeps the registration when the join was not cancelled', async () => { + const call = createCall(); + + await call.join(); + + expect(mockCallingxModule.startCall).toHaveBeenCalled(); + expect(mockCallingxModule.endCallWithReason).not.toHaveBeenCalled(); + expect(mockTracked.has(call.cid)).toBe(true); + }); +}); diff --git a/packages/react-native-sdk/__tests__/callingx/joinCallingxCall.test.ts b/packages/react-native-sdk/__tests__/callingx/joinCallingxCall.test.ts new file mode 100644 index 0000000000..9021c4406f --- /dev/null +++ b/packages/react-native-sdk/__tests__/callingx/joinCallingxCall.test.ts @@ -0,0 +1,209 @@ +import { CallingState } from '@stream-io/video-client'; + +/** + * Registration of an incoming call waits for any other ringing call to leave + * first, and that wait is unbounded. If the target call is left during it, the + * bridge must not go on to register and answer it: the native side is keyed by + * cid alone, so an orphan registration cannot be told apart from a replacement + * lifecycle's, and whichever cleanup runs next ends the wrong call. + */ + +const mockTracked = new Set(); +const mockCallingxModule = { + isSetup: true, + isOngoingCallsEnabled: false, + isCallTracked: jest.fn((cid: string) => mockTracked.has(cid)), + displayIncomingCall: jest.fn(async (cid: string) => { + mockTracked.add(cid); + }), + answerIncomingCall: jest.fn().mockResolvedValue(undefined), + endCallWithReason: jest.fn(async (cid: string) => { + mockTracked.delete(cid); + }), + startCall: jest.fn(async (cid: string) => { + mockTracked.add(cid); + }), +}; + +jest.mock('../../src/utils/push/libs/callingx', () => ({ + getCallingxLibIfAvailable: () => mockCallingxModule, + getCallingxLib: () => mockCallingxModule, +})); + +const makeCall = (overrides: Partial = {}) => + ({ + cid: 'default:target', + ringing: true, + isCreatedByMe: false, + state: { + callingState: CallingState.RINGING, + createdBy: { id: 'caller' }, + settings: { video: { enabled: false } }, + members: [], + }, + leave: jest.fn().mockResolvedValue(undefined), + ...overrides, + }) as any; + +describe('joinCallingxCall', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockTracked.clear(); + mockCallingxModule.isOngoingCallsEnabled = false; + }); + + it('registers an incoming call that is still wanted', async () => { + const { + joinCallingxCall, + } = require('../../src/utils/internal/callingx/callingx'); + const call = makeCall(); + + await joinCallingxCall(call, [], () => false); + + expect(mockCallingxModule.displayIncomingCall).toHaveBeenCalledWith( + 'default:target', + 'caller', + expect.anything(), + false, + ); + expect(mockCallingxModule.answerIncomingCall).toHaveBeenCalledWith( + 'default:target', + ); + }); + + it('does not register a call whose join was cancelled while waiting', async () => { + const { + joinCallingxCall, + } = require('../../src/utils/internal/callingx/callingx'); + const call = makeCall(); + let cancelled = false; + const other = makeCall({ + cid: 'default:other', + leave: jest.fn(async () => { + // the user hangs up the target while the previous call is still leaving + cancelled = true; + }), + }); + + // the caller owns this decision: a `Call` reused for a fresh ring is also + // `LEFT` here, so the call's own state cannot answer it + await joinCallingxCall(call, [other], () => cancelled); + + expect(other.leave).toHaveBeenCalled(); + expect(mockCallingxModule.displayIncomingCall).not.toHaveBeenCalled(); + expect(mockCallingxModule.answerIncomingCall).not.toHaveBeenCalled(); + // nothing was registered, so there is nothing to end either + expect(mockCallingxModule.endCallWithReason).not.toHaveBeenCalled(); + }); + + it('ends a registration that completed after its join was cancelled', async () => { + const { + joinCallingxCall, + } = require('../../src/utils/internal/callingx/callingx'); + const call = makeCall(); + let cancelled = false; + mockCallingxModule.displayIncomingCall.mockImplementationOnce( + async (cid: string) => { + // the user hangs up while the OS is still bringing the call up + cancelled = true; + mockTracked.add(cid); + }, + ); + + await joinCallingxCall(call, [], () => cancelled); + + expect(mockCallingxModule.endCallWithReason).toHaveBeenCalledWith( + 'default:target', + 'canceled', + ); + expect(mockTracked.has('default:target')).toBe(false); + }); + + it('leaves the currently active call before registering the accepted one', async () => { + const { + joinCallingxCall, + } = require('../../src/utils/internal/callingx/callingx'); + const accepted = makeCall(); + const active = makeCall({ cid: 'default:active' }); + + await joinCallingxCall(accepted, [active], () => false); + + // the SDK's standing behaviour: one active call, so A goes before B arrives + expect(active.leave).toHaveBeenCalledWith({ reason: 'cancel' }); + expect(mockCallingxModule.displayIncomingCall).toHaveBeenCalled(); + expect(mockTracked.has('default:target')).toBe(true); + // declining B never reaches this bridge at all, so A is untouched by it + }); + + it('ends an outgoing registration that outlived its join', async () => { + const { + joinCallingxCall, + } = require('../../src/utils/internal/callingx/callingx'); + const call = makeCall({ cid: 'default:outgoing', isCreatedByMe: true }); + let cancelled = false; + mockCallingxModule.startCall.mockImplementationOnce(async (cid: string) => { + cancelled = true; + mockTracked.add(cid); + }); + + await joinCallingxCall(call, [], () => cancelled); + + expect(mockCallingxModule.endCallWithReason).toHaveBeenCalledWith( + 'default:outgoing', + 'canceled', + ); + expect(mockTracked.has('default:outgoing')).toBe(false); + }); + + it('ends a partial registration whose answer rejected after cancellation', async () => { + const { + joinCallingxCall, + } = require('../../src/utils/internal/callingx/callingx'); + const call = makeCall(); + let cancelled = false; + mockCallingxModule.displayIncomingCall.mockImplementationOnce( + async (cid: string) => { + mockTracked.add(cid); + }, + ); + mockCallingxModule.answerIncomingCall.mockImplementationOnce(async () => { + cancelled = true; + throw new Error('answer failed'); + }); + + await joinCallingxCall(call, [], () => cancelled); + + // the call was displayed, so something is tracked even though answer threw + expect(mockCallingxModule.endCallWithReason).toHaveBeenCalledWith( + 'default:target', + 'canceled', + ); + expect(mockTracked.has('default:target')).toBe(false); + }); + + it('ends an ordinary ongoing registration that outlived its join', async () => { + mockCallingxModule.isOngoingCallsEnabled = true; + const { + joinCallingxCall, + } = require('../../src/utils/internal/callingx/callingx'); + const call = makeCall({ + cid: 'default:ongoing', + ringing: false, + state: { callingState: CallingState.IDLE, members: [] }, + }); + let cancelled = false; + mockCallingxModule.startCall.mockImplementationOnce(async (cid: string) => { + cancelled = true; + mockTracked.add(cid); + }); + + await joinCallingxCall(call, [], () => cancelled); + + expect(mockCallingxModule.startCall).toHaveBeenCalled(); + expect(mockCallingxModule.endCallWithReason).toHaveBeenCalledWith( + 'default:ongoing', + 'canceled', + ); + expect(mockTracked.has('default:ongoing')).toBe(false); + }); +}); diff --git a/packages/react-native-sdk/__tests__/dogfood/meetingUiLifecycle.test.tsx b/packages/react-native-sdk/__tests__/dogfood/meetingUiLifecycle.test.tsx new file mode 100644 index 0000000000..d537ee466a --- /dev/null +++ b/packages/react-native-sdk/__tests__/dogfood/meetingUiLifecycle.test.tsx @@ -0,0 +1,520 @@ +import React from 'react'; +import { act, cleanup, render } from '@testing-library/react-native'; + +/** + * The dogfood meeting screen's Call lifetime. + * + * One `Call` is one call flow, and the sample has to honour that itself: the + * SDK cannot see a leave that finished before the public join even started, so + * a join handler still awaiting encryption setup is the app's to cancel. These + * cover that, and the teardown of a flow whose join failed. + * + * They live in this package rather than in the sample because the sample has no + * Jest setup of its own, and standing one up for two components is more + * machinery than the coverage is worth. + */ + +let mockCall: any; +const mockAppSetState = jest.fn(); +let mockLobbyProps: any; +let mockErrorProps: any; +let mockActiveCallProps: any; + +jest.mock('@stream-io/video-react-native-sdk', () => { + class Manager { + static isSupported = jest.fn(() => true); + static create = jest.fn(); + dispose = jest.fn(); + setSharedKey = jest.fn(); + requestKeyState = jest.fn(); + on = () => () => {}; + } + return { + EncryptionManager: Manager, + EncryptionSettingsRequestModeEnum: { AUTO_ON: 'auto-on' }, + EncryptionSettingsResponseModeEnum: { AUTO_ON: 'auto-on' }, + CallingState: { LEFT: 'left', JOINED: 'joined', IDLE: 'idle' }, + useCall: () => mockCall, + useI18n: () => ({ t: (key: string) => key }), + useCallStateHooks: () => ({ + useRemoteParticipants: () => [], + useCallCallingState: () => mockCall?.state?.callingState, + }), + }; +}); + +jest.mock( + 'react-native-quick-crypto', + () => ({ pbkdf2Sync: () => new Uint8Array(16) }), + { virtual: true }, +); + +// literal paths: `jest.mock` is hoisted above any local that would shorten them +const DOGFOOD = '../../../../sample-apps/react-native/dogfood/src'; +jest.mock( + '../../../../sample-apps/react-native/dogfood/src/contexts/createStoreContext', + () => ({ mmkvStorage: { getString: () => JSON.stringify('a-passphrase') } }), +); +jest.mock( + '../../../../sample-apps/react-native/dogfood/src/contexts/AppContext', + () => ({ useAppGlobalStoreSetState: () => mockAppSetState }), +); +jest.mock( + '../../../../sample-apps/react-native/dogfood/src/contexts/LayoutContext', + () => ({ LayoutProvider: ({ children }: any) => children }), +); +jest.mock( + '../../../../sample-apps/react-native/dogfood/src/components/LobbyViewComponent', + () => ({ + LobbyViewComponent: (props: any) => { + mockLobbyProps = props; + return null; + }, + }), +); +jest.mock( + '../../../../sample-apps/react-native/dogfood/src/components/ActiveCall', + () => ({ + ActiveCall: (props: any) => { + mockActiveCallProps = props; + return null; + }, + }), +); +jest.mock( + '../../../../sample-apps/react-native/dogfood/src/components/AuthenticatingProgress', + () => ({ AuthenticationProgress: () => null }), +); +jest.mock( + '../../../../sample-apps/react-native/dogfood/src/components/CallErrorComponent', + () => ({ + CallErrorComponent: (props: any) => { + mockErrorProps = props; + return null; + }, + }), +); +void DOGFOOD; + +// Required, not imported. A static import would pull the sample's whole tree +// into this package's type-check, where it sits outside `rootDir`; the paths +// below are plain strings that `tsc` never resolves. The SDK is taken from its +// mock for a similar reason - this package cannot list itself as a dependency. +const { MeetingUI } = jest.requireActual( + '../../../../sample-apps/react-native/dogfood/src/components/MeetingUI', +) as { MeetingUI: React.ComponentType }; +const { LobbyE2EEContext } = jest.requireActual( + '../../../../sample-apps/react-native/dogfood/src/contexts/LobbyE2EEContext', +) as { LobbyE2EEContext: React.Context }; +const { EncryptionManager } = jest.requireMock( + '@stream-io/video-react-native-sdk', +) as { EncryptionManager: any }; + +const fakeCall = (overrides: Partial = {}) => { + const call: any = { + currentUserId: 'dogfood-user', + state: { callingState: 'idle' }, + setE2EEManager: jest.fn((manager: any) => { + call.e2eeManager = manager; + }), + join: jest.fn().mockResolvedValue(undefined), + leave: jest.fn(async () => { + call.state.callingState = 'left'; + }), + ...overrides, + }; + return call; +}; + +const navigation = () => ({ + navigate: jest.fn(), + popTo: jest.fn(), + goBack: jest.fn(), +}); + +/** The meeting's lobby key, as the meeting screen provides it. */ +const e2eeControls = { + encryptionKey: 'a-passphrase', + updateEncryptionKey: jest.fn(), +}; + +const meeting = (nav: any) => ( + + + +); + +const renderMeeting = (nav = navigation()) => { + const view = render(meeting(nav)); + return { view, nav }; +}; + +/** Swaps in a replacement Call, the way a changed callId or client does. */ +const replaceCall = (view: any, nav: any, next: any) => { + mockCall = next; + view.rerender(meeting(nav)); +}; + +/** + * A `leave()` that stays pending for every caller until released. + * + * One shared promise on purpose: a failed join and the unmount cleanup both + * call leave, and a fresh deferred per call would strand the first caller. + */ +const pendingLeave = () => { + let release!: () => void; + const promise = new Promise((resolve) => (release = resolve)); + return { leave: jest.fn(() => promise), release: () => release() }; +}; + +/** Holds `EncryptionManager.create` open until the test releases it. */ +const deferCreate = () => { + let release!: (manager: any) => void; + (EncryptionManager.create as jest.Mock).mockImplementation( + () => new Promise((resolve) => (release = resolve)), + ); + return (manager: any) => release(manager); +}; + +const settle = () => act(async () => void (await Promise.resolve())); + +beforeEach(() => { + jest.clearAllMocks(); + mockLobbyProps = undefined; + mockErrorProps = undefined; + mockActiveCallProps = undefined; + (EncryptionManager.isSupported as jest.Mock).mockReturnValue(true); + jest.spyOn(console, 'log').mockImplementation(() => {}); +}); + +afterEach(() => { + cleanup(); + jest.restoreAllMocks(); +}); + +describe('a join whose screen ends while encryption is still being prepared', () => { + it('does not join the discarded call, and releases the late manager', async () => { + const finishCreate = deferCreate(); + mockCall = fakeCall(); + const { view } = renderMeeting(); + + let joining!: Promise; + act(() => { + joining = mockLobbyProps.onJoinCallHandler(); + }); + // Android Back, say: the screen goes while the manager is still being made + view.unmount(); + await settle(); + expect(mockCall.state.callingState).toBe('left'); + + const manager = new (EncryptionManager as any)(); + await act(async () => { + finishCreate(manager); + await joining; + }); + + expect(mockCall.join).not.toHaveBeenCalled(); + expect(manager.dispose).toHaveBeenCalledTimes(1); + }); + + it('waits for an in-flight leave before releasing the late manager', async () => { + const finishCreate = deferCreate(); + const leave = pendingLeave(); + mockCall = fakeCall({ leave: leave.leave }); + const { view } = renderMeeting(); + + let joining!: Promise; + act(() => { + joining = mockLobbyProps.onJoinCallHandler(); + }); + view.unmount(); + await settle(); + // the screen is gone but its leave has not finished, so a LEFT check alone + // would still read `idle` here + expect(mockCall.state.callingState).toBe('idle'); + + const manager = new (EncryptionManager as any)(); + finishCreate(manager); + await act(async () => { + mockCall.state.callingState = 'left'; + leave.release(); + await joining; + }); + + expect(mockCall.join).not.toHaveBeenCalled(); + // both owners can reach it here - the cleanup's own disposal runs once its + // leave settles, and this continuation's runs after that - so only the + // release itself is asserted; `dispose()` is idempotent by contract + expect(manager.dispose).toHaveBeenCalled(); + }); + + it('still prepares once for two initial taps', async () => { + const finishCreate = deferCreate(); + mockCall = fakeCall(); + renderMeeting(); + + let first!: Promise; + let second!: Promise; + act(() => { + first = mockLobbyProps.onJoinCallHandler(); + second = mockLobbyProps.onJoinCallHandler(); + }); + expect(EncryptionManager.create).toHaveBeenCalledTimes(1); + + await act(async () => { + finishCreate(new (EncryptionManager as any)()); + await Promise.all([first, second]); + }); + + expect(mockCall.join).toHaveBeenCalledTimes(1); + }); +}); + +describe('a meeting whose join failed', () => { + const joinAndFail = async () => { + const manager = new (EncryptionManager as any)(); + (EncryptionManager.create as jest.Mock).mockResolvedValue(manager); + mockCall = fakeCall(); + mockCall.join.mockRejectedValue(new Error('join failed')); + const { nav, view } = renderMeeting(); + await act(async () => { + await mockLobbyProps.onJoinCallHandler(); + }); + return { manager, nav, view }; + }; + + it('leaves the call and releases its manager', async () => { + const { manager } = await joinAndFail(); + + expect(mockCall.state.callingState).toBe('left'); + expect(manager.dispose).toHaveBeenCalledTimes(1); + // no lobby button: that Call is finished with + expect(mockErrorProps.backToLobbyHandler).toBeUndefined(); + }); + + it('removes the finished route on Return to Home', async () => { + const { nav } = await joinAndFail(); + + act(() => mockErrorProps.returnToHomeHandler()); + + // `navigate` would push a second JoinMeetingScreen and leave this one + // mounted underneath it, reachable with Back + expect(nav.popTo).toHaveBeenCalledWith('JoinMeetingScreen'); + expect(nav.navigate).not.toHaveBeenCalledWith('JoinMeetingScreen'); + }); + + it('gives the next entry a different call and manager', async () => { + const first = await joinAndFail(); + const firstCall = mockCall; + // Return to Home pops this route, so the screen unmounts + await act(async () => first.view.unmount()); + + const second = await joinAndFail(); + + expect(mockCall).not.toBe(firstCall); + expect(second.manager).not.toBe(first.manager); + expect(mockCall.join).toHaveBeenCalledTimes(1); + }); +}); + +describe('a live meeting whose leave failed', () => { + it('keeps its manager and still offers the lobby', async () => { + const manager = new (EncryptionManager as any)(); + (EncryptionManager.create as jest.Mock).mockResolvedValue(manager); + mockCall = fakeCall({ + leave: jest.fn().mockRejectedValue(new Error('leave failed')), + }); + mockCall.join.mockRejectedValue(new Error('join failed')); + jest.spyOn(console, 'error').mockImplementation(() => {}); + renderMeeting(); + + await act(async () => { + await mockLobbyProps.onJoinCallHandler(); + }); + + // the call may still have live peers using this manager + expect(mockCall.state.callingState).not.toBe('left'); + expect(manager.dispose).not.toHaveBeenCalled(); + }); +}); + +/** + * Both parents keep one mounted MeetingUI across a change of `Call`, so the + * screen has to keep the two flows apart itself: the abandoned one must not + * hold the replacement's pending-join guard, and must not finish later into + * the replacement's view or the app store. + */ +describe('a Call replaced while its flow is still running', () => { + it('does not make the replacement wait on the abandoned setup', async () => { + let releaseA!: (manager: any) => void; + (EncryptionManager.create as jest.Mock) + .mockImplementationOnce( + () => new Promise((resolve) => (releaseA = resolve)), + ) + .mockResolvedValue(new (EncryptionManager as any)()); + const callA = fakeCall(); + mockCall = callA; + const { view, nav } = renderMeeting(); + + act(() => void mockLobbyProps.onJoinCallHandler()); + const callB = fakeCall(); + replaceCall(view, nav, callB); + + // B's own guard, not A's: A's preparation has no bounded timeout here + await act(async () => { + await mockLobbyProps.onJoinCallHandler(); + }); + + expect(callB.join).toHaveBeenCalledTimes(1); + expect(callA.join).not.toHaveBeenCalled(); + + // let A finish so it releases its own manager and nothing else + const managerA = new (EncryptionManager as any)(); + await act(async () => { + releaseA(managerA); + await Promise.resolve(); + }); + expect(managerA.dispose).toHaveBeenCalled(); + expect(callB.e2eeManager.dispose).not.toHaveBeenCalled(); + }); + + it('does not show the replacement as active when the old join resolves', async () => { + (EncryptionManager.create as jest.Mock).mockResolvedValue( + new (EncryptionManager as any)(), + ); + let finishJoinA!: () => void; + const callA = fakeCall({ + join: jest.fn(() => new Promise((r) => (finishJoinA = r))), + }); + mockCall = callA; + const { view, nav } = renderMeeting(); + + let joiningA!: Promise; + await act(async () => { + joiningA = mockLobbyProps.onJoinCallHandler(); + await Promise.resolve(); + }); + replaceCall(view, nav, fakeCall()); + + // core can resolve a superseded join rather than reject it + await act(async () => { + finishJoinA(); + await joiningA; + }); + + expect(mockActiveCallProps).toBeUndefined(); + expect(mockLobbyProps).toBeDefined(); + expect(mockAppSetState).not.toHaveBeenCalled(); + }); + + it("does not replace the new lobby with the old flow's error", async () => { + (EncryptionManager.create as jest.Mock).mockResolvedValue( + new (EncryptionManager as any)(), + ); + const leaveA = pendingLeave(); + const callA = fakeCall({ + join: jest.fn().mockRejectedValue(new Error('join failed')), + leave: leaveA.leave, + }); + mockCall = callA; + const { view, nav } = renderMeeting(); + + let joiningA!: Promise; + await act(async () => { + joiningA = mockLobbyProps.onJoinCallHandler(); + await Promise.resolve(); + }); + replaceCall(view, nav, fakeCall()); + + await act(async () => { + callA.state.callingState = 'left'; + leaveA.release(); + await joiningA; + }); + + expect(mockErrorProps).toBeUndefined(); + expect(mockLobbyProps).toBeDefined(); + }); +}); + +/** + * A leave that rejects may have stopped short of disposing the peers, which are + * still encrypting through the manager. Every disposal path has to agree on + * that, not just the failed-join one. + */ +describe('a teardown that failed', () => { + it('keeps the manager when both the failed join and the unmount leave fail', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + const manager = new (EncryptionManager as any)(); + (EncryptionManager.create as jest.Mock).mockResolvedValue(manager); + mockCall = fakeCall({ + join: jest.fn().mockRejectedValue(new Error('join failed')), + leave: jest.fn().mockRejectedValue(new Error('leave failed')), + }); + const { view } = renderMeeting(); + + await act(async () => { + await mockLobbyProps.onJoinCallHandler(); + }); + expect(manager.dispose).not.toHaveBeenCalled(); + + // Return to Home pops the route, so the screen unmounts + await act(async () => view.unmount()); + + expect(mockCall.state.callingState).not.toBe('left'); + expect(manager.dispose).not.toHaveBeenCalled(); + }); + + it('keeps the manager of a joined call whose hangup and unmount leave fail', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + const manager = new (EncryptionManager as any)(); + (EncryptionManager.create as jest.Mock).mockResolvedValue(manager); + mockCall = fakeCall({ + leave: jest.fn().mockRejectedValue(new Error('leave failed')), + }); + mockCall.join.mockImplementation(async () => { + mockCall.state.callingState = 'joined'; + }); + const { view } = renderMeeting(); + + await act(async () => { + await mockLobbyProps.onJoinCallHandler(); + }); + expect(mockActiveCallProps).toBeDefined(); + + await act(async () => { + await mockActiveCallProps.onHangupCallHandler(); + }); + await act(async () => view.unmount()); + + expect(mockCall.state.callingState).toBe('joined'); + expect(manager.dispose).not.toHaveBeenCalled(); + }); + + it('still releases the manager once a pending leave succeeds', async () => { + let finishCreate!: (manager: any) => void; + (EncryptionManager.create as jest.Mock).mockImplementation( + () => new Promise((resolve) => (finishCreate = resolve)), + ); + const leave = pendingLeave(); + mockCall = fakeCall({ leave: leave.leave }); + const { view } = renderMeeting(); + + let joining!: Promise; + act(() => { + joining = mockLobbyProps.onJoinCallHandler(); + }); + view.unmount(); + await settle(); + + const manager = new (EncryptionManager as any)(); + finishCreate(manager); + await act(async () => { + mockCall.state.callingState = 'left'; + leave.release(); + await joining; + }); + + expect(manager.dispose).toHaveBeenCalled(); + }); +}); diff --git a/packages/react-native-sdk/__tests__/encryption/EncryptionManager.test.ts b/packages/react-native-sdk/__tests__/encryption/EncryptionManager.test.ts new file mode 100644 index 0000000000..dbaefb0e88 --- /dev/null +++ b/packages/react-native-sdk/__tests__/encryption/EncryptionManager.test.ts @@ -0,0 +1,421 @@ +/** + * Tests for the React Native EncryptionManager: the wrapper that makes the + * native encryption manager satisfy the core `E2EEManager` contract with the + * same public semantics as the web manager. + * + * The failure mode this guards against is silently publishing plaintext, so the + * assertions are deliberately exact about validation, fail-closed throwing, and + * that no key material leaves the process. + */ +import { + RTCEncryptionAlgorithm, + RTCEncryptionManager, + RTCEncryptionTrackType, +} from '@stream-io/react-native-webrtc'; +import * as client from '@stream-io/video-client'; +import { EncryptionManager } from '../../src/modules/encryption/EncryptionManager'; + +const NativeManager = RTCEncryptionManager as unknown as { + supported: boolean; + instances: any[]; + isSupported: jest.Mock; + create: jest.Mock; +}; + +/** Replace the manager's logger with a recording stub. */ +const stubLogger = () => { + const logger = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + trace: jest.fn(), + }; + jest + .spyOn(client.videoLoggerSystem, 'getLogger') + .mockReturnValue(logger as never); + return logger; +}; + +const key = (length: number) => new Uint8Array(length).fill(7).buffer; + +const createManager = async (options?: { + algorithm?: 'AES-128-GCM' | 'AES-256-GCM'; +}) => { + const manager = await EncryptionManager.create('alice', options); + return { manager, native: NativeManager.instances.at(-1)! }; +}; + +beforeEach(() => { + NativeManager.supported = true; + NativeManager.instances = []; + NativeManager.isSupported.mockClear(); + NativeManager.create.mockClear(); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('platform dispatch', () => { + it('creates a native-backed manager bound to the local user', async () => { + const { manager, native } = await createManager(); + expect(native.userId).toBe('alice'); + expect(manager).toBeDefined(); + }); + + it('defaults to AES-128 and maps the algorithm to the native enum', async () => { + const { native } = await createManager(); + expect(native.options).toEqual({ + algorithm: RTCEncryptionAlgorithm.AES_128_GCM, + }); + const { native: native256 } = await createManager({ + algorithm: 'AES-256-GCM', + }); + expect(native256.options).toEqual({ + algorithm: RTCEncryptionAlgorithm.AES_256_GCM, + }); + }); + + it('reports support from the native module', () => { + expect(EncryptionManager.isSupported()).toBe(true); + NativeManager.supported = false; + expect(EncryptionManager.isSupported()).toBe(false); + }); + + it('rejects rather than degrading to plaintext when unsupported', async () => { + NativeManager.supported = false; + await expect(EncryptionManager.create('alice')).rejects.toThrow( + 'E2EE is not supported on this device', + ); + expect(NativeManager.create).not.toHaveBeenCalled(); + }); + + it('propagates a native create failure', async () => { + NativeManager.create.mockImplementationOnce(() => { + throw new Error('native boom'); + }); + await expect(EncryptionManager.create('alice')).rejects.toThrow( + 'native boom', + ); + }); +}); + +describe('key validation', () => { + it.each([15, 17, 0, 32])('rejects a %s-byte AES-128 key', async (length) => { + const { manager, native } = await createManager(); + expect(() => manager.setSharedKey(0, key(length))).toThrow( + 'Key must be exactly 16 bytes (AES-128)', + ); + expect(() => manager.setKey('bob', 0, key(length))).toThrow( + 'Key must be exactly 16 bytes (AES-128)', + ); + expect(native.setSharedKey).not.toHaveBeenCalled(); + expect(native.setKey).not.toHaveBeenCalled(); + }); + + it.each([31, 33, 16])('rejects a %s-byte AES-256 key', async (length) => { + const { manager } = await createManager({ algorithm: 'AES-256-GCM' }); + expect(() => manager.setSharedKey(0, key(length))).toThrow( + 'Key must be exactly 32 bytes (AES-256)', + ); + }); + + it('accepts the exact key length for each algorithm', async () => { + const { manager, native } = await createManager(); + manager.setSharedKey(0, key(16)); + expect(native.setSharedKey).toHaveBeenCalledTimes(1); + + const { manager: m256, native: n256 } = await createManager({ + algorithm: 'AES-256-GCM', + }); + m256.setKey('bob', 3, key(32)); + expect(n256.setKey).toHaveBeenCalledWith('bob', 3, expect.any(Uint8Array)); + }); + + it.each([-1, 256, 1.5, NaN, Infinity])( + 'rejects keyIndex %s at the API boundary', + async (keyIndex) => { + const { manager, native } = await createManager(); + const message = `keyIndex must be an integer between 0 and 255, got ${keyIndex}`; + expect(() => manager.setSharedKey(keyIndex, key(16))).toThrow(message); + expect(() => manager.setKey('bob', keyIndex, key(16))).toThrow(message); + expect(() => manager.removeKey('bob', keyIndex)).toThrow(message); + expect(() => manager.removeSharedKey(keyIndex)).toThrow(message); + expect(native.setSharedKey).not.toHaveBeenCalled(); + expect(native.removeKey).not.toHaveBeenCalled(); + }, + ); + + it.each([0, 255])('accepts keyIndex %s', async (keyIndex) => { + const { manager, native } = await createManager(); + manager.setSharedKey(keyIndex, key(16)); + manager.removeSharedKey(keyIndex); + expect(native.setSharedKey).toHaveBeenCalledWith( + keyIndex, + expect.any(Uint8Array), + ); + expect(native.removeSharedKey).toHaveBeenCalledWith(keyIndex); + }); + + it('validates the key index before the key length', async () => { + // Otherwise a caller with two mistakes fixes the length and hits the index. + const { manager } = await createManager(); + expect(() => manager.setSharedKey(999, key(3))).toThrow( + 'keyIndex must be an integer between 0 and 255, got 999', + ); + }); + + it('does not validate a key index it does not send', async () => { + const { manager, native } = await createManager(); + manager.removeAllKeys('bob'); + expect(native.removeAllKeys).toHaveBeenCalledWith('bob'); + }); +}); + +describe('key hygiene', () => { + it('copies the caller buffer instead of aliasing it', async () => { + const { manager, native } = await createManager(); + const buffer = new Uint8Array(16).fill(1); + manager.setSharedKey(0, buffer.buffer); + const handed: Uint8Array = native.setSharedKey.mock.calls[0][1]; + expect(Array.from(handed)).toEqual(Array(16).fill(1)); + + // The caller keeps ownership and may re-import the same bytes, so mutating + // its buffer afterwards must not reach into the installed key. + buffer.fill(9); + expect(Array.from(handed)).toEqual(Array(16).fill(1)); + expect(handed.buffer).not.toBe(buffer.buffer); + }); + + it('never puts key material in a log line', async () => { + const logger = stubLogger(); + const { manager } = await createManager(); + manager.setSharedKey(0, key(16)); + manager.setKey('bob', 1, key(16)); + manager.requestKeyState(); + manager.enablePerformanceReporting(true); + await new Promise(process.nextTick); + + const logged = JSON.stringify( + Object.values(logger).flatMap((level) => level.mock.calls), + ); + // the key bytes are 7s; a leak would serialize them either as the array or + // as the base64 the bridge uses + expect(logged).not.toContain('7,7,7'); + expect(logged).not.toContain( + Buffer.from(new Uint8Array(16).fill(7)).toString('base64'), + ); + }); +}); + +describe('attach', () => { + it.each([ + ['AUDIO', RTCEncryptionTrackType.AUDIO], + ['VIDEO', RTCEncryptionTrackType.VIDEO], + ['SCREEN_SHARE', RTCEncryptionTrackType.SCREEN_SHARE], + ['SCREEN_SHARE_AUDIO', RTCEncryptionTrackType.SCREEN_SHARE_AUDIO], + ])('passes the %s track type through to native', async (name, native) => { + const { manager, native: nativeManager } = await createManager(); + const sender = { id: 'sender' } as never; + const receiver = { id: 'receiver' } as never; + manager.encrypt(sender, 'vp8', name); + manager.decrypt(receiver, 'bob', name); + expect(nativeManager.encrypt).toHaveBeenCalledWith(sender, 'vp8', native); + expect(nativeManager.decrypt).toHaveBeenCalledWith(receiver, 'bob', native); + }); + + it('passes the sender and codec through untouched', async () => { + // The core lowercases the codec already, and native pins it exactly. + const { manager, native } = await createManager(); + const sender = {} as never; + manager.encrypt(sender, undefined, undefined); + expect(native.encrypt).toHaveBeenCalledWith(sender, undefined, undefined); + }); + + it('lets native infer the track type when the name is unknown', async () => { + const { manager, native } = await createManager(); + manager.encrypt({} as never, 'opus', 'UNSPECIFIED'); + expect(native.encrypt).toHaveBeenCalledWith( + expect.anything(), + 'opus', + undefined, + ); + }); + + it('propagates a native attach failure rather than publishing plaintext', async () => { + const { manager, native } = await createManager(); + native.encrypt.mockImplementationOnce(() => { + throw new Error('attach failed'); + }); + expect(() => manager.encrypt({} as never, 'vp8', 'VIDEO')).toThrow( + 'attach failed', + ); + }); +}); + +describe('events', () => { + it('emits the web-shaped payload for a native event', async () => { + const { manager, native } = await createManager(); + const onStalled = jest.fn(); + manager.on('e2ee.decryption_stalled', onStalled); + native.emitNative({ + type: 'e2ee.decryption_stalled', + userId: 'bob', + keyIndex: 2, + trackType: RTCEncryptionTrackType.SCREEN_SHARE_AUDIO, + }); + expect(onStalled).toHaveBeenCalledWith({ + userId: 'bob', + keyIndex: 2, + trackType: 'SCREEN_SHARE_AUDIO', + }); + }); + + it('subscribes to every native event type', async () => { + const { native } = await createManager(); + expect(native.on.mock.calls.map(([type]: [string]) => type).sort()).toEqual( + [ + 'e2ee.decryption_failed', + 'e2ee.decryption_resumed', + 'e2ee.decryption_stalled', + 'e2ee.encryption_failed', + 'e2ee.key_state', + 'e2ee.missing_key', + 'e2ee.perf_report', + 'e2ee.unencrypted_frame', + 'e2ee.unsupported_version', + ], + ); + }); + + it('unsubscribes through the returned function and through off()', async () => { + const { manager, native } = await createManager(); + const listener = jest.fn(); + const unsubscribe = manager.on('e2ee.decryption_failed', listener); + unsubscribe(); + native.emitNative({ + type: 'e2ee.decryption_failed', + userId: 'bob', + }); + expect(listener).not.toHaveBeenCalled(); + + manager.on('e2ee.decryption_failed', listener); + manager.off('e2ee.decryption_failed', listener); + native.emitNative({ type: 'e2ee.decryption_failed', userId: 'bob' }); + expect(listener).not.toHaveBeenCalled(); + }); + + it('keeps dispatching when one listener throws', async () => { + const { manager, native } = await createManager(); + const second = jest.fn(); + manager.on('e2ee.decryption_failed', () => { + throw new Error('bad listener'); + }); + manager.on('e2ee.decryption_failed', second); + expect(() => + native.emitNative({ type: 'e2ee.decryption_failed', userId: 'bob' }), + ).not.toThrow(); + expect(second).toHaveBeenCalled(); + }); + + it('ignores an event name it does not know', async () => { + const { manager, native } = await createManager(); + const listener = jest.fn(); + manager.on('e2ee.key_state', listener); + native.listeners + .get('e2ee.key_state') + ?.forEach((fn: any) => fn({ type: 'e2ee.brand_new', userId: 'bob' })); + expect(listener).not.toHaveBeenCalled(); + }); +}); + +describe('observational calls', () => { + it('returns void and swallows a native rejection', async () => { + // The web manager returns void here, so these stay void rather than + // exposing the bridge's promises. + const logger = stubLogger(); + const { manager, native } = await createManager(); + native.enablePerformanceReporting.mockRejectedValueOnce( + new Error('no dice'), + ); + native.requestKeyState.mockRejectedValueOnce(new Error('no dice')); + + expect(manager.enablePerformanceReporting(true)).toBeUndefined(); + expect(manager.requestKeyState()).toBeUndefined(); + await new Promise(process.nextTick); + + expect(native.enablePerformanceReporting).toHaveBeenCalledWith(true); + expect(logger.warn).toHaveBeenCalledTimes(2); + }); +}); + +describe('dispose', () => { + const guarded = (manager: any) => [ + () => manager.setKey('bob', 0, key(16)), + () => manager.setSharedKey(0, key(16)), + () => manager.removeKey('bob', 0), + () => manager.removeAllKeys('bob'), + () => manager.removeSharedKey(0), + () => manager.encrypt({} as never, 'vp8', 'VIDEO'), + () => manager.decrypt({} as never, 'bob', 'VIDEO'), + () => manager.enablePerformanceReporting(true), + () => manager.requestKeyState(), + ]; + + it('makes every key and attach method throw afterwards', async () => { + const { manager } = await createManager(); + manager.dispose(); + for (const call of guarded(manager)) { + expect(call).toThrow('EncryptionManager is disposed'); + } + }); + + it('is idempotent and releases the native manager once', async () => { + const { manager, native } = await createManager(); + manager.dispose(); + manager.dispose(); + expect(native.dispose).toHaveBeenCalledTimes(1); + }); + + it('detaches every bridged native listener', async () => { + const { manager, native } = await createManager(); + manager.dispose(); + expect(native.off).toHaveBeenCalledTimes(9); + expect(native.listeners.get('e2ee.key_state')?.size ?? 0).toBe(0); + }); + + it('finishes local cleanup even when native dispose throws', async () => { + const logger = stubLogger(); + const { manager, native } = await createManager(); + native.dispose.mockImplementationOnce(() => { + throw new Error('native gone'); + }); + const listener = jest.fn(); + manager.on('e2ee.decryption_failed', listener); + expect(() => manager.dispose()).not.toThrow(); + expect(logger.warn).toHaveBeenCalled(); + expect(native.off).toHaveBeenCalledTimes(9); + expect(() => manager.setSharedKey(0, key(16))).toThrow( + 'EncryptionManager is disposed', + ); + }); + + it('leaves the listener API callable, as the web manager does', async () => { + const { manager } = await createManager(); + manager.dispose(); + const listener = jest.fn(); + expect(() => manager.on('e2ee.key_state', listener)()).not.toThrow(); + expect(() => manager.off('e2ee.key_state', listener)).not.toThrow(); + expect(() => manager.removeAllListeners()).not.toThrow(); + }); + + it('stops emitting events after dispose', async () => { + const { manager, native } = await createManager(); + const listener = jest.fn(); + manager.on('e2ee.decryption_failed', listener); + manager.dispose(); + native.emitNative({ type: 'e2ee.decryption_failed', userId: 'bob' }); + expect(listener).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react-native-sdk/__tests__/encryption/eventMapping.test.ts b/packages/react-native-sdk/__tests__/encryption/eventMapping.test.ts new file mode 100644 index 0000000000..99162debae --- /dev/null +++ b/packages/react-native-sdk/__tests__/encryption/eventMapping.test.ts @@ -0,0 +1,228 @@ +import { + RTCEncryptionAlgorithm, + RTCEncryptionTrackType, +} from '@stream-io/react-native-webrtc'; +import { + algorithmToNative, + mapNativeEvent, + trackTypeFromNative, + trackTypeToNative, +} from '../../src/modules/encryption/eventMapping'; + +describe('trackType mapping', () => { + it.each([ + ['AUDIO', RTCEncryptionTrackType.AUDIO], + ['VIDEO', RTCEncryptionTrackType.VIDEO], + ['SCREEN_SHARE', RTCEncryptionTrackType.SCREEN_SHARE], + ['SCREEN_SHARE_AUDIO', RTCEncryptionTrackType.SCREEN_SHARE_AUDIO], + ])('round-trips %s', (name, native) => { + expect(trackTypeToNative(name)).toBe(native); + expect(trackTypeFromNative(native)).toBe(name); + }); + + it('keeps screen-share audio distinct from microphone audio', () => { + // Native keys replay state per (userId, trackType), so collapsing the two + // mis-groups it - the bug the iOS SDK shipped. + expect(trackTypeToNative('SCREEN_SHARE_AUDIO')).not.toBe( + trackTypeToNative('AUDIO'), + ); + }); + + it('maps unknown and absent track types to undefined', () => { + expect(trackTypeToNative(undefined)).toBeUndefined(); + expect(trackTypeToNative('UNSPECIFIED')).toBeUndefined(); + expect(trackTypeToNative('audio')).toBeUndefined(); + expect(trackTypeFromNative(undefined)).toBeUndefined(); + expect(trackTypeFromNative(99)).toBeUndefined(); + }); +}); + +describe('algorithmToNative', () => { + it('maps both algorithms', () => { + expect(algorithmToNative('AES-128-GCM')).toBe( + RTCEncryptionAlgorithm.AES_128_GCM, + ); + expect(algorithmToNative('AES-256-GCM')).toBe( + RTCEncryptionAlgorithm.AES_256_GCM, + ); + }); +}); + +describe('mapNativeEvent', () => { + const base = { managerId: 'handle-1', userId: 'alice' }; + + it('drops the bridge-only fields from every payload', () => { + const mapped = mapNativeEvent({ + ...base, + type: 'e2ee.decryption_failed', + trackType: RTCEncryptionTrackType.VIDEO, + } as never); + expect(mapped).toEqual({ + type: 'e2ee.decryption_failed', + payload: { userId: 'alice', trackType: 'VIDEO' }, + }); + expect(Object.keys(mapped!.payload)).toEqual(['userId', 'trackType']); + }); + + it('maps decryption_resumed and unencrypted_frame the same way', () => { + for (const type of [ + 'e2ee.decryption_resumed', + 'e2ee.unencrypted_frame', + ] as const) { + expect(mapNativeEvent({ ...base, type } as never)).toEqual({ + type, + payload: { userId: 'alice', trackType: undefined }, + }); + } + }); + + it('keeps missing_key.keyIndex optional, telling the two cases apart', () => { + // No keyIndex means the local encoder has no key at all; with one, a remote + // frame named an epoch we do not hold. + expect( + mapNativeEvent({ ...base, type: 'e2ee.missing_key' } as never)!.payload, + ).toEqual({ userId: 'alice', keyIndex: undefined, trackType: undefined }); + expect( + mapNativeEvent({ + ...base, + type: 'e2ee.missing_key', + keyIndex: 7, + trackType: RTCEncryptionTrackType.AUDIO, + } as never)!.payload, + ).toEqual({ userId: 'alice', keyIndex: 7, trackType: 'AUDIO' }); + }); + + it('defaults the fields the web payloads require but native leaves optional', () => { + expect( + mapNativeEvent({ + ...base, + type: 'e2ee.decryption_stalled', + } as never)!.payload, + ).toEqual({ userId: 'alice', keyIndex: 0, trackType: undefined }); + expect( + mapNativeEvent({ + ...base, + type: 'e2ee.encryption_failed', + } as never)!.payload, + ).toEqual({ userId: 'alice', reason: '', trackType: undefined }); + expect( + mapNativeEvent({ + ...base, + type: 'e2ee.unsupported_version', + } as never)!.payload, + ).toEqual({ userId: 'alice', version: 0, trackType: undefined }); + }); + + it('passes through the populated failure fields', () => { + expect( + mapNativeEvent({ + ...base, + type: 'e2ee.encryption_failed', + reason: 'no key', + trackType: RTCEncryptionTrackType.SCREEN_SHARE, + } as never)!.payload, + ).toEqual({ userId: 'alice', reason: 'no key', trackType: 'SCREEN_SHARE' }); + expect( + mapNativeEvent({ + ...base, + type: 'e2ee.unsupported_version', + version: 3, + } as never)!.payload, + ).toEqual({ userId: 'alice', version: 3, trackType: undefined }); + }); + + it('reshapes perf_report into encode/decode rows', () => { + const mapped = mapNativeEvent({ + ...base, + type: 'e2ee.perf_report', + encode: [ + { + userId: 'alice', + trackType: RTCEncryptionTrackType.SCREEN_SHARE_AUDIO, + codec: 'opus', + fps: 50, + maxCryptoMs: 0.3, + }, + ], + decode: [ + { + userId: 'bob', + trackType: RTCEncryptionTrackType.VIDEO, + fps: 30, + maxCryptoMs: 1.2, + }, + ], + } as never); + expect(mapped).toEqual({ + type: 'e2ee.perf_report', + payload: { + encode: [ + { + userId: 'alice', + trackType: 'SCREEN_SHARE_AUDIO', + codec: 'opus', + fps: 50, + maxCryptoMs: 0.3, + }, + ], + decode: [ + { userId: 'bob', trackType: 'VIDEO', fps: 30, maxCryptoMs: 1.2 }, + ], + }, + }); + // decode rows carry no codec: a remote sender's codec is not known locally + expect(mapped!.payload).not.toHaveProperty('userId'); + expect(Object.keys((mapped!.payload as any).decode[0])).not.toContain( + 'codec', + ); + }); + + it('defaults missing perf rows and codecs', () => { + expect( + mapNativeEvent({ ...base, type: 'e2ee.perf_report' } as never)!.payload, + ).toEqual({ encode: [], decode: [] }); + const mapped = mapNativeEvent({ + ...base, + type: 'e2ee.perf_report', + encode: [ + { + userId: 'alice', + trackType: RTCEncryptionTrackType.AUDIO, + fps: 50, + maxCryptoMs: 0.1, + }, + ], + } as never); + expect((mapped!.payload as any).encode[0].codec).toBe(''); + }); + + it('unwraps key_state, which carries fingerprints only', () => { + const keyState = { + perUserKeys: [ + { userId: 'bob', keyIndex: 1, fingerprint: '0123456789abcdef' }, + ], + sharedKeys: [ + { keyIndex: 0, fingerprint: 'fedcba9876543210', isActive: true }, + ], + }; + const mapped = mapNativeEvent({ + ...base, + type: 'e2ee.key_state', + keyState, + } as never); + expect(mapped).toEqual({ type: 'e2ee.key_state', payload: keyState }); + expect(mapped!.payload).not.toHaveProperty('userId'); + }); + + it('defaults an empty key_state', () => { + expect( + mapNativeEvent({ ...base, type: 'e2ee.key_state' } as never)!.payload, + ).toEqual({ perUserKeys: [], sharedKeys: [] }); + }); + + it('ignores an unrecognized event name', () => { + expect( + mapNativeEvent({ ...base, type: 'e2ee.something_new' } as never), + ).toBeUndefined(); + }); +}); diff --git a/packages/react-native-sdk/__tests__/encryption/legacyWebrtc.test.ts b/packages/react-native-sdk/__tests__/encryption/legacyWebrtc.test.ts new file mode 100644 index 0000000000..d89ec291e2 --- /dev/null +++ b/packages/react-native-sdk/__tests__/encryption/legacyWebrtc.test.ts @@ -0,0 +1,44 @@ +/** + * Regression guard: the SDK's entry point re-exports the encryption module, so + * an app whose `@stream-io/react-native-webrtc` predates E2EE must still be able + * to import it. Reading the native enums at module scope would crash such an app + * on startup even if it never touches E2EE. + * + * Its own file so the stripped mock is the only copy in this module registry. + */ +jest.mock('@stream-io/react-native-webrtc', () => ({ + // what a pre-E2EE version exports: no RTCEncryptionManager, no enums + registerGlobals: () => {}, + MediaStream: undefined, +})); + +describe('with a pre-E2EE @stream-io/react-native-webrtc', () => { + it('imports the encryption module without throwing', () => { + expect(() => + require('../../src/modules/encryption/EncryptionManager'), + ).not.toThrow(); + expect(() => + require('../../src/modules/encryption/eventMapping'), + ).not.toThrow(); + }); + + it('reports E2EE as unsupported instead of crashing', () => { + const { + EncryptionManager, + } = require('../../src/modules/encryption/EncryptionManager'); + expect(EncryptionManager.isSupported()).toBe(false); + }); + + it('rejects create with an actionable message', async () => { + const { + EncryptionManager, + } = require('../../src/modules/encryption/EncryptionManager'); + await expect(EncryptionManager.create('alice')).rejects.toThrow( + /@stream-io\/react-native-webrtc/, + ); + }); + + // The mapping helpers do read the enums, but only ever with a created manager + // in hand - `create` rejects here, so that path is unreachable and needs no + // fallback of its own. +}); diff --git a/packages/react-native-sdk/__tests__/push/ringingJoin.test.ts b/packages/react-native-sdk/__tests__/push/ringingJoin.test.ts new file mode 100644 index 0000000000..a05287237d --- /dev/null +++ b/packages/react-native-sdk/__tests__/push/ringingJoin.test.ts @@ -0,0 +1,275 @@ +import { BehaviorSubject } from 'rxjs'; +import { CallingState } from '@stream-io/video-client'; +import { + beforeJoin, + onJoinFailed, + onLeave, + setRingingCallLifecycleHooks, +} from '../../src/utils/internal/ringingCallLifecycle'; + +/** + * React Native prepares a ringing call's join, because the SDK performs that + * join rather than the app: the accept button joins internally and an outgoing + * call joins itself once the callee answers. These cover the preparation and the + * release that pairs with it, in isolation from core. + */ + +const createFakeCall = (state = CallingState.RINGING) => + ({ + cid: 'default:ringing-test', + ringing: true, + leave: jest.fn().mockResolvedValue(undefined), + state: { + callingState: state, + callingState$: new BehaviorSubject(state), + }, + }) as any; + +const flush = async (times = 8) => { + for (let i = 0; i < times; i++) await Promise.resolve(); +}; + +afterEach(() => { + setRingingCallLifecycleHooks({}); + jest.useRealTimers(); + jest.restoreAllMocks(); +}); + +describe('beforeJoin', () => { + it('resolves immediately when no hook is registered', async () => { + await expect(beforeJoin(createFakeCall())).resolves.toBeUndefined(); + }); + + it('starts the hook synchronously and resolves once it finishes', async () => { + const order: string[] = []; + setRingingCallLifecycleHooks({ + onBeforeCallJoin: jest.fn(async () => { + order.push('hook'); + }), + }); + + const pending = beforeJoin(createFakeCall()); + expect(order).toEqual(['hook']); + await pending; + }); + + it('rejects when the hook rejects', async () => { + setRingingCallLifecycleHooks({ + onBeforeCallJoin: jest.fn().mockRejectedValue(new Error('no key')), + }); + + await expect(beforeJoin(createFakeCall())).rejects.toThrow('no key'); + }); + + it('rejects when the hook throws synchronously', async () => { + setRingingCallLifecycleHooks({ + onBeforeCallJoin: jest.fn(() => { + throw new Error('sync boom'); + }), + }); + + await expect(beforeJoin(createFakeCall())).rejects.toThrow('sync boom'); + }); + + it('gives up on a hook that outruns its deadline', async () => { + jest.useFakeTimers(); + setRingingCallLifecycleHooks({ + onBeforeCallJoin: jest.fn(() => new Promise(() => {})), + }); + + const pending = beforeJoin(createFakeCall()).catch((e: Error) => e.message); + jest.advanceTimersByTime(5_000); + + await expect(pending).resolves.toContain('did not settle within 5000ms'); + }); +}); + +describe('release pairing', () => { + it('releases on leave what the hook installed', async () => { + const onAfterCallLeave = jest.fn(); + setRingingCallLifecycleHooks({ + onBeforeCallJoin: jest.fn().mockResolvedValue(undefined), + onAfterCallLeave, + }); + const call = createFakeCall(); + + await beforeJoin(call); + onLeave(call); + await flush(); + + expect(onAfterCallLeave).toHaveBeenCalledWith(call); + }); + + it('releases even when the hook rejected, because it may have installed something', async () => { + const onAfterCallLeave = jest.fn(); + setRingingCallLifecycleHooks({ + onBeforeCallJoin: jest.fn().mockRejectedValue(new Error('half done')), + onAfterCallLeave, + }); + const call = createFakeCall(); + + await beforeJoin(call).catch(() => {}); + onLeave(call); + await flush(); + + expect(onAfterCallLeave).toHaveBeenCalledTimes(1); + }); + + it('releases exactly once when a failure is followed by a leave', async () => { + const onAfterCallLeave = jest.fn(); + setRingingCallLifecycleHooks({ + onBeforeCallJoin: jest.fn().mockResolvedValue(undefined), + onAfterCallLeave, + }); + // already LEFT, so the failure handler does not leave again + const call = createFakeCall(CallingState.LEFT); + + await beforeJoin(call); + await onJoinFailed(call); + await flush(); + expect(onAfterCallLeave).toHaveBeenCalledTimes(1); + + onLeave(call); + await flush(); + expect(onAfterCallLeave).toHaveBeenCalledTimes(1); + }); + + it('waits for a timed-out hook to settle before releasing', async () => { + jest.useFakeTimers(); + let finishHook: () => void = () => {}; + const onAfterCallLeave = jest.fn(); + setRingingCallLifecycleHooks({ + onBeforeCallJoin: jest.fn( + () => new Promise((resolve) => (finishHook = resolve)), + ), + onAfterCallLeave, + }); + const call = createFakeCall(CallingState.LEFT); + + const timedOut = beforeJoin(call).catch(() => 'timed-out'); + jest.advanceTimersByTime(5_000); + await expect(timedOut).resolves.toBe('timed-out'); + + // the failure handler must not wait on a promise it cannot cancel + await onJoinFailed(call); + expect(onAfterCallLeave).not.toHaveBeenCalled(); + + // ...but whatever the hook installs late is still released + finishHook(); + await flush(); + expect(onAfterCallLeave).toHaveBeenCalledWith(call); + }); + + it('releases nothing for a call whose setup never ran', () => { + const onAfterCallLeave = jest.fn(); + setRingingCallLifecycleHooks({ + onBeforeCallJoin: jest.fn().mockResolvedValue(undefined), + onAfterCallLeave, + }); + + onLeave(createFakeCall()); + + expect(onAfterCallLeave).not.toHaveBeenCalled(); + }); + + it('keeps each call to its own resource', async () => { + const released: string[] = []; + let finishA: () => void = () => {}; + const onBeforeCallJoin = jest + .fn() + .mockImplementationOnce( + () => new Promise((resolve) => (finishA = resolve)), + ) + .mockResolvedValue(undefined); + setRingingCallLifecycleHooks({ + onBeforeCallJoin, + onAfterCallLeave: (call) => { + released.push(call.cid); + }, + }); + const a = createFakeCall(); + a.cid = 'default:a'; + const b = createFakeCall(); + b.cid = 'default:b'; + + // A is abandoned with its hook still running, then B joins and stays + const abandoned = beforeJoin(a); + onLeave(a); + await beforeJoin(b); + + finishA(); + await abandoned; + await flush(); + + expect(released).toEqual(['default:a']); + }); + + it('swallows a synchronous throw from the release hook', async () => { + setRingingCallLifecycleHooks({ + onBeforeCallJoin: jest.fn().mockResolvedValue(undefined), + onAfterCallLeave: jest.fn(() => { + throw new Error('cleanup exploded'); + }), + }); + const call = createFakeCall(); + + await beforeJoin(call); + expect(() => onLeave(call)).not.toThrow(); + await flush(); + }); +}); + +/** + * An app may register only `onAfterCallLeave` - it has nothing to install before + * a join, but still owns something per call that has to be freed. With no setup + * hook to pair with, the release belongs to the call ending, so it must fire for + * every ringing call that ends. + */ +describe('release-only registration', () => { + it('releases a call that joined successfully', async () => { + const onAfterCallLeave = jest.fn(); + setRingingCallLifecycleHooks({ onAfterCallLeave }); + const call = createFakeCall(); + + await beforeJoin(call); + onLeave(call); + + expect(onAfterCallLeave).toHaveBeenCalledTimes(1); + expect(onAfterCallLeave).toHaveBeenCalledWith(call); + }); + + it('releases a call that was declined without ever joining', () => { + const onAfterCallLeave = jest.fn(); + setRingingCallLifecycleHooks({ onAfterCallLeave }); + + onLeave(createFakeCall()); + + expect(onAfterCallLeave).toHaveBeenCalledTimes(1); + }); +}); + +describe('onJoinFailed', () => { + it('ends the flow so the ringing UI cannot offer the failed call again', async () => { + setRingingCallLifecycleHooks({}); + const call = createFakeCall(); + + await onJoinFailed(call); + + expect(call.leave).toHaveBeenCalledWith({ reject: false }); + }); + + it('does not leave a call that has already left', async () => { + const call = createFakeCall(CallingState.LEFT); + + await onJoinFailed(call); + + expect(call.leave).not.toHaveBeenCalled(); + }); + + it('never rejects, so the join error survives a failing leave', async () => { + const call = createFakeCall(); + call.leave.mockRejectedValue(new Error('leave blew up')); + + await expect(onJoinFailed(call)).resolves.toBeUndefined(); + }); +}); diff --git a/packages/react-native-sdk/__tests__/push/ringingJoinIntegration.test.ts b/packages/react-native-sdk/__tests__/push/ringingJoinIntegration.test.ts new file mode 100644 index 0000000000..c42bc08ec1 --- /dev/null +++ b/packages/react-native-sdk/__tests__/push/ringingJoinIntegration.test.ts @@ -0,0 +1,291 @@ +import { Call, CallingState, StreamVideoClient } from '@stream-io/video-client'; +import { + beforeJoin, + onJoinFailed, + onLeave, + setRingingCallLifecycleHooks, +} from '../../src/utils/internal/ringingCallLifecycle'; + +/** + * Drives a real `Call` against the real RN preparation module through the globals + * bridge. The unit suites either stub the owner inside core or call the module + * with a fake call; between them they miss the dispatch, the ordering against + * native registration and media setup, and what a failed join does to the call. + * + * One `Call` is one call flow here, as the public contract says: nothing below + * rejoins an instance it has left. + */ + +const endCall = jest.fn().mockResolvedValue(undefined); +const joinCall = jest.fn().mockResolvedValue(undefined); + +type Kind = 'incoming' | 'outgoing' | 'ring-option'; + +const createCall = (kind: Kind = 'ring-option') => { + const client = new StreamVideoClient({ + apiKey: 'abc', + // no network from these fixtures: the reporter would otherwise post call + // telemetry after the test has finished and fail the run on a late log + options: { clientEventsReportingEnabled: false, logLevel: 'error' }, + }); + const call = + kind === 'ring-option' + ? client.call('test', `int-${Math.random().toString(36).slice(2)}`) + : // an incoming or outgoing ringing call is built ringing by the SDK, well + // before anything calls `join()` on it + new Call({ + type: 'test', + id: `int-${Math.random().toString(36).slice(2)}`, + ringing: true, + streamClient: client.streamClient, + clientEventReporter: client['clientEventReporter'], + clientState: client.state, + }); + if (kind === 'outgoing') { + jest.spyOn(call, 'isCreatedByMe', 'get').mockReturnValue(true); + } + // stop short of the network; the join flow up to that point is the subject + jest.spyOn(client.streamClient, 'post').mockResolvedValue({ duration: '0' }); + jest.spyOn(call as any, 'setup').mockResolvedValue(undefined); + jest.spyOn(call as any, 'doJoin').mockImplementation(async () => { + // a real join reaches JOINED, which is what refuses a later duplicate + call.state.setCallingState(CallingState.JOINED); + }); + return call; +}; + +const joinOptions = (kind: Kind) => + kind === 'ring-option' ? { ring: true } : {}; + +const tick = () => new Promise((r) => setImmediate(r)); +const flush = async (times = 8) => { + for (let i = 0; i < times; i++) await Promise.resolve(); +}; + +beforeEach(() => { + jest.clearAllMocks(); + (globalThis as any).streamRNVideoSDK = { + ringingCallLifecycle: { beforeJoin, onJoinFailed, onLeave }, + callingX: { joinCall, endCall }, + callManager: { setup: jest.fn(), start: jest.fn(), stop: jest.fn() }, + }; +}); + +afterEach(() => { + setRingingCallLifecycleHooks({}); + (globalThis as any).streamRNVideoSDK = undefined; + // the timeout test installs fake timers, which also fake `setImmediate` + jest.useRealTimers(); +}); + +describe('a fresh ringing join', () => { + it.each(['incoming', 'outgoing', 'ring-option'])( + 'runs setup before native registration and media setup (%s)', + async (kind) => { + const order: string[] = []; + setRingingCallLifecycleHooks({ + onBeforeCallJoin: jest.fn(async () => { + order.push('setup'); + }), + }); + joinCall.mockImplementation(async () => { + order.push('native'); + }); + const call = createCall(kind); + jest + .spyOn(call as any, 'setup') + .mockImplementation(async () => void order.push('call-setup')); + + await call.join(joinOptions(kind)); + + expect(order).toEqual(['setup', 'native', 'call-setup']); + }, + ); + + it('shares one preparation and one join between concurrent accepts', async () => { + let finishHook: () => void = () => {}; + const onBeforeCallJoin = jest.fn( + () => new Promise((resolve) => (finishHook = resolve)), + ); + setRingingCallLifecycleHooks({ onBeforeCallJoin }); + const call = createCall('incoming'); + + // a push acceptance racing an in-app tap + const first = call.join(); + const second = call.join(); + finishHook(); + await Promise.all([first, second]); + + expect(onBeforeCallJoin).toHaveBeenCalledTimes(1); + expect(joinCall).toHaveBeenCalledTimes(1); + expect((call as any).doJoin).toHaveBeenCalledTimes(1); + }); + + it('refuses a duplicate after success without releasing the live call', async () => { + const onBeforeCallJoin = jest.fn().mockResolvedValue(undefined); + const onAfterCallLeave = jest.fn(); + setRingingCallLifecycleHooks({ onBeforeCallJoin, onAfterCallLeave }); + const call = createCall('incoming'); + + await call.join(); + await expect(call.join()).rejects.toThrow('Illegal State'); + + expect(onBeforeCallJoin).toHaveBeenCalledTimes(1); + // the live call still owns its manager + expect(onAfterCallLeave).not.toHaveBeenCalled(); + + await call.leave(); + expect(onAfterCallLeave).toHaveBeenCalledTimes(1); + }); +}); + +describe('a ringing join whose setup fails', () => { + it.each([ + ['rejects', () => jest.fn().mockRejectedValue(new Error('no key'))], + [ + 'throws synchronously', + () => + jest.fn(() => { + throw new Error('no key'); + }), + ], + ])('fails the join and ends the flow when the hook %s', async (_, hook) => { + setRingingCallLifecycleHooks({ onBeforeCallJoin: hook() as any }); + const call = createCall('incoming'); + + await expect(call.join()).rejects.toThrow('no key'); + + expect((call as any).doJoin).not.toHaveBeenCalled(); + expect(joinCall).not.toHaveBeenCalled(); + // ended, so the ringing UI has nothing left to accept + expect(call.state.callingState).toBe(CallingState.LEFT); + }); + + it('ends the already reported native call', async () => { + setRingingCallLifecycleHooks({ + onBeforeCallJoin: jest.fn().mockRejectedValue(new Error('no key')), + }); + const call = createCall('incoming'); + + await call.join().catch(() => {}); + + // core never registered it - the push path reported the accept already + expect(endCall).toHaveBeenCalledWith(call, 'error'); + }); + + it('ends the flow on a timeout, and releases the hook only once it settles', async () => { + jest.useFakeTimers(); + let finishHook: () => void = () => {}; + const onAfterCallLeave = jest.fn(); + setRingingCallLifecycleHooks({ + onBeforeCallJoin: jest.fn( + () => new Promise((resolve) => (finishHook = resolve)), + ), + onAfterCallLeave, + }); + const call = createCall('incoming'); + + const joining = call.join().catch((e: Error) => e.message); + await jest.advanceTimersByTimeAsync(5_000); + + await expect(joining).resolves.toContain('did not settle within'); + expect(call.state.callingState).toBe(CallingState.LEFT); + // the app's hook cannot be cancelled, so nothing waited for it + expect(onAfterCallLeave).not.toHaveBeenCalled(); + + finishHook(); + await flush(); + expect(onAfterCallLeave).toHaveBeenCalledTimes(1); + }); + + it('releases exactly once across the failure and the leave it performs', async () => { + const onAfterCallLeave = jest.fn(); + setRingingCallLifecycleHooks({ + onBeforeCallJoin: jest.fn().mockRejectedValue(new Error('no key')), + onAfterCallLeave, + }); + const call = createCall('incoming'); + + await call.join().catch(() => {}); + await flush(); + + expect(onAfterCallLeave).toHaveBeenCalledTimes(1); + }); +}); + +describe('a ringing join overtaken by leave', () => { + it('never reaches media setup, and releases the late setup once', async () => { + let finishHook: () => void = () => {}; + const onAfterCallLeave = jest.fn(); + setRingingCallLifecycleHooks({ + onBeforeCallJoin: jest.fn( + () => new Promise((resolve) => (finishHook = resolve)), + ), + onAfterCallLeave, + }); + const call = createCall('incoming'); + + const joining = call.join(); + await tick(); + await call.leave({ reject: false }); + + finishHook(); + // A leave that overtakes the join settles it quietly rather than rejecting; + // the release below is what proves the late setup was not stranded. + await expect(joining).resolves.toBeUndefined(); + await flush(); + + expect((call as any).doJoin).not.toHaveBeenCalled(); + expect(onAfterCallLeave).toHaveBeenCalledTimes(1); + }); +}); + +describe('release-only registration', () => { + it('releases a joined call on leave, and a declined one too', async () => { + const onAfterCallLeave = jest.fn(); + setRingingCallLifecycleHooks({ onAfterCallLeave }); + + const joined = createCall('incoming'); + await joined.join(); + expect(onAfterCallLeave).not.toHaveBeenCalled(); + await joined.leave(); + expect(onAfterCallLeave).toHaveBeenCalledTimes(1); + + const declined = createCall('incoming'); + await declined.leave({ reject: true }); + expect(onAfterCallLeave).toHaveBeenCalledTimes(2); + }); +}); + +describe('an ordinary call', () => { + it('runs no ringing hooks and joins through the core path', async () => { + const onBeforeCallJoin = jest.fn().mockResolvedValue(undefined); + const onAfterCallLeave = jest.fn(); + setRingingCallLifecycleHooks({ onBeforeCallJoin, onAfterCallLeave }); + const call = createCall('ring-option'); + + await call.join(); + await call.leave(); + + expect(onBeforeCallJoin).not.toHaveBeenCalled(); + expect(onAfterCallLeave).not.toHaveBeenCalled(); + expect((call as any).doJoin).toHaveBeenCalledTimes(1); + }); + + it('is not ended by the ringing failure path when its join fails', async () => { + setRingingCallLifecycleHooks({ + onBeforeCallJoin: jest.fn().mockResolvedValue(undefined), + }); + const call = createCall('ring-option'); + jest + .spyOn(call as any, 'doJoin') + .mockRejectedValue(new Error('sfu unavailable')); + + await expect(call.join({ maxJoinRetries: 1 })).rejects.toThrow( + 'sfu unavailable', + ); + + // upstream behaviour: the app owns an ordinary call's teardown + expect(call.state.callingState).not.toBe(CallingState.LEFT); + }); +}); diff --git a/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/audio/AudioDeviceManager.kt b/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/audio/AudioDeviceManager.kt index c4a8d67fa8..c9081f4c51 100644 --- a/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/audio/AudioDeviceManager.kt +++ b/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/audio/AudioDeviceManager.kt @@ -107,6 +107,30 @@ class AudioDeviceManager( */ var telecomManagedMode: Boolean = false + /** + * Opt-out for the Android 11+ communication-mode keep-alive. Sticky developer preference — + * intentionally NOT reset in [stop], unlike the per-call fields above. + */ + var disableCommunicationModeWorkaround: Boolean = false + + /** + * True once [stop]/[close] have torn routing down, until the next [start]. + * + * [updateAudioDeviceState] is enqueued from off-thread sources (Bluetooth receivers, + * audio-focus and device callbacks), so a task can still land after teardown. Re-routing + * then would re-assert MODE_IN_COMMUNICATION via AudioManagerUtil, undoing the + * MODE_NORMAL restore in [stop]. Audio-thread confined. + */ + private var routingStopped = false + + /** Keeps MODE_IN_COMMUNICATION owned for the whole Communicator call on Android 11+. */ + private val communicationModeKeepAlive: CommunicationModeKeepAlive = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + SilentPlaybackKeepAlive(mReactContext) + } else { + NoCommunicationModeKeepAlive + } + val bluetoothManager = BluetoothManager(mReactContext, this) private val proximityManager by lazy { ProximityManager(mReactContext, this) } @@ -130,53 +154,64 @@ class AudioDeviceManager( audioFocusUtil.setup(callAudioRole, mReactContext) } + /** Runs inline; callers must already be on the audio thread (see [runInAudioThread]). */ fun start(activity: Activity) { - runInAudioThread { - setup() - selectedAudioDeviceEndpoint = null - if (callAudioRole == CallAudioRole.Communicator) { - // Audio routing is manually controlled by the SDK in communication media mode - // and local microphone can be published - activity.volumeControlStream = AudioManager.STREAM_VOICE_CALL - if (!telecomManagedMode) { - // Telecom owns routing/focus; only run our own routing when not Telecom-managed. - bluetoothManager.start() - mAudioManager.registerAudioDeviceCallback(this, null) - updateAudioDeviceState() - } - proximityManager.start() - } else { - activity.volumeControlStream = AudioManager.USE_DEFAULT_STREAM_TYPE - } + routingStopped = false + setup() + selectedAudioDeviceEndpoint = null + if (callAudioRole == CallAudioRole.Communicator) { + // Audio routing is manually controlled by the SDK in communication media mode + // and local microphone can be published + activity.volumeControlStream = AudioManager.STREAM_VOICE_CALL if (!telecomManagedMode) { - audioFocusUtil.requestFocus(callAudioRole, mReactContext) + // Telecom owns routing/focus; only run our own routing when not Telecom-managed. + bluetoothManager.start() + mAudioManager.registerAudioDeviceCallback(this, null) + updateAudioDeviceState() } + proximityManager.start() + } else { + activity.volumeControlStream = AudioManager.USE_DEFAULT_STREAM_TYPE + } + if (!telecomManagedMode) { + audioFocusUtil.requestFocus(callAudioRole, mReactContext) + } + // Started last, after focus/routing, so the silent track uses the correct route. + if (callAudioRole == CallAudioRole.Communicator && + !telecomManagedMode && + !disableCommunicationModeWorkaround + ) { + communicationModeKeepAlive.start() } } - fun stop(activity: Activity) { - runInAudioThread { - if (callAudioRole == CallAudioRole.Communicator) { - if (!telecomManagedMode) { - // Only tear down what we set up ourselves; Telecom owns its own teardown. - if (Build.VERSION.SDK_INT >= 31) { - mAudioManager.clearCommunicationDevice() - } else { - mAudioManager.setSpeakerphoneOn(false) - } - bluetoothManager.stop() - } - callAudioRole = CallAudioRole.Communicator - enableStereo = false - defaultAudioDevice = AudioDeviceEndpoint.TYPE_SPEAKER - proximityManager.stop() - } - activity.volumeControlStream = AudioManager.USE_DEFAULT_STREAM_TYPE + /** Runs inline; callers must already be on the audio thread (see [runInAudioThread]). */ + fun stop(activity: Activity?) { + routingStopped = true + communicationModeKeepAlive.stop() + if (callAudioRole == CallAudioRole.Communicator) { if (!telecomManagedMode) { - audioFocusUtil.abandonFocus() + // Only tear down what we set up ourselves; Telecom owns its own teardown. + if (Build.VERSION.SDK_INT >= 31) { + mAudioManager.clearCommunicationDevice() + } else { + mAudioManager.setSpeakerphoneOn(false) + } + bluetoothManager.stop() + // Restore the mode set in setup(); it was previously left at + // MODE_IN_COMMUNICATION, holding the device in in-call routing. + mAudioManager.mode = AudioManager.MODE_NORMAL } - telecomManagedMode = false + callAudioRole = CallAudioRole.Communicator + enableStereo = false + defaultAudioDevice = AudioDeviceEndpoint.TYPE_SPEAKER + proximityManager.stop() } + activity?.volumeControlStream = AudioManager.USE_DEFAULT_STREAM_TYPE + if (!telecomManagedMode) { + audioFocusUtil.abandonFocus() + } + telecomManagedMode = false } fun setMicrophoneMute(enable: Boolean) { @@ -228,6 +263,9 @@ class AudioDeviceManager( } private fun switchDeviceEndpointType(@EndpointType deviceType: Int) { + // Sole sink for routing changes, and AudioManagerUtil re-asserts MODE_IN_COMMUNICATION + // here — so late work of any origin must not reach it after teardown. + if (routingStopped) return val newDevice = AudioManagerUtil.switchDeviceEndpointType( deviceType, mEndpointMaps, @@ -237,38 +275,49 @@ class AudioDeviceManager( this.selectedAudioDeviceEndpoint = newDevice } + /** Runs inline; callers must already be on the audio thread (see [runInAudioThread]). */ fun switchDeviceById( deviceId: String ) { Log.d(TAG, "switchDeviceById: deviceId = $deviceId") - runInAudioThread { - val btDevice = mEndpointMaps.bluetoothEndpoints[deviceId] - if (btDevice != null) { - if (Build.VERSION.SDK_INT >= 31) { - mAudioManager.setCommunicationDevice(btDevice.deviceInfo) - bluetoothManager.updateDevice() - this.selectedAudioDeviceEndpoint = btDevice - } else { - switchDeviceEndpointType( - AudioDeviceEndpoint.TYPE_BLUETOOTH - ) - } + // Guarded here too: the branch below sets the communication device directly rather + // than going through switchDeviceEndpointType(). + if (routingStopped) return + val btDevice = mEndpointMaps.bluetoothEndpoints[deviceId] + if (btDevice != null) { + if (Build.VERSION.SDK_INT >= 31) { + mAudioManager.setCommunicationDevice(btDevice.deviceInfo) + bluetoothManager.updateDevice() + this.selectedAudioDeviceEndpoint = btDevice } else { - val endpoint = nonBluetoothEndpointById(deviceId) - if (endpoint != null) { - switchDeviceEndpointType(endpoint.type) - } else { - Log.e(TAG, "switchDeviceById: no endpoint found for id $deviceId") - } + switchDeviceEndpointType( + AudioDeviceEndpoint.TYPE_BLUETOOTH + ) + } + } else { + val endpoint = nonBluetoothEndpointById(deviceId) + if (endpoint != null) { + switchDeviceEndpointType(endpoint.type) + } else { + Log.e(TAG, "switchDeviceById: no endpoint found for id $deviceId") } } } override fun close() { - mAudioManager.unregisterAudioDeviceCallback(this) - proximityManager.onDestroy() + // Unlike start()/stop(), this is called straight from the module, so it must be queued + // to land after any start/stop already pending on the audio thread. + runInAudioThread { + routingStopped = true + communicationModeKeepAlive.release() + mAudioManager.unregisterAudioDeviceCallback(this) + proximityManager.onDestroy() + } } + /** Short description of the keep-alive state, for the audio debug log. */ + fun communicationModeKeepAliveState(): String = communicationModeKeepAlive.describeState() + override fun onAudioDevicesAdded(addedDevices: Array?) { if (addedDevices != null) { runInAudioThread { @@ -423,6 +472,9 @@ class AudioDeviceManager( */ fun updateAudioDeviceState() { runInAudioThread { + // Bail early: on API < 31 bluetoothState survives stop(), so a late pass could + // still start SCO after the call ended. + if (routingStopped) return@runInAudioThread if (telecomManagedMode) { // Telecom is the single source of truth for routing/endpoints in this mode. return@runInAudioThread diff --git a/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/audio/CommunicationModeKeepAlive.kt b/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/audio/CommunicationModeKeepAlive.kt new file mode 100644 index 0000000000..431651f33f --- /dev/null +++ b/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/audio/CommunicationModeKeepAlive.kt @@ -0,0 +1,247 @@ +package com.streamvideo.reactnative.audio + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Context.AUDIO_SERVICE +import android.media.AudioAttributes +import android.media.AudioFormat +import android.media.AudioManager +import android.media.AudioTrack +import android.os.Build +import android.util.Log +import androidx.annotation.RequiresApi +import com.streamvideo.reactnative.callmanager.StreamInCallManagerModule +import java.nio.ByteBuffer +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.TimeUnit + +/** + * Holds the audio system in [AudioManager.MODE_IN_COMMUNICATION] for the duration + * of a communicator-role call. + * + * Why this is needed: from Android 11 (API 30) the platform demotes the + * audio mode back to `MODE_NORMAL` about six seconds after `setMode()` unless the + * call is actively playing or recording on the voice-communication path. + * During a call whose microphone is muted and that has no inbound audio yet, + * neither condition is met, so routing silently falls back to the media path and + * hardware echo cancellation is switched off. + * Details: https://issuetracker.google.com/issues/209493718 + * + * We satisfy the "actively playing" condition by continuously looping an inaudible + * audo track on the voice-communication stream, which keeps the platform counting us + * as an active player and therefore keeps the mode in place. + */ +interface CommunicationModeKeepAlive { + /** Begin holding the communication mode. Idempotent. */ + fun start() + + /** Stop holding the communication mode. Idempotent; the instance can be started again. */ + fun stop() + + /** Permanently release all resources. The instance must not be used afterwards. */ + fun release() + + /** Short human-readable state, surfaced in the audio-state debug log. */ + fun describeState(): String +} + +/** No-op variant used on platforms where the mode reset does not occur (Android API < 30). */ +object NoCommunicationModeKeepAlive : CommunicationModeKeepAlive { + override fun start() {} + override fun stop() {} + override fun release() {} + override fun describeState(): String = "disabled (android platform API < 30)" +} + +/** + * Default implementation backed by a silent, looping voice-communication + * [AudioTrack]. If the silent player cannot be created on a given device, it + * degrades to a lightweight poller that re-applies the mode whenever the platform + * has reset it. + * + * All lifecycle transitions and every access to [track] are serialized on [gate], + * so a play/pause can never overlap a release. The poller only ever touches the + * audio mode (never the track), so it runs lock-free. + * + * @suppress + */ +@RequiresApi(Build.VERSION_CODES.R) +internal class SilentPlaybackKeepAlive( + private val context: Context, +) : CommunicationModeKeepAlive { + + private val gate = Any() + + private var track: AudioTrack? = null + + /** Whether we currently intend the silent player to be running. */ + private var engaged = false + + /** Read by the poller (off-gate) to drop ticks that were queued before we stopped. */ + @Volatile + private var live = false + + private var modePoller: ScheduledExecutorService? = null + + override fun start(): Unit = synchronized(gate) { + live = true + if (engaged) return + engaged = true + + val player = track ?: createSilentTrack()?.also { track = it } + if (player != null && player.state == AudioTrack.STATE_INITIALIZED && play(player)) { + return + } + // No usable silent player on this device — fall back to reactive repair. + player?.release() + track = null + startModePoller() + } + + /** + * Starts playback, reporting whether the track actually entered the playing state. + * `play()` can throw or leave the track stopped even from STATE_INITIALIZED; treating + * that as success would strand the keep-alive with neither a track nor the poller. + */ + private fun play(player: AudioTrack): Boolean = + try { + player.play() + player.playState == AudioTrack.PLAYSTATE_PLAYING + } catch (e: IllegalStateException) { + Log.w(TAG, "Silent keep-alive track refused to start.", e) + false + } + + override fun stop() = synchronized(gate) { + live = false + if (engaged) { + engaged = false + track?.takeIf { it.state == AudioTrack.STATE_INITIALIZED }?.pause() + } + stopModePoller() + } + + override fun release() = synchronized(gate) { + live = false + engaged = false + stopModePoller() + track?.let { + if (it.state == AudioTrack.STATE_INITIALIZED) it.pause() + it.release() + } + track = null + } + + override fun describeState(): String = synchronized(gate) { + val playing = track?.playState == AudioTrack.PLAYSTATE_PLAYING + "enabled, engaged=$engaged, built=${track != null}, playing=$playing, " + + "modePoller=${modePoller != null}" + } + + @SuppressLint("Range") + private fun createSilentTrack(): AudioTrack? { + return try { + val audioManager = context.getSystemService(AUDIO_SERVICE) as AudioManager + val sampleRate = positiveOrDefault( + audioManager.getProperty(AudioManager.PROPERTY_OUTPUT_SAMPLE_RATE)?.toIntOrNull(), + DEFAULT_SAMPLE_RATE, + ) + // One buffer worth of frames is enough — we loop it forever. + val frameCount = positiveOrDefault( + audioManager.getProperty(AudioManager.PROPERTY_OUTPUT_FRAMES_PER_BUFFER) + ?.toIntOrNull(), + sampleRate / 100, // ~10 ms + ) + val bufferBytes = frameCount * BYTES_PER_FRAME + // A freshly allocated direct buffer is zero-filled, i.e. pure silence. + val silence = ByteBuffer.allocateDirect(bufferBytes) + + val player = AudioTrack.Builder() + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build(), + ) + .setAudioFormat( + AudioFormat.Builder() + .setEncoding(ENCODING) + .setSampleRate(sampleRate) + .setChannelMask(AudioFormat.CHANNEL_OUT_MONO) + .build(), + ) + .setBufferSizeInBytes(bufferBytes) + .setTransferMode(AudioTrack.MODE_STATIC) + .setSessionId(AudioManager.AUDIO_SESSION_ID_GENERATE) + .build() + + val written = player.write(silence, silence.remaining(), AudioTrack.WRITE_BLOCKING) + val loopResult = player.setLoopPoints(0, frameCount - 1, -1) + val ready = player.state == AudioTrack.STATE_INITIALIZED && + written >= 0 && + loopResult == AudioTrack.SUCCESS + if (ready) { + player + } else { + Log.w( + TAG, + "Silent keep-alive track not usable " + + "(state=${player.state}, written=$written, loop=$loopResult); discarding.", + ) + player.release() + null + } + } catch (e: Exception) { + Log.w(TAG, "Could not create silent keep-alive track.", e) + null + } + } + + private fun startModePoller() { + if (modePoller != null) return + modePoller = Executors.newSingleThreadScheduledExecutor().also { poller -> + poller.scheduleWithFixedDelay( + ::reapplyModeIfReset, + MODE_POLL_INTERVAL_MS, + MODE_POLL_INTERVAL_MS, + TimeUnit.MILLISECONDS, + ) + } + } + + private fun reapplyModeIfReset() { + // Read/write the mode on the shared audio thread so it can't race routing changes. + AudioDeviceManager.runInAudioThread { + if (!live) return@runInAudioThread + val audioManager = context.getSystemService(AUDIO_SERVICE) as AudioManager + if (audioManager.mode != AudioManager.MODE_IN_COMMUNICATION) { + Log.d(TAG, "Re-applying MODE_IN_COMMUNICATION after a platform reset.") + audioManager.mode = AudioManager.MODE_IN_COMMUNICATION + } + } + } + + private fun stopModePoller() { + modePoller?.shutdownNow() + modePoller = null + } + + private fun positiveOrDefault(value: Int?, default: Int): Int = + if (value != null && value > 0) value else default + + companion object { + private const val TAG = StreamInCallManagerModule.TAG + private const val DEFAULT_SAMPLE_RATE = 16000 + private const val ENCODING = AudioFormat.ENCODING_PCM_16BIT + + // We always emit mono 16-bit PCM, so a frame is exactly 2 bytes. + private const val BYTES_PER_FRAME = 2 + + // Fallback poll cadence. Deliberately shorter than AOSP's ~6s reset window: the + // interval bounds only the worst-case wrong-routing gap (<= interval), while the + // real setMode/HAL transition rate stays governed by the OS re-arm, so polling + // faster only costs cheap reads and shortens the recovery latency. + private const val MODE_POLL_INTERVAL_MS = 2000L + } +} diff --git a/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/callmanager/StreamInCallManagerModule.kt b/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/callmanager/StreamInCallManagerModule.kt index 0c9c669e2a..d4a11af62c 100644 --- a/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/callmanager/StreamInCallManagerModule.kt +++ b/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/callmanager/StreamInCallManagerModule.kt @@ -75,6 +75,18 @@ class StreamInCallManagerModule(reactContext: ReactApplicationContext) : } } + @ReactMethod + fun setDisableCommunicationModeWorkaround(disabled: Boolean) { + AudioDeviceManager.runInAudioThread { + if (audioManagerActivated) { + Log.e(TAG, "setDisableCommunicationModeWorkaround(): AudioManager is already activated and so it cannot be changed") + return@runInAudioThread + } + Log.d(TAG, "setDisableCommunicationModeWorkaround(): $disabled") + mAudioDeviceManager.disableCommunicationModeWorkaround = disabled + } + } + @ReactMethod fun setDefaultAudioDeviceEndpointType(endpointDeviceTypeName: String) { AudioDeviceManager.runInAudioThread { @@ -129,10 +141,8 @@ class StreamInCallManagerModule(reactContext: ReactApplicationContext) : AudioDeviceManager.runInAudioThread { if (audioManagerActivated) { Log.d(TAG, "stop() mAudioDeviceManager") - reactApplicationContext.currentActivity?.let { - mAudioDeviceManager.stop(it) - audioManagerActivated = false - } + mAudioDeviceManager.stop(reactApplicationContext.currentActivity) + audioManagerActivated = false setMicrophoneMute(false) setKeepScreenOn(false) } @@ -185,7 +195,8 @@ class StreamInCallManagerModule(reactContext: ReactApplicationContext) : @ReactMethod(isBlockingSynchronousMethod = true) fun getAudioStateLog(): String { - return WebRtcAudioUtils.getAudioStateLog(reactApplicationContext) + return WebRtcAudioUtils.getAudioStateLog(reactApplicationContext) + + "Communication mode keep-alive: ${mAudioDeviceManager.communicationModeKeepAliveState()}\n" } @Suppress("unused") diff --git a/packages/react-native-sdk/package.json b/packages/react-native-sdk/package.json index 24c6b7da81..16114713a6 100644 --- a/packages/react-native-sdk/package.json +++ b/packages/react-native-sdk/package.json @@ -69,7 +69,7 @@ "@react-native-firebase/messaging": ">=17.5.0", "@stream-io/noise-cancellation-react-native": ">=1.0.0-beta.0", "@stream-io/react-native-callingx": ">=1.0.0-beta.0", - "@stream-io/react-native-webrtc": "^145.3.1", + "@stream-io/react-native-webrtc": "^145.3.1 || >=145.4.0-alpha.1", "@stream-io/video-filters-react-native": ">=1.0.0-beta.0", "expo": ">=47.0.0", "expo-notifications": "*", @@ -123,7 +123,7 @@ "@react-native/metro-config": "0.86.2", "@stream-io/noise-cancellation-react-native": "workspace:^", "@stream-io/react-native-callingx": "workspace:^", - "@stream-io/react-native-webrtc": "145.3.1", + "@stream-io/react-native-webrtc": "145.4.0-alpha.1", "@stream-io/typescript-config": "workspace:^", "@stream-io/video-filters-react-native": "workspace:^", "@testing-library/jest-native": "^5.4.3", diff --git a/packages/react-native-sdk/src/index.ts b/packages/react-native-sdk/src/index.ts index e8fa606fd0..97dd877c8b 100644 --- a/packages/react-native-sdk/src/index.ts +++ b/packages/react-native-sdk/src/index.ts @@ -36,6 +36,11 @@ export * from './modules/call-manager'; // Explicitly re-exporting to resolve ambiguity. export { StreamVideo } from './providers/StreamVideo'; export { StreamCall } from './providers/StreamCall'; +// Overriding 'EncryptionManager' from '@stream-io/video-client' with the React +// Native implementation, which is backed by the native WebRTC transform. React +// Native Web is not supported: `isSupported()` returns false there rather than +// falling back to the web manager. +export { EncryptionManager } from './modules/encryption'; export * from './providers/NoiseCancellation'; setClientDetails(); diff --git a/packages/react-native-sdk/src/modules/call-manager/native-module.d.ts b/packages/react-native-sdk/src/modules/call-manager/native-module.d.ts index 4881e7ff58..029639710a 100644 --- a/packages/react-native-sdk/src/modules/call-manager/native-module.d.ts +++ b/packages/react-native-sdk/src/modules/call-manager/native-module.d.ts @@ -32,6 +32,20 @@ export interface CallManager extends NativeModule { */ setTelecomManagedMode: (enabled: boolean) => void; + /** + * Opt out of the Android 11+ communication-mode keep-alive workaround. + * + * On Android 11+ (API 30) the OS resets `MODE_IN_COMMUNICATION` ~6s after it is + * set when there is no active voice-communication playback/recording, which + * breaks audio routing and AEC. By default the SDK plays a silent + * voice-communication track for the duration of a communicator-role call to + * prevent this. Pass `true` to disable it. Sticky for the process lifetime. + * + * No-op on iOS and on Android below API 30. See + * https://issuetracker.google.com/issues/209493718 + */ + setDisableCommunicationModeWorkaround: (disabled: boolean) => void; + /** * Choose an audio device endpoint by its stable id. * @param deviceId - The id of the audio device to choose (`AudioDevice.id`). diff --git a/packages/react-native-sdk/src/modules/encryption/EncryptionManager.ts b/packages/react-native-sdk/src/modules/encryption/EncryptionManager.ts new file mode 100644 index 0000000000..6cd1161be5 --- /dev/null +++ b/packages/react-native-sdk/src/modules/encryption/EncryptionManager.ts @@ -0,0 +1,360 @@ +import { + RTCEncryptionManager, + type RTCEncryptionEventData, + type RTCEncryptionEventType, +} from '@stream-io/react-native-webrtc'; +import { + TypedEventEmitter, + videoLoggerSystem, + type E2EEAlgorithm, + type E2EEEventMap, + type E2EEManager, + type EncryptionManagerOptions, + type Listener, + type ScopedLogger, +} from '@stream-io/video-client'; +import { + algorithmToNative, + E2EE_EVENT_TYPES, + mapNativeEvent, + trackTypeToNative, +} from './eventMapping'; + +/** + * Distributes keys to the native encryption manager and attaches + * encrypt/decrypt transforms to RTCRtpSenders and RTCRtpReceivers. + * + * The public surface matches the web + * {@link https://github.com/GetStream/stream-video-js/blob/main/packages/client/src/rtc/e2ee/EncryptionManager.ts | EncryptionManager} + * method for method, so host code is portable between the two SDKs. + * + * The crypto itself lives in the WebRTC binary, not here: frames never cross + * the React Native bridge. This class carries key and attach commands over it, + * and diagnostic events back. + * + * Like the keys it installs, the manager is application-owned: create one per + * call, attach it before {@link https://github.com/GetStream/stream-video-js/blob/main/packages/client/src/Call.ts | Call}`.join()`, + * and {@link EncryptionManager.dispose | dispose} it when that call is done. + * Nothing in the SDK will do it for you, and on React Native the leak is native + * rather than collectable. + * + * @example + * ```ts + * if (EncryptionManager.isSupported()) { + * const e2ee = await EncryptionManager.create(call.currentUserId); + * e2ee.setSharedKey(0, keyBytes); + * call.setE2EEManager(e2ee); // must happen before call.join() + * } + * ``` + */ +export class EncryptionManager implements E2EEManager { + private readonly algorithm: E2EEAlgorithm; + private readonly native: RTCEncryptionManager; + private readonly events: TypedEventEmitter; + private readonly logger: ScopedLogger; + private readonly bridged: Array< + [RTCEncryptionEventType, (data: RTCEncryptionEventData) => void] + > = []; + private disposed = false; + + private constructor(native: RTCEncryptionManager, algorithm: E2EEAlgorithm) { + this.native = native; + this.algorithm = algorithm; + this.logger = videoLoggerSystem.getLogger('EncryptionManager'); + this.events = new TypedEventEmitter(this.logger); + // Native dispatches per event name, so the one handler is registered under + // each of them, and each registration has to be undone on dispose. + for (const type of E2EE_EVENT_TYPES) { + this.native.on(type, this.handleNativeEvent); + this.bridged.push([type, this.handleNativeEvent]); + } + } + + private handleNativeEvent = (data: RTCEncryptionEventData) => { + const event = mapNativeEvent(data); + if (!event) return; + this.events.emit(event.type, event.payload); + }; + + /** + * Whether E2EE can run here. Use it to guard UI, or to avoid calling + * {@link create} where it would throw. + * + * `false` means the installed `@stream-io/react-native-webrtc` predates E2EE + * support. + */ + static isSupported = (): boolean => + RTCEncryptionManager?.isSupported() ?? false; + + /** + * Create an EncryptionManager and initialize the underlying native manager. + * + * Attach it with `call.setE2EEManager()` **before** `call.join()`: the join + * request carries the E2EE flag, and the peer connections are built with the + * transforms in place. + * + * Resolves rather than returning directly because every Stream SDK exposes + * this as a promise; nothing here is actually asynchronous, since a blocking + * native create is what keeps a sender from ever existing unencrypted. + * + * @param userId - The local user's ID, normally `call.currentUserId`. + * @param options - the create options. + * @throws {Error} If E2EE is unavailable. It never degrades to plaintext. + */ + static create = async ( + userId: string, + options?: EncryptionManagerOptions, + ): Promise => { + if (!RTCEncryptionManager) { + throw new Error( + 'E2EE requires a version of @stream-io/react-native-webrtc that supports RTCEncryptionManager', + ); + } + if (!RTCEncryptionManager.isSupported()) { + throw new Error('E2EE is not supported on this device'); + } + const algorithm = options?.algorithm ?? 'AES-128-GCM'; + const native = RTCEncryptionManager.create(userId, { + algorithm: algorithmToNative(algorithm), + }); + return new EncryptionManager(native, algorithm); + }; + + /** + * Subscribe to an E2EE event. + * + * @returns a function that unsubscribes the listener. + */ + on = ( + event: E, + fn: Listener, + ): (() => void) => this.events.on(event, fn); + + /** Unsubscribe a listener registered with {@link on}. */ + off = ( + event: E, + fn: Listener, + ): void => this.events.off(event, fn); + + /** Drop every listener, or every listener of one event. */ + removeAllListeners = (event?: keyof E2EEEventMap): void => + this.events.removeAllListeners(event); + + /** + * Set a per-user AES-GCM encryption key in the native key store. + * + * Use it when each participant has their own key from a central authority. + * The receiver picks the right one by the `keyIndex` in the frame trailer. + * + * @param userId - The key owner. + * @param keyIndex - Increases with each rotation. + * @param rawKey - 16 bytes for AES-128-GCM, 32 for AES-256-GCM. + */ + setKey = (userId: string, keyIndex: number, rawKey: ArrayBuffer): void => { + this.assertUsable(); + this.validateKeyIndex(keyIndex); + this.validateKeyLength(rawKey); + this.native.setKey(userId, keyIndex, this.copyKey(rawKey)); + }; + + /** + * Fallback key for any user without a per-user key. The simplest E2EE mode: + * one key for everyone, usually passphrase-derived, no distribution needed. + * Setting an epoch makes it active for encryption while older epochs remain + * available to decrypt in-flight frames until {@link removeSharedKey}. + * + * @param keyIndex - An integer 0-255, since one trailer byte carries it. + * @param rawKey - 16 bytes for AES-128-GCM, 32 for AES-256-GCM. + */ + setSharedKey = (keyIndex: number, rawKey: ArrayBuffer): void => { + this.assertUsable(); + this.validateKeyIndex(keyIndex); + this.validateKeyLength(rawKey); + this.native.setSharedKey(keyIndex, this.copyKey(rawKey)); + }; + + /** + * Retire one of a user's key epochs, leaving their other epochs usable. + * + * @param userId - The key owner. + * @param keyIndex - The exact epoch to remove. Absent epochs are a no-op. + */ + removeKey = (userId: string, keyIndex: number): void => { + this.assertUsable(); + this.validateKeyIndex(keyIndex); + this.native.removeKey(userId, keyIndex); + }; + + /** + * Drop every key a user holds from the local key store, so later frames of + * theirs no longer decrypt. + * + * This is local cleanup, not remote revocation: it cannot stop that + * participant from decrypting anything, and a shared key still decrypts them. + * Revoking access means withholding future keys, which the application owns. + * + * To retire one rotated epoch instead, use {@link removeKey}. + */ + removeAllKeys = (userId: string): void => { + this.assertUsable(); + this.native.removeAllKeys(userId); + }; + + /** + * Remove one shared-key epoch from the native receive key ring. + * + * If this is the active epoch, shared-key encryption stops until + * {@link setSharedKey} succeeds again. An older epoch is not reactivated. + * + * @param keyIndex - The exact shared-key epoch to remove. + */ + removeSharedKey = (keyIndex: number): void => { + this.assertUsable(); + this.validateKeyIndex(keyIndex); + this.native.removeSharedKey(keyIndex); + }; + + /** + * Called by the Publisher when it adds a transceiver. + * + * Synchronous all the way to the native `SetFrameTransformer` call: were it + * async, the sender would exist before its transform did, which is a + * plaintext window. A failure throws instead of publishing cleartext. + * + * @param sender - The sender to encrypt. + * @param codec - Codec name, e.g. 'vp8', selecting the clear-byte rules. + * @param trackType - `TrackType` enum name, e.g. 'SCREEN_SHARE_AUDIO'. + * @internal + */ + encrypt = ( + sender: RTCRtpSender, + codec?: string, + trackType?: string, + ): void => { + this.assertUsable(); + this.native.encrypt(sender as never, codec, trackTypeToNative(trackType)); + }; + + /** + * Called by the Subscriber when a remote track arrives. + * + * @param receiver - The receiver to decrypt. + * @param userId - The remote user, for key lookup. + * @param trackType - `TrackType` enum name, e.g. 'SCREEN_SHARE_AUDIO'. + * @internal + */ + decrypt = ( + receiver: RTCRtpReceiver, + userId: string, + trackType?: string, + ): void => { + this.assertUsable(); + this.native.decrypt( + receiver as never, + userId, + trackTypeToNative(trackType), + ); + }; + + /** + * Toggle periodic performance reporting. + * + * While on, `e2ee.perf_report` is emitted once per second with per-track FPS + * and crypto timings. Useful for debugging throughput. + */ + enablePerformanceReporting = (enabled: boolean): void => { + this.assertUsable(); + // Observational, so it is the one native call that need not block. Kept + // void-returning to match the web manager. + this.native + .enablePerformanceReporting(enabled) + .catch((err) => + this.logger.warn('Failed to toggle performance reporting', err), + ); + }; + + /** + * Request a snapshot of the installed keys. It arrives later as the + * `e2ee.key_state` event, listing fingerprints only, never key material. + */ + requestKeyState = (): void => { + this.assertUsable(); + this.native + .requestKeyState() + .catch((err) => this.logger.warn('Failed to request key state', err)); + }; + + /** + * Release the native manager and all resources. + * + * **The application owns this object's lifetime — the SDK never disposes it.** + * Nothing releases the native manager when a peer connection closes, and there + * is no detach API, so an undisposed manager keeps its transforms and its key + * material alive for the lifetime of the process. Dispose it together with the + * call instance whose flow has ended; a later flow gets a fresh call and a + * fresh manager (see `Call.setE2EEManager`). + * + * In-flight frames are dropped rather than drained. + * + * The manager is unusable afterwards and every key or attach method throws. + * Call {@link create} for a new one. Safe to call more than once. + */ + dispose = (): void => { + if (this.disposed) return; + this.disposed = true; + for (const [type, handler] of this.bridged) { + this.native.off(type, handler); + } + this.bridged.length = 0; + try { + this.native.dispose(); + } catch (err) { + // Local cleanup still has to finish, or listeners outlive the manager. + this.logger.warn('Failed to dispose the native encryption manager', err); + } + this.events.removeAllListeners(); + }; + + /** + * {@link dispose} releases the native manager, so an attached transform would + * point at nothing: frames would stall with no error and no event. Throwing + * is also fail-closed, since a caller that swallows it still publishes + * nothing rather than cleartext. + */ + private assertUsable = () => { + if (this.disposed) throw new Error(`EncryptionManager is disposed`); + }; + + /** + * The caller keeps ownership of its buffer and may re-import the same bytes, + * so hand native a copy rather than a view onto memory that can change. + */ + private copyKey = (rawKey: ArrayBuffer): Uint8Array => { + const copy = new Uint8Array(rawKey.byteLength); + copy.set(new Uint8Array(rawKey)); + return copy; + }; + + private validateKeyLength = (rawKey: ArrayBuffer) => { + const is256 = this.algorithm === 'AES-256-GCM'; + const expected = is256 ? 32 : 16; + if (rawKey.byteLength !== expected) { + throw new Error( + `Key must be exactly ${expected} bytes (${is256 ? 'AES-256' : 'AES-128'})`, + ); + } + }; + + /** + * One trailer byte carries the keyIndex. A larger value would truncate to + * `keyIndex & 0xFF`, so the receiver would look up the wrong key and fail + * every decrypt. Reject it rather than ship a silently broken key epoch. + */ + private validateKeyIndex = (keyIndex: number) => { + if (!Number.isInteger(keyIndex) || keyIndex < 0 || keyIndex > 255) { + throw new Error( + `keyIndex must be an integer between 0 and 255, got ${keyIndex}`, + ); + } + }; +} diff --git a/packages/react-native-sdk/src/modules/encryption/eventMapping.ts b/packages/react-native-sdk/src/modules/encryption/eventMapping.ts new file mode 100644 index 0000000000..a61cb4f4f2 --- /dev/null +++ b/packages/react-native-sdk/src/modules/encryption/eventMapping.ts @@ -0,0 +1,151 @@ +import { + RTCEncryptionAlgorithm, + RTCEncryptionTrackType, + type RTCEncryptionEventData, + type RTCEncryptionTrackPerf, +} from '@stream-io/react-native-webrtc'; +import type { + E2EEAlgorithm, + E2EEEventMap, + TrackPerf, +} from '@stream-io/video-client'; + +/** + * The core RTC layer labels tracks with the `TrackType` enum *name* + * (`SfuModels.TrackType[trackType]`), while the native manager takes a numeric + * enum. Screen-share audio is deliberately its own value: native keeps replay + * state per (userId, trackType), so folding it into `AUDIO` mis-groups it. + * + * These read the native enums inside the function body on purpose. This module + * is reachable from the SDK's public entry point, so an app whose + * `@stream-io/react-native-webrtc` predates E2EE would crash on import if the + * enums were dereferenced at module scope - even if it never touches E2EE. + */ +export const trackTypeToNative = ( + trackType?: string, +): RTCEncryptionTrackType | undefined => { + switch (trackType) { + case 'AUDIO': + return RTCEncryptionTrackType.AUDIO; + case 'VIDEO': + return RTCEncryptionTrackType.VIDEO; + case 'SCREEN_SHARE': + return RTCEncryptionTrackType.SCREEN_SHARE; + case 'SCREEN_SHARE_AUDIO': + return RTCEncryptionTrackType.SCREEN_SHARE_AUDIO; + default: + // Native infers audio vs video from the sender rather than pinning a + // wrong value. + return undefined; + } +}; + +/** Map a native track type back to the name the web events carry. */ +export const trackTypeFromNative = ( + trackType?: RTCEncryptionTrackType | number, +): string | undefined => { + switch (trackType) { + case RTCEncryptionTrackType.AUDIO: + return 'AUDIO'; + case RTCEncryptionTrackType.VIDEO: + return 'VIDEO'; + case RTCEncryptionTrackType.SCREEN_SHARE: + return 'SCREEN_SHARE'; + case RTCEncryptionTrackType.SCREEN_SHARE_AUDIO: + return 'SCREEN_SHARE_AUDIO'; + default: + return undefined; + } +}; + +/** Map the public algorithm name to the native enum. */ +export const algorithmToNative = ( + algorithm: E2EEAlgorithm, +): RTCEncryptionAlgorithm => + algorithm === 'AES-256-GCM' + ? RTCEncryptionAlgorithm.AES_256_GCM + : RTCEncryptionAlgorithm.AES_128_GCM; + +const toTrackPerf = (row: RTCEncryptionTrackPerf): TrackPerf => ({ + userId: row.userId, + trackType: trackTypeFromNative(row.trackType) ?? '', + fps: row.fps, + maxCryptoMs: row.maxCryptoMs, +}); + +/** + * Translate a native event into the payload the web manager emits, so a host + * can write one set of handlers for both platforms. + * + * Two things have to be reshaped rather than passed through: native track types + * are numeric, and native flattens `managerId`/`type`/`userId` alongside the + * payload of every event, including the two (`perf_report`, `key_state`) that + * are not about a single user. Fields the web types require but native leaves + * optional get an explicit default, so a handler never reads `undefined` where + * its type promises a value. + * + * @returns the mapped event, or `undefined` for an unrecognized event name. + */ +export const mapNativeEvent = ( + event: RTCEncryptionEventData, +): { type: E; payload: E2EEEventMap[E] } | undefined => { + const { type, userId, trackType, keyIndex, version, reason } = event; + const mapped = (payload: E2EEEventMap[keyof E2EEEventMap]) => + ({ type, payload }) as { type: E; payload: E2EEEventMap[E] }; + + switch (type) { + case 'e2ee.decryption_failed': + case 'e2ee.decryption_resumed': + case 'e2ee.unencrypted_frame': + return mapped({ userId, trackType: trackTypeFromNative(trackType) }); + case 'e2ee.missing_key': + return mapped({ + userId, + keyIndex, + trackType: trackTypeFromNative(trackType), + }); + case 'e2ee.decryption_stalled': + return mapped({ + userId, + keyIndex: keyIndex ?? 0, + trackType: trackTypeFromNative(trackType), + }); + case 'e2ee.encryption_failed': + return mapped({ + userId, + trackType: trackTypeFromNative(trackType), + reason: reason ?? '', + }); + case 'e2ee.unsupported_version': + return mapped({ + userId, + trackType: trackTypeFromNative(trackType), + version: version ?? 0, + }); + case 'e2ee.perf_report': + return mapped({ + encode: (event.encode ?? []).map((row) => ({ + ...toTrackPerf(row), + codec: row.codec ?? '', + })), + decode: (event.decode ?? []).map(toTrackPerf), + }); + case 'e2ee.key_state': + return mapped(event.keyState ?? { perUserKeys: [], sharedKeys: [] }); + default: + return undefined; + } +}; + +/** Every event the native manager emits, i.e. what the bridge subscribes to. */ +export const E2EE_EVENT_TYPES = [ + 'e2ee.decryption_failed', + 'e2ee.decryption_resumed', + 'e2ee.decryption_stalled', + 'e2ee.encryption_failed', + 'e2ee.missing_key', + 'e2ee.unencrypted_frame', + 'e2ee.unsupported_version', + 'e2ee.perf_report', + 'e2ee.key_state', +] as const; diff --git a/packages/react-native-sdk/src/modules/encryption/index.ts b/packages/react-native-sdk/src/modules/encryption/index.ts new file mode 100644 index 0000000000..ce7604452c --- /dev/null +++ b/packages/react-native-sdk/src/modules/encryption/index.ts @@ -0,0 +1 @@ +export { EncryptionManager } from './EncryptionManager'; diff --git a/packages/react-native-sdk/src/modules/encryption/parity.ts b/packages/react-native-sdk/src/modules/encryption/parity.ts new file mode 100644 index 0000000000..38278d3962 --- /dev/null +++ b/packages/react-native-sdk/src/modules/encryption/parity.ts @@ -0,0 +1,83 @@ +/** + * Compile-time proof that the React Native {@link EncryptionManager} exposes the + * same public surface as the web one in `@stream-io/video-client`. + * + * This file has no runtime output and is imported by nothing. It exists because + * the SDK's entry point deliberately *shadows* the client's `EncryptionManager` + * export with this platform's implementation: a host writing against the + * documented API gets whichever class its platform ships, so the two surfaces + * diverging would be a silent break for that host rather than a build error. + * Both directions are asserted, so adding a method to either manager fails the + * build until the other one follows. Keep it that way, or delete both managers' + * claim to a shared API. + * + * `test:types` and the `tsc` pass in `bob build` both cover `src`, so CI runs + * this whether or not anything imports it. + */ +import type { EncryptionManager as WebEncryptionManager } from '@stream-io/video-client'; +import type { EncryptionManager } from './EncryptionManager'; + +/** Fails to compile unless `T` is exactly `true`. */ +type AssertTrue = T; + +/** + * Public members only. + * + * A class type carrying `private` members is nominal, so the two managers can + * never be compared as classes however identical their APIs. `keyof` yields + * public keys alone, which is also precisely the surface a host can touch. + */ +type PublicSurface = { [K in keyof T]: T[K] }; + +/** + * `emit` is public on the web manager only because it extends the shared + * `TypedEventEmitter`. Dispatching a forged E2EE event is not part of the + * documented API — `SPEC.md` §2 lists `on`/`off` and nothing else — so it is + * excluded here rather than mirrored on this side. + */ +type WebSurface = Omit, 'emit'>; +type NativeSurface = PublicSurface; + +/** Every documented web member exists here, with a compatible signature. */ +export type NativeCoversWeb = AssertTrue< + NativeSurface extends WebSurface ? true : false +>; + +/** + * ...and nothing extra. A React Native-only method would compile fine but make + * host code silently unportable, which is the failure this file exists to catch. + */ +export type WebCoversNative = AssertTrue< + WebSurface extends NativeSurface ? true : false +>; + +/** + * The statics are checked by signature rather than by assignability: both + * `create`s return their own class, so comparing them whole would only ever + * restate that the two classes are nominally distinct. + */ +export type CreateAcceptsSameArguments = AssertTrue< + Parameters extends Parameters< + typeof WebEncryptionManager.create + > + ? Parameters extends Parameters< + typeof EncryptionManager.create + > + ? true + : false + : false +>; + +export type CreateResolvesAManager = AssertTrue< + Awaited> extends NativeSurface + ? true + : false +>; + +export type IsSupportedMatches = AssertTrue< + typeof EncryptionManager.isSupported extends typeof WebEncryptionManager.isSupported + ? typeof WebEncryptionManager.isSupported extends typeof EncryptionManager.isSupported + ? true + : false + : false +>; diff --git a/packages/react-native-sdk/src/utils/StreamVideoRN/index.ts b/packages/react-native-sdk/src/utils/StreamVideoRN/index.ts index a6189ed534..fa568d31bb 100644 --- a/packages/react-native-sdk/src/utils/StreamVideoRN/index.ts +++ b/packages/react-native-sdk/src/utils/StreamVideoRN/index.ts @@ -1,4 +1,5 @@ -import type { StreamVideoConfig } from './types'; +import type { RingingCallLifecycleHooks, StreamVideoConfig } from './types'; +import { setRingingCallLifecycleHooks as storeRingingCallLifecycleHooks } from '../internal/ringingCallLifecycle'; import pushLogoutCallbacks from '../internal/pushLogoutCallback'; import { setupIosVoipPushEvents } from '../push/setupIosVoipPushEvents'; import { setupAndroidPushEvents } from '../push/setupAndroidPushEvents'; @@ -137,6 +138,28 @@ export class StreamVideoRN { setupAndroidPushEvents(pushConfig); } + /** + * Register hooks that run around a ringing call's join, for every ringing path: + * accepted from the CallKit/Telecom UI, accepted inside the app, and outgoing. + * + * Call this at your application's entry point, alongside + * {@link StreamVideoRN.setPushConfig} and **outside** the React tree. A call + * accepted from a push notification can be created and joined before any + * component mounts, so hooks registered from inside React would be missed on + * exactly the path that most needs them. + * + * Calling it again replaces the previously registered hooks. + * + * @example // in index.js + * StreamVideoRN.setRingingCallLifecycleHooks({ + * onBeforeCallJoin: (call) => attachE2EEIfConfigured(call), + * onAfterCallLeave: (call) => disposeE2EEManager(call), + * }); + */ + static setRingingCallLifecycleHooks = (hooks: RingingCallLifecycleHooks) => { + storeRingingCallLifecycleHooks(hooks); + }; + static getConfig() { return this.config; } @@ -158,6 +181,33 @@ export class StreamVideoRN { pushLogoutCallbacks.current = []; } + /** + * Android only. Opt out of the Android 11+ communication-mode keep-alive. + * + * By default the SDK plays a silent voice-communication track for the duration of a + * communicator-role call, so Android does not reset `MODE_IN_COMMUNICATION` ~6s after it is + * set (which breaks audio routing and echo cancellation). + * See {@link https://issuetracker.google.com/issues/209493718} + * + * Call this at app start, alongside {@link setPushConfig} — the native audio manager + * rejects the change once it has been activated for a call. No-op on iOS and on + * Android below API 30. + */ + static setDisableCommunicationModeWorkaround(disabled: boolean) { + if (Platform.OS !== 'android') { + return; + } + try { + NativeModules.StreamInCallManager?.setDisableCommunicationModeWorkaround( + disabled, + ); + } catch (error) { + videoLoggerSystem + .getLogger('StreamVideoRN') + .warn('setDisableCommunicationModeWorkaround failed', error); + } + } + /** * Play native busy tone for call rejection */ diff --git a/packages/react-native-sdk/src/utils/StreamVideoRN/types.ts b/packages/react-native-sdk/src/utils/StreamVideoRN/types.ts index bc2053d51c..a7e2f42c03 100644 --- a/packages/react-native-sdk/src/utils/StreamVideoRN/types.ts +++ b/packages/react-native-sdk/src/utils/StreamVideoRN/types.ts @@ -19,6 +19,74 @@ export type KeepAliveAndroidNotificationTexts = { export type NonRingingPushEvent = 'call.live_started' | 'call.notification' | 'call.missed'; +/** + * Hooks that run around a ringing call's join, wherever that join is triggered. + * + * A ringing call is joined by the SDK, not by your code: the accept button joins + * internally, and an outgoing call joins itself once the callee answers. That + * leaves no moment in which app code holds the call and the join has not yet + * happened - which is a problem for anything that must be installed first, most + * notably `call.setE2EEManager()`, since it throws once a call has peer + * connections. + * + * These hooks are that moment. They run for every ringing path: accepted from the + * CallKit/Telecom UI, accepted inside the app, and outgoing. + * + * Register them with {@link StreamVideoRN.setRingingCallLifecycleHooks} at your app's + * entry point, next to `setPushConfig`. A call accepted from a push notification + * can be created and joined before any component mounts - the app may be launched + * from a killed state - so registering them from inside React is too late. + */ +export type RingingCallLifecycleHooks = { + /** + * Awaited by `Call.join()` for a ringing call, before any peer connection exists. + * **Throwing aborts the join** and the call is not entered. + * + * That is deliberate: joining without whatever this hook installs would, in the + * E2EE case, publish unencrypted media on a call the user believes is private. + * On the push path there is no UI to reveal it, so the join fails closed instead. + * A failed join also ends the call, so the `Call` it failed on is finished with - + * a later attempt happens on a new incoming call or a newly created one. + * + * Keep it fast. On the CallKit accept path it runs inside iOS's accept deadline + * (roughly 30s before the app is killed) and anything slower than five seconds is + * treated as a failure. Deriving a key locally is fine; fetching one over the + * network does not belong here. + * + * @example + * onBeforeCallJoin: async (call) => { + * await attachE2EEIfConfigured(call); + * } + */ + onBeforeCallJoin?: (call: Call) => Promise; + /** + * Called once, when a ringing call is finished with - it left, whether because + * the user hung up, the other side ended it, or its join failed and the SDK + * ended the flow. + * + * Use it to free per-call resources the SDK does not own. An E2EE manager is the + * motivating case: it has no native detach, closing the peer connections does not + * free it, and on the push path the call can end while the app is still in the + * background, where no React cleanup ever runs. + * + * When an {@link onBeforeCallJoin} is also registered, this is only called for a + * call that hook actually entered, so it always pairs with a setup that happened - + * a call that rings and is then rejected, cancelled or missed was never set up and + * releases nothing. Registered on its own it has no such pairing to honour and is + * called for every ringing call that ends. Either way it is tied to the call + * ending rather than to any view's lifetime, so navigating away from a live call + * does not release it. + * + * If the setup hook is still running when the call ends - it timed out, say - the + * release waits for it to settle, so it cannot free something that hook is about + * to create. + * + * May return a promise; rejections are logged. Nothing is gated on it, so do not + * rely on it completing before the OS suspends the app. + */ + onAfterCallLeave?: (call: Call) => void | Promise; +}; + export type StreamVideoConfig = { /** * The configuration to be used for push notifications. diff --git a/packages/react-native-sdk/src/utils/internal/callingx/callingx.ts b/packages/react-native-sdk/src/utils/internal/callingx/callingx.ts index 2939055d8b..8ded1b72dc 100644 --- a/packages/react-native-sdk/src/utils/internal/callingx/callingx.ts +++ b/packages/react-native-sdk/src/utils/internal/callingx/callingx.ts @@ -115,7 +115,11 @@ export async function registerOutgoingCall(call: Call) { * 2. Displays the incoming call in the callingx library * 3. Optionally for non-ringing calls also when ongoing calls are enabled. */ -export async function joinCallingxCall(call: Call, activeCalls: Call[]) { +export async function joinCallingxCall( + call: Call, + activeCalls: Call[], + isCancelled?: () => boolean, +) { if ( !CallingxModule || !CallingxModule.isSetup || @@ -151,12 +155,25 @@ export async function joinCallingxCall(call: Call, activeCalls: Call[]) { logger.error(`failed to leave active call ${activeCall.cid}`, e); }); } + // Leaving the other calls above can take arbitrarily long, and this join may + // have been cancelled meanwhile. Registering now would create a native call + // nobody owns. The caller decides, not the call's state: a `Call` reused for + // a fresh ring is still `LEFT` here, because `setup()` runs after this. + if (isCancelled?.()) { + logger.debug( + `joinCallingxCall: skipping registration for ${call.cid}: join cancelled while waiting for other calls`, + ); + return; + } logger.debug( `joinCallingxCall: Joining call ${call.cid} isIncoming: ${isIncomingCall} isOutgoing: ${isOutcomingCall}`, ); const callArgs = getCallingxCallArgs(call); if (isIncomingCall) { await CallingxModule.displayIncomingCall(...callArgs); + // never answer a call that was hung up while the OS was displaying it - + // the cleanup below ends the registration instead + if (isCancelled?.()) return; await CallingxModule.answerIncomingCall(call.cid); } else { await CallingxModule.startCall(...callArgs); @@ -166,6 +183,19 @@ export async function joinCallingxCall(call: Call, activeCalls: Call[]) { `startCallingxCall: Error starting call in callingx: ${call.cid} isIncoming: ${isIncomingCall} isOutgoing: ${isOutcomingCall}`, error, ); + } finally { + // The registration above can outlive the join that asked for it: `leave()` + // may land while native is still bringing the call up, and by the time the + // caller aborts, the call exists natively with nobody to end it. Ringing + // calls have a lifecycle owner that would clean up; ordinary ones do not. + // Nothing was registered when the check above already skipped it, and + // `endCallingxCall` no-ops for an untracked cid, so this is safe either way. + if (isCancelled?.()) { + logger.debug( + `joinCallingxCall: ending ${call.cid}: join was cancelled while registering`, + ); + await endCallingxCall(call, 'canceled'); + } } } diff --git a/packages/react-native-sdk/src/utils/internal/registerSDKGlobals.ts b/packages/react-native-sdk/src/utils/internal/registerSDKGlobals.ts index 1a318307c8..ede2b75a32 100644 --- a/packages/react-native-sdk/src/utils/internal/registerSDKGlobals.ts +++ b/packages/react-native-sdk/src/utils/internal/registerSDKGlobals.ts @@ -16,6 +16,7 @@ import { unwireAudioEngineSubscription, } from './callingx/callingx'; import { registerCallMediaEngine } from './registerMediaEngine'; +import { beforeJoin, onJoinFailed, onLeave } from './ringingCallLifecycle'; import { callManager as publicCallManager } from '../../modules/call-manager'; const StreamInCallManagerNativeModule = NativeModules.StreamInCallManager; @@ -91,6 +92,7 @@ const streamRNVideoSDKGlobals: StreamRNVideoSDKGlobals = { wireAudioEngineSubscription: wireAudioEngineSubscription, unwireAudioEngineSubscription: unwireAudioEngineSubscription, }, + ringingCallLifecycle: { beforeJoin, onJoinFailed, onLeave }, callManager: { setup: ({ defaultDevice, isRingingTypeCall, cid }) => { const isTelecomManaged = isAndroidTelecomManaged({ cid }); diff --git a/packages/react-native-sdk/src/utils/internal/ringingCallLifecycle.ts b/packages/react-native-sdk/src/utils/internal/ringingCallLifecycle.ts new file mode 100644 index 0000000000..860cec4328 --- /dev/null +++ b/packages/react-native-sdk/src/utils/internal/ringingCallLifecycle.ts @@ -0,0 +1,148 @@ +import { + CallingState, + videoLoggerSystem, + type Call, +} from '@stream-io/video-client'; +import type { RingingCallLifecycleHooks } from '../StreamVideoRN/types'; + +const logger = videoLoggerSystem.getLogger('ringingCallLifecycle'); + +/** + * How long `onBeforeCallJoin` may take before the join gives up on it. + * + * The hook runs inside the CallKit accept, which iOS gives a hard deadline of + * roughly 30s before killing the app. Failing well short of that leaves room to + * report the failure and end the native call cleanly, and a hook that legitimately + * needs longer than this does not belong on the accept path at all. + */ +const ON_BEFORE_CALL_JOIN_TIMEOUT_MS = 5_000; + +/** + * The registered hooks. + * + * Held here rather than on `StreamVideoRN.config` so the push internals and the + * globals bridge can both reach them without importing the `StreamVideoRN` + * class, which would close an import cycle through the push setup modules. + */ +let hooks: RingingCallLifecycleHooks | undefined; + +export const setRingingCallLifecycleHooks = ( + next: RingingCallLifecycleHooks, +) => { + hooks = next; +}; + +/** + * How to release what a call's setup hook installed. + * + * Kept per call rather than in a single current-call slot: an abandoned call's + * hook can still finish after the next call has joined, and it has to release + * its own manager rather than whatever is current. An entry exists only for a + * call whose setup hook actually ran. + */ +const cleanups = new WeakMap void>(); + +/** Invokes the release hook, swallowing whatever it throws. */ +const fireRelease = (call: Call): void => { + const hook = hooks?.onAfterCallLeave; + if (!hook) return; + try { + Promise.resolve(hook(call)).catch((e) => { + logger.warn(`onAfterCallLeave failed for callCid: ${call.cid}`, e); + }); + } catch (e) { + logger.warn(`onAfterCallLeave threw for callCid: ${call.cid}`, e); + } +}; + +/** Releases what the setup hook installed, at most once. */ +const requestRelease = (call: Call): void => { + const cleanup = cleanups.get(call); + if (!cleanup) return; + // Dropped before it runs: that is what makes a failed join followed by a + // leave release exactly once. + cleanups.delete(call); + cleanup(); +}; + +/** + * Runs the app's pre-join setup, bounded by the deadline. + * + * Rejecting fails the join closed - joining without whatever this installs would + * publish unencrypted media on a call the user believes is private. + */ +export const beforeJoin = (call: Call): Promise => { + const hook = hooks?.onBeforeCallJoin; + if (!hook) return Promise.resolve(); + + // Invoked synchronously - a caller expects its hook to start now - but a + // synchronous throw becomes a rejection so it cannot escape the bookkeeping. + let started: Promise; + try { + started = Promise.resolve(hook(call)); + } catch (error) { + started = Promise.reject(error); + } + + // Cleanup tracks the hook's own promise, never the bounded wait below. The + // deadline ends this join's wait but cannot cancel the app's work, so a + // manager the hook creates late still has an owner waiting to release it. + const settled = started.catch(() => {}); + cleanups.set(call, () => void settled.then(() => fireRelease(call))); + + let timeout: ReturnType | undefined; + const expiry = new Promise((_, reject) => { + timeout = setTimeout( + () => + reject( + new Error( + `onBeforeCallJoin did not settle within ${ON_BEFORE_CALL_JOIN_TIMEOUT_MS}ms`, + ), + ), + ON_BEFORE_CALL_JOIN_TIMEOUT_MS, + ); + }); + + return Promise.race([started, expiry]).finally(() => clearTimeout(timeout)); +}; + +/** + * The join failed and will not be retried on this call. + * + * Ends the ringing flow rather than leaving the call sitting in `RINGING`: one + * `Call` is one call flow, so the accept button must not be able to offer this + * instance again. Never rejects, so the original join error survives. + */ +export const onJoinFailed = async (call: Call): Promise => { + if (call.state.callingState !== CallingState.LEFT) { + try { + // Takes the call out of the client's list, which unmounts the ringing UI. + // `reject: false`: the join failed locally, so this is not the callee + // declining, and the native call has already been ended with 'error'. + await call.leave({ reject: false }); + } catch (error) { + logger.warn(`failed to leave after a failed join: ${call.cid}`, error); + } + } + // The leave above notifies {@link onLeave}, which consumes the cleanup. This + // covers what it could not: a call already left, or a leave that itself failed. + requestRelease(call); +}; + +/** + * The call has ended. + * + * Releases what the setup hook installed, once the hook has actually settled - + * a hook still running may yet install something, and releasing before it + * finishes would strand that. + */ +export const onLeave = (call: Call): void => { + // A release-only registration has nothing to pair with, so its hook belongs to + // the call ending rather than to a join: it is owed for every ringing call + // that ends, including one declined without ever joining. + if (!hooks?.onBeforeCallJoin) { + fireRelease(call); + return; + } + requestRelease(call); +}; diff --git a/packages/react-native-sdk/src/utils/push/internal/utils.ts b/packages/react-native-sdk/src/utils/push/internal/utils.ts index 8b9a6c2932..3f25cd9c42 100644 --- a/packages/react-native-sdk/src/utils/push/internal/utils.ts +++ b/packages/react-native-sdk/src/utils/push/internal/utils.ts @@ -128,6 +128,10 @@ export const processCallFromPushInBackground = async ( 'processCallFromPushInBackground: failed to join call from push notification', e, ); + // Cleanup is not repeated here: `join()`'s own failure boundary already + // released whatever the pre-join hook installed, and a second call would + // invoke a release-only registration twice. + onIOSActionCanBeFulfilled(true); } } else if (action === 'decline') { const alreadyLeft = callFromPush.state.callingState === CallingState.LEFT; diff --git a/sample-apps/react-native/dogfood/App.tsx b/sample-apps/react-native/dogfood/App.tsx index 170ec7d683..265838ca65 100755 --- a/sample-apps/react-native/dogfood/App.tsx +++ b/sample-apps/react-native/dogfood/App.tsx @@ -4,7 +4,7 @@ import { NavigationContainer } from '@react-navigation/native'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { RootStackParamList } from './types'; import { - deeplinkCallId$, + deeplinkCall$, useDeepLinkEffect, } from './src/hooks/useDeepLinkEffect'; import { @@ -136,8 +136,8 @@ const StackNavigator = () => { } useEffect(() => { - const subscription = deeplinkCallId$.subscribe((prontoCallId) => { - if (prontoCallId) { + const subscription = deeplinkCall$.subscribe((deeplinkCall) => { + if (deeplinkCall) { setState({ appMode: 'Meeting' }); } }); diff --git a/sample-apps/react-native/dogfood/CLAUDE.md b/sample-apps/react-native/dogfood/CLAUDE.md index c510964695..27a55911f6 100644 --- a/sample-apps/react-native/dogfood/CLAUDE.md +++ b/sample-apps/react-native/dogfood/CLAUDE.md @@ -21,6 +21,8 @@ All commands should be run from this directory (`sample-apps/react-native/dogfoo | Run on iOS device | `yarn ios-device` | | Run on Android emulator | `yarn android` | | Type-check | `yarn build` (runs `tsc`) | +| Unit tests | `yarn test` (Jest + React Native Testing Library) | +| Unit tests in CI | `yarn test-ci` (included in root `yarn test:ci:all`) | | Reinstall iOS pods | `cd ios && bundle exec pod install` | Before running the app, the workspace SDK packages must be built first: diff --git a/sample-apps/react-native/dogfood/README.md b/sample-apps/react-native/dogfood/README.md index bf81b8f785..649f78959b 100644 --- a/sample-apps/react-native/dogfood/README.md +++ b/sample-apps/react-native/dogfood/README.md @@ -50,6 +50,22 @@ Optional: If you have trouble running the app with iOS, try to reinstall the iOS 2. `yarn android` or `yarn ios` to run on Android Emulator or iOS simulator respectively. 3. Optional: To run on a physical device follow the guide [here](https://reactnative.dev/docs/running-on-device). +## Unit tests + +Run these commands from `sample-apps/react-native/dogfood`: + +```bash +yarn test +yarn test --watch +yarn test-ci +yarn build # Type-check the app and tests +``` + +Tests live in `__tests__/` and use Jest with `@react-native/jest-preset` and +React Native Testing Library. Keep native and SDK mocks local to the tests that +need them. The repository's `yarn test:ci:all` command runs `test-ci` automatically; +these JavaScript tests do not require a simulator or device. +