From 864d77a05421cc274608aa8654af2ebeb40bb741 Mon Sep 17 00:00:00 2001 From: Santhosh Vaiyapuri <3846977+santhoshvai@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:06:10 +0200 Subject: [PATCH 1/7] fix(client): abandon retries when leave supersedes join (#2441) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes a race where join() could keep retrying after leave(), causing a call to rejoin after the user had left. Stops pending join setup and retries when a leave occurs. Normal retries and explicitly joining again still work. Adds regression coverage for leaving during setup, an active join attempt, and retry delays. 🎫 Ticket: https://linear.app/stream/issue/XYZ-123 πŸ“‘ Docs: https://github.com/GetStream/docs-content/pull/ * **Bug Fixes** * Improved call lifecycle handling when leaving during an in-progress join or reconnect. * Prevented interrupted operations from continuing setup, retries, reconnection, media setup, or connection work. * Ensured interrupted calls remain in the left state without creating or restoring call sessions. * Prevented late responses and failures from triggering unwanted call updates or cleanup. * Preserved normal retry, error handling, and rejoining behavior when no leave interruption occurs. * **Tests** * Added coverage for leave races across joining, reconnecting, retries, teardown, and error scenarios. Backport note: adapted to `release-v1`, which does not have the `ClientState` refactor (#2422): `clientState` -> `clientStore` and `ClientState` -> `StreamVideoWriteableStateStore`. No behaviour change. (cherry picked from commit 1936cb5dc0b9a5e81ebb2a1f92ff9ac57231c583) --- packages/client/src/Call.ts | 145 +++++- .../src/__tests__/Call.lifecycle.test.ts | 488 +++++++++++++++++- 2 files changed, 600 insertions(+), 33 deletions(-) diff --git a/packages/client/src/Call.ts b/packages/client/src/Call.ts index f2a7a13ec2..7706b8574e 100644 --- a/packages/client/src/Call.ts +++ b/packages/client/src/Call.ts @@ -983,12 +983,14 @@ export class Call { members_limit?: number; video?: boolean; }): Promise => { + const getLeaveGeneration = this.leaveGeneration; await this.setup(); const response = await this.streamClient.get( this.streamClientBasePath, params, ); + if (this.leaveGeneration !== getLeaveGeneration) return response; this.updateFromCallStateResponse(response); @@ -1158,6 +1160,13 @@ export class Call { throw new Error(`Illegal State: call.join() shall be called only once`); } + // snapshot before the first await: a leave() landing at any point from + // here on supersedes this join, and the retry loop must bail out instead + // of resurrecting a call that leave() already tore down. + const joinLeaveGeneration = this.leaveGeneration; + const supersededByLeave = () => + this.leaveGeneration !== joinLeaveGeneration; + // we need this to be set before the callingx.joinCall() is // called to avoid registering the test call in the CallKit/Telecom this.allowOwnTracksLoopback = allowOwnTracksLoopback; @@ -1170,9 +1179,17 @@ export class Call { if (callingX) { // for Android/iOS, we need to start the call in the callingx library as soon as possible await callingX.joinCall(this, this.clientStore.calls); + 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 registering'); + return; + } this.clientEventReporter.registerCall(this.cid, { callType: this.type, @@ -1195,6 +1212,10 @@ export class Call { { joinReason: 'first-attempt', joinSource }, async () => { for (let attempt = 0; attempt < maxJoinRetries; attempt++) { + if (supersededByLeave()) { + this.logger.debug('Join superseded by leave; not attempting'); + return; + } try { this.logger.trace(`Joining call (${attempt})`, this.cid); await this.doJoin(data); @@ -1202,6 +1223,10 @@ export class Call { delete joinData.migrating_from_list; return; } catch (err) { + if (supersededByLeave()) { + this.logger.debug('Join superseded by leave; not retrying'); + return; + } this.logger.warn(`Failed to join call (${attempt})`, this.cid); if ( (err instanceof ErrorFromResponse && err.unrecoverable) || @@ -1270,6 +1295,10 @@ export class Call { // globals resolve to the call's factory. Idempotent across // reconnect/migration attempts. await this.ensureMediaFactory(); + if (supersededByLeave()) { + this.logger.debug('Join superseded by leave; not wiring media'); + return; + } const callingX = globalThis.streamRNVideoSDK?.callingX; if (callingX) { @@ -1297,10 +1326,17 @@ export class Call { 'CoordinatorJoin', () => this.doJoinRequest(data), ); + if (!joinResponse || supersededByLeave()) { + this.logger.debug( + 'Join superseded by leave; not creating SFU client', + ); + return; + } this.credentials = joinResponse.credentials; statsOptions = joinResponse.stats_options; this.lastStatsOptions = statsOptions; } catch (error) { + if (supersededByLeave()) return; // prevent triggering reconnect flow if the state is OFFLINE const avoidRestoreState = this.state.callingState === CallingState.OFFLINE; @@ -1355,6 +1391,10 @@ export class Call { getGenericSdp('recvonly', dangerouslyForceCodec, subscriberFmtpLine), getGenericSdp('sendonly', dangerouslyForceCodec, fmtpLine), ]); + if (supersededByLeave()) { + this.logger.debug('Join superseded by leave; not joining SFU'); + return; + } const isReconnecting = this.reconnectStrategy !== WebsocketReconnectStrategy.UNSPECIFIED; const reconnectDetails = isReconnecting @@ -1388,6 +1428,12 @@ export class Call { source: ParticipantSource.WEBRTC_UNSPECIFIED, }), ); + if (supersededByLeave()) { + this.logger.debug( + 'Join superseded by leave; not applying SFU response', + ); + return; + } this.currentPublishOptions = publishOptions; this.fastReconnectDeadlineSeconds = fastReconnectDeadlineSeconds; @@ -1399,6 +1445,10 @@ export class Call { ); } } catch (error) { + if (supersededByLeave()) { + this.logger.debug('Join superseded by leave; ignoring SFU failure'); + return; + } this.logger.warn('Join SFU request failed', error); sfuClient.close( StreamSfuClient.JOIN_FAILED, @@ -1410,8 +1460,8 @@ export class Call { } } - // If the user left while this join was in flight, bail before re-setting JOINED and before - // peer-connection setup below (both run synchronously after this, so one check covers them). + // If the user left while this join was in flight, bail before re-setting JOINED + // or starting peer-connection setup below. if (supersededByLeave()) { this.logger.debug('Join superseded by leave; aborting join flow'); return; @@ -1441,6 +1491,10 @@ export class Call { unifiedSessionId: this.unifiedSessionId, }); } + if (supersededByLeave()) { + this.logger.debug('Join superseded by leave; not completing join flow'); + return; + } // make sure we only track connection timing if we are not calling this method as part of a reconnection flow if (!performingRejoin && !performingFastReconnect && !performingMigration) { @@ -1626,6 +1680,7 @@ export class Call { closePreviousInstances: boolean; unifiedSessionId: string; }) => { + const joinLeaveGeneration = this.leaveGeneration; const { sfuClient, connectionConfig, @@ -1642,10 +1697,12 @@ export class Call { // Flush the previous reporter's final sample while its peer connections are // still alive, before we dispose them below. Awaits only the sampling step. await this.sfuStatsReporter?.flush(); + if (this.leaveGeneration !== joinLeaveGeneration) return; this.sfuStatsReporter?.stop(); this.sfuStatsReporter = undefined; if (closePreviousInstances && this.subscriber) { await this.subscriber.dispose(); + if (this.leaveGeneration !== joinLeaveGeneration) return; this.state.removeAllOrphanedTracks(); } const basePeerConnectionOptions: BasePeerConnectionOpts = { @@ -1692,6 +1749,7 @@ export class Call { if (!isAnonymous) { if (closePreviousInstances && this.publisher) { await this.publisher.dispose(); + if (this.leaveGeneration !== joinLeaveGeneration) return; } this.publisher = new Publisher( basePeerConnectionOptions, @@ -1735,9 +1793,19 @@ export class Call { * * @internal * @param data the join call data. + * @returns The coordinator response, or undefined if leave superseded the request before it was sent. */ - doJoinRequest = async (data?: JoinCallData): Promise => { + doJoinRequest = async ( + data?: JoinCallData, + ): Promise => { + const joinLeaveGeneration = this.leaveGeneration; const location = await this.streamClient.getLocationHint(); + if (this.leaveGeneration !== joinLeaveGeneration) { + this.logger.debug( + 'Join superseded by leave; not sending coordinator request', + ); + return; + } const e2ee = !!this.e2eeManager; const request: JoinCallRequest = { ...data, location, e2ee }; const joinResponse = await this.streamClient.post< @@ -1745,6 +1813,13 @@ export class Call { JoinCallRequest >(`${this.streamClientBasePath}/join`, request); + if (this.leaveGeneration !== joinLeaveGeneration) { + this.logger.debug( + 'Join superseded by leave; not applying coordinator response', + ); + return joinResponse; + } + this.state.updateFromCallResponse(joinResponse.call); this.state.setMembers(joinResponse.members); this.state.setOwnCapabilities(joinResponse.own_capabilities); @@ -1759,6 +1834,7 @@ export class Call { if (!isReconnecting && this.ringing && !this.isCreatedByMe) { // signals other users that I have accepted the incoming call. await this.accept(); + if (this.leaveGeneration !== joinLeaveGeneration) return joinResponse; } if (this.streamClient._hasConnectionID()) { @@ -1863,6 +1939,7 @@ export class Call { callingState === CallingState.JOINING || callingState === CallingState.RECONNECTING || callingState === CallingState.MIGRATING || + callingState === CallingState.LEFT || callingState === CallingState.RECONNECTING_FAILED ) return; @@ -1873,19 +1950,26 @@ export class Call { // `POST /join` per entry) once the call is already healthy again. if (hasPending(this.reconnectConcurrencyTag)) return; + const reconnectLeaveGeneration = this.leaveGeneration; + const supersededByLeave = () => + this.leaveGeneration !== reconnectLeaveGeneration; return withoutConcurrency(this.reconnectConcurrencyTag, async () => { + if (supersededByLeave()) return; const reconnectStartTime = Date.now(); this.reconnectStrategy = strategy; this.reconnectReason = reason; const sfuRejoinFailures = new Map(); const markAsReconnectingFailed = async () => { + if (supersededByLeave()) return; try { // attempt to fetch the call data from the server, as the call // state might have changed while we were reconnecting or were offline await this.get(); } finally { - this.state.setCallingState(CallingState.RECONNECTING_FAILED); + if (!supersededByLeave()) { + this.state.setCallingState(CallingState.RECONNECTING_FAILED); + } } }; @@ -1955,6 +2039,7 @@ export class Call { try { // wait until the network is available await this.networkAvailableTask?.promise; + if (supersededByLeave()) return; this.logger.info( `[Reconnect] Reconnecting with strategy ${ @@ -2005,6 +2090,7 @@ export class Call { this.consecutiveNegotiationFailures = 0; break; // do-while loop, reconnection worked, exit the loop } catch (error) { + if (supersededByLeave()) return; if (attemptedStrategy === WebsocketReconnectStrategy.REJOIN) { const failedSfu = this.credentials?.server.edge_name; if (failedSfu) { @@ -2048,6 +2134,7 @@ export class Call { // exponential backoff with jitter, capped at 5 s await sleep(retryInterval(attempt)); + if (supersededByLeave()) return; const wasMigrating = this.reconnectStrategy === WebsocketReconnectStrategy.MIGRATE; @@ -2078,6 +2165,7 @@ export class Call { ); } } while ( + !supersededByLeave() && this.state.callingState !== CallingState.JOINED && this.state.callingState !== CallingState.RECONNECTING_FAILED && this.state.callingState !== CallingState.LEFT @@ -2091,10 +2179,12 @@ export class Call { * @internal */ private reconnectFast = async () => { + const reconnectLeaveGeneration = this.leaveGeneration; const reconnectStartTime = Date.now(); this.reconnectStrategy = WebsocketReconnectStrategy.FAST; this.state.setCallingState(CallingState.RECONNECTING); await this.doJoin(this.joinCallData); + if (this.leaveGeneration !== reconnectLeaveGeneration) return; await this.get(); // fetch the latest call state, as it might have changed this.sfuStatsReporter?.sendReconnectionTime( WebsocketReconnectStrategy.FAST, @@ -2107,6 +2197,7 @@ export class Call { * @internal */ private reconnectRejoin = async () => { + const reconnectLeaveGeneration = this.leaveGeneration; const reconnectStartTime = Date.now(); this.reconnectStrategy = WebsocketReconnectStrategy.REJOIN; this.state.setCallingState(CallingState.RECONNECTING); @@ -2119,7 +2210,9 @@ export class Call { { joinReason }, () => this.doJoin(this.joinCallData), ); + if (this.leaveGeneration !== reconnectLeaveGeneration) return; await this.restorePublishedTracks(); + if (this.leaveGeneration !== reconnectLeaveGeneration) return; this.restoreSubscribedTracks(); this.sfuStatsReporter?.sendReconnectionTime( WebsocketReconnectStrategy.REJOIN, @@ -2132,6 +2225,7 @@ export class Call { * @internal */ private reconnectMigrate = async () => { + const reconnectLeaveGeneration = this.leaveGeneration; const reconnectStartTime = Date.now(); const currentSfuClient = this.sfuClient; if (!currentSfuClient) { @@ -2149,32 +2243,35 @@ export class Call { const migrationTask = makeSafePromise(currentSfuClient.enterMigration()); try { - const currentSfu = currentSfuClient.edgeName; - await this.clientEventReporter.withJoinLifecycle( - this.cid, - { joinReason: 'migration' }, - () => - this.doJoin({ - ...this.joinCallData, - migrating_from: currentSfu, - migrating_from_list: [currentSfu], - }), - ); - } finally { - // cleanup the migration_from field after the migration is complete or failed - // as we don't want to keep dirty data in the join call data - delete this.joinCallData?.migrating_from; - delete this.joinCallData?.migrating_from_list; - } + try { + const currentSfu = currentSfuClient.edgeName; + await this.clientEventReporter.withJoinLifecycle( + this.cid, + { joinReason: 'migration' }, + () => + this.doJoin({ + ...this.joinCallData, + migrating_from: currentSfu, + migrating_from_list: [currentSfu], + }), + ); + } finally { + // cleanup the migration_from field after the migration is complete or failed + // as we don't want to keep dirty data in the join call data + delete this.joinCallData?.migrating_from; + delete this.joinCallData?.migrating_from_list; + } - await this.restorePublishedTracks(); - this.restoreSubscribedTracks(); + if (this.leaveGeneration !== reconnectLeaveGeneration) return; + await this.restorePublishedTracks(); + if (this.leaveGeneration !== reconnectLeaveGeneration) return; + this.restoreSubscribedTracks(); - try { // Wait for the migration to complete, then close the previous SFU client // and the peer connection instances. In case of failure, the migration // task would throw an error and REJOIN would be attempted. await migrationTask(); + if (this.leaveGeneration !== reconnectLeaveGeneration) return; // in MIGRATE, we can consider the call as joined only after // `participantMigrationComplete` event is received, signaled by diff --git a/packages/client/src/__tests__/Call.lifecycle.test.ts b/packages/client/src/__tests__/Call.lifecycle.test.ts index 200019ea93..4f2129b75e 100644 --- a/packages/client/src/__tests__/Call.lifecycle.test.ts +++ b/packages/client/src/__tests__/Call.lifecycle.test.ts @@ -5,15 +5,61 @@ import '../rtc/__tests__/mocks/webrtc.mocks'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { fromPartial } from '@total-typescript/shoehorn'; import { Call } from '../Call'; +import * as sfu from '../StreamSfuClient'; +import * as rtc from '../rtc'; import { StreamClient } from '../coordinator/connection/client'; +import { ErrorFromResponse } from '../coordinator/connection/types'; import { ClientEventReporter } from '../reporting'; +import type { GetCallResponse, JoinCallResponse } from '../gen/coordinator'; +import { WebsocketReconnectStrategy } from '../gen/video/sfu/models/models'; import { generateUUIDv4 } from '../coordinator/connection/utils'; -import { StreamVideoWriteableStateStore } from '../store'; +import { CallingState, StreamVideoWriteableStateStore } from '../store'; import { promiseWithResolvers } from '../helpers/promise'; +// A controlled stand-in for the retry backoff. Unless a test installs a hook, +// the real `sleep` is used, so the rest of the suite is unaffected. +const sleepControl: { onSleep?: (ms: number) => Promise } = {}; +vi.mock('../coordinator/connection/utils', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + sleep: (ms: number) => + sleepControl.onSleep ? sleepControl.onSleep(ms) : actual.sleep(ms), + }; +}); + +// the React Native globals that `join()`/`leave()` reach for when the SDK runs +// on a device. Installed only by the tests that assert on callingX behavior. +const installCallingX = () => { + const callingX = { + joinCall: vi.fn().mockResolvedValue(undefined), + endCall: vi.fn(), + wireAudioEngineSubscription: vi.fn(), + }; + (globalThis as Record).streamRNVideoSDK = { + callingX, + callManager: { stop: vi.fn() }, + }; + return callingX; +}; + +const makePeer = () => ({ + detachEventHandlers: vi.fn(), + dispose: vi.fn().mockResolvedValue(undefined), +}); + describe('Call lifecycle wiring', () => { let call: Call; + let internalCall: { + doJoin: Call['join']; + reconnectFast: () => Promise; + restorePublishedTracks: () => Promise; + restoreSubscribedTracks: () => void; + applyDeviceConfig: () => Promise; + }; beforeEach(() => { vi.spyOn(globalThis, 'fetch').mockResolvedValue({ @@ -30,12 +76,20 @@ describe('Call lifecycle wiring', () => { }), clientStore: new StreamVideoWriteableStateStore(), }); + internalCall = call as unknown as typeof internalCall; }); afterEach(() => { vi.restoreAllMocks(); + delete sleepControl.onSleep; + delete (globalThis as Record).streamRNVideoSDK; }); + const mockJoin = () => { + vi.spyOn(call, 'setup').mockResolvedValue(undefined); + return vi.spyOn(internalCall, 'doJoin').mockResolvedValue(undefined); + }; + // Regression guard for the Call-owned helper teardown chain. Each of // these helpers holds a resource (timer, listener, AudioContext) that // leaks across calls if teardown is dropped during a refactor. @@ -82,10 +136,7 @@ describe('Call lifecycle wiring', () => { // `joinSource` is reporting-only: it must reach the event reporter and never // the coordinator's join request. it('call.join() reports joinSource without putting it on the wire', async () => { - vi.spyOn(call, 'setup').mockResolvedValue(undefined); - const doJoin = vi - .spyOn(call as unknown as { doJoin: Call['join'] }, 'doJoin') - .mockResolvedValue(undefined); + const doJoin = mockJoin(); const withJoinLifecycle = vi.spyOn( call.clientEventReporter, 'withJoinLifecycle', @@ -104,10 +155,7 @@ describe('Call lifecycle wiring', () => { it('call.join() shares an in-flight join flow', async () => { const joinTask = promiseWithResolvers(); - vi.spyOn(call, 'setup').mockResolvedValue(undefined); - const doJoin = vi - .spyOn(call as unknown as { doJoin: Call['join'] }, 'doJoin') - .mockReturnValue(joinTask.promise); + const doJoin = mockJoin().mockReturnValue(joinTask.promise); const firstJoin = call.join(); const secondJoin = call.join(); @@ -121,4 +169,426 @@ describe('Call lifecycle wiring', () => { undefined, ]); }); + + // Cover both a retryable attempt and the last allowed attempt. + it.each([3, 1])( + 'leave() cancels an in-flight join with maxJoinRetries=%s', + async (maxJoinRetries) => { + const callingX = installCallingX(); + const pending = promiseWithResolvers(); + const doJoin = mockJoin().mockImplementationOnce(async () => { + await pending.promise; + throw new Error('join response timeout'); + }); + + const task = call.join({ maxJoinRetries }); + await vi.waitFor(() => expect(doJoin).toHaveBeenCalledTimes(1)); + await call.leave(); + expect(call.state.callingState).toBe(CallingState.LEFT); + pending.resolve(); + + await expect(task).resolves.toBeUndefined(); + expect(doJoin).toHaveBeenCalledTimes(1); + expect(call.state.callingState).toBe(CallingState.LEFT); + expect(callingX.endCall).not.toHaveBeenCalledWith(call, 'error'); + }, + ); + + it('call.join() stops retrying when leave() lands during the backoff', async () => { + const backoff = promiseWithResolvers(); + sleepControl.onSleep = () => backoff.promise; + const doJoin = mockJoin().mockRejectedValue(new Error('transient failure')); + + const joinTask = call.join({ maxJoinRetries: 3 }); + await vi.waitFor(() => expect(doJoin).toHaveBeenCalledTimes(1)); + + // the user leaves while the retry loop waits out its backoff + await call.leave(); + backoff.resolve(); + + await expect(joinTask).resolves.toBeUndefined(); + expect(doJoin).toHaveBeenCalledTimes(1); + expect(call.state.callingState).toBe(CallingState.LEFT); + }); + + it.each(['callingX.joinCall', 'setup'] as const)( + 'call.join() stops before registration when leave() lands during %s', + async (phase) => { + const pending = promiseWithResolvers(); + const callingX = installCallingX(); + // Keep real setup for the native-join case to detect reinitialization. + const setup = vi.spyOn(call, 'setup'); + const paused = phase === 'setup' ? setup : callingX.joinCall; + paused.mockReturnValue(pending.promise); + const registerCall = vi.spyOn(call.clientEventReporter, 'registerCall'); + const doJoin = vi + .spyOn(internalCall, 'doJoin') + .mockResolvedValue(undefined); + + const task = call.join(); + await vi.waitFor(() => expect(paused).toHaveBeenCalled()); + await call.leave(); + expect(call.state.callingState).toBe(CallingState.LEFT); + pending.resolve(); + + await expect(task).resolves.toBeUndefined(); + if (phase === 'callingX.joinCall') expect(setup).not.toHaveBeenCalled(); + expect(registerCall).not.toHaveBeenCalled(); + expect(doJoin).not.toHaveBeenCalled(); + expect(call.state.callingState).toBe(CallingState.LEFT); + expect(callingX.endCall).not.toHaveBeenCalledWith(call, 'error'); + }, + ); + + it.each([ + 'media factory', + 'location hint', + 'coordinator request', + 'coordinator rejection', + 'acceptance', + 'generic SDP', + 'SFU join', + 'SFU rejection', + ] as const)('call.join() stops after leave() during %s', async (phase) => { + const pending = promiseWithResolvers(); + const stage = + phase === 'coordinator rejection' + ? 'coordinator request' + : phase === 'SFU rejection' + ? 'SFU join' + : phase; + const responseAt = async (step: typeof stage, response: T) => { + if (stage === step) { + await pending.promise; + if (phase.endsWith('rejection')) throw new Error('Join failed'); + } + return response; + }; + vi.spyOn(call, 'setup').mockResolvedValue(undefined); + const mediaFactory = vi.spyOn(call, 'ensureMediaFactory'); + const callingX = + phase === 'media factory' || phase === 'SFU rejection' + ? installCallingX() + : undefined; + if (phase === 'media factory') { + mediaFactory.mockImplementation(() => + responseAt('media factory', fromPartial({})), + ); + } + const locationHint = vi + .spyOn(call.streamClient, 'getLocationHint') + .mockImplementation(() => responseAt('location hint', 'AMS')); + vi.spyOn(call.streamClient, '_hasConnectionID').mockReturnValue(true); + const updateState = vi.spyOn(call.state, 'updateFromCallResponse'); + const accept = vi + .spyOn(call, 'accept') + .mockImplementation(() => responseAt('acceptance', { duration: '0ms' })); + const registerCall = vi.spyOn(call.clientStore, 'registerOrUpdateCall'); + const request = vi.spyOn(call.streamClient, 'post').mockImplementation(() => + responseAt( + 'coordinator request', + fromPartial({ + call: { egress: {}, custom: {}, created_by: { id: 'other-user' } }, + members: [], + own_capabilities: [], + stats_options: { enable_rtc_stats: false }, + }), + ), + ); + const genericSdp = vi + .spyOn(rtc, 'getGenericSdp') + .mockImplementation(() => responseAt('generic SDP', 'sdp')); + const updateSfuState = vi + .spyOn(call.state, 'updateFromSfuCallState') + .mockImplementation(() => {}); + const joinSfu = vi.fn(() => + responseAt('SFU join', { + callState: {}, + publishOptions: [], + fastReconnectDeadlineSeconds: 123, + }), + ); + const closeSfu = vi.fn(); + const createSfu = vi + .spyOn(sfu, 'StreamSfuClient') + .mockImplementation(function () { + return fromPartial({ + sessionId: 'test-session', + join: joinSfu, + close: closeSfu, + leaveAndClose: vi.fn().mockResolvedValue(undefined), + }); + }); + + const joinTask = call.join({ ring: true, maxJoinRetries: 1 }); + const pausedOperation = { + 'media factory': mediaFactory, + 'location hint': locationHint, + 'coordinator request': request, + acceptance: accept, + 'generic SDP': genericSdp, + 'SFU join': joinSfu, + }[stage]; + await vi.waitFor(() => expect(pausedOperation).toHaveBeenCalled()); + expect(call.state.callingState).toBe(CallingState.JOINING); + await call.leave(); + const publishOptions = call['currentPublishOptions']; + const reconnectDeadline = call['fastReconnectDeadlineSeconds']; + pending.resolve(); + + await expect(joinTask).resolves.toBeUndefined(); + if (phase === 'media factory') { + expect(callingX!.wireAudioEngineSubscription).not.toHaveBeenCalled(); + } + if (phase === 'media factory' || phase === 'location hint') { + expect(request).not.toHaveBeenCalled(); + } + if ( + phase === 'coordinator request' || + phase === 'media factory' || + phase === 'location hint' + ) { + expect(createSfu).not.toHaveBeenCalled(); + expect(updateState).not.toHaveBeenCalled(); + expect(accept).not.toHaveBeenCalled(); + expect(registerCall).not.toHaveBeenCalled(); + } + if (phase === 'acceptance') expect(registerCall).not.toHaveBeenCalled(); + expect(joinSfu).toHaveBeenCalledTimes( + phase === 'SFU join' || phase === 'SFU rejection' ? 1 : 0, + ); + expect(updateSfuState).not.toHaveBeenCalled(); + expect(call['currentPublishOptions']).toBe(publishOptions); + expect(call['fastReconnectDeadlineSeconds']).toBe(reconnectDeadline); + expect(call['sfuClient']).toBeUndefined(); + expect(call.clientStore.calls).not.toContain(call); + expect(call.ringing).toBe(false); + expect(call.state.callingState).toBe(CallingState.LEFT); + if (phase === 'SFU rejection') { + expect(closeSfu).not.toHaveBeenCalled(); + expect(callingX!.endCall).not.toHaveBeenCalledWith(call, 'error'); + } + }); + + it.each([ + ['FAST', 'join'], + ['REJOIN', 'join'], + ['MIGRATE', 'join'], + ['MIGRATE', 'migration'], + ] as const)( + 'stops %s reconnect after leave during %s', + async (strategy, phase) => { + const pending = promiseWithResolvers(); + const doJoin = vi + .spyOn(internalCall, 'doJoin') + .mockImplementation(async () => { + if (phase === 'join') await pending.promise; + }); + const get = vi.spyOn(call, 'get').mockResolvedValue(fromPartial({})); + const restorePublished = vi + .spyOn(internalCall, 'restorePublishedTracks') + .mockResolvedValue(undefined); + const restoreSubscribed = vi + .spyOn(internalCall, 'restoreSubscribedTracks') + .mockImplementation(() => {}); + const oldSfu = fromPartial({ + enterMigration: vi.fn().mockReturnValue(pending.promise), + leaveAndClose: vi.fn().mockResolvedValue(undefined), + close: vi.fn(), + }); + const oldSubscriber = fromPartial(makePeer()); + const oldPublisher = fromPartial(makePeer()); + call['sfuClient'] = oldSfu; + call['subscriber'] = oldSubscriber; + call['publisher'] = oldPublisher; + call.clientStore.registerOrUpdateCall(call); + const task = call['reconnect']( + WebsocketReconnectStrategy[strategy], + 'test', + ); + await vi.waitFor(() => + expect( + phase === 'join' ? doJoin : restoreSubscribed, + ).toHaveBeenCalled(), + ); + await call.leave(); + vi.mocked(oldSubscriber.dispose).mockClear(); + vi.mocked(oldPublisher.dispose).mockClear(); + restoreSubscribed.mockClear(); + pending.resolve(); + await task; + + expect(call.state.callingState).toBe(CallingState.LEFT); + expect(call.clientStore.calls).not.toContain(call); + expect(get).not.toHaveBeenCalled(); + expect(restoreSubscribed).not.toHaveBeenCalled(); + if (phase === 'join') expect(restorePublished).not.toHaveBeenCalled(); + if (strategy === 'MIGRATE') { + expect(oldSubscriber.dispose).toHaveBeenCalledOnce(); + expect(oldPublisher.dispose).toHaveBeenCalledOnce(); + expect(oldSfu.close).toHaveBeenCalledOnce(); + } + }, + ); + + it.each(['reconnect failure', 'failure refresh', 'backoff'] as const)( + 'preserves LEFT when leave happens during %s', + async (phase) => { + const pending = promiseWithResolvers(); + const backoff = vi.fn(() => pending.promise); + if (phase === 'backoff') sleepControl.onSleep = backoff; + const reconnect = vi + .spyOn(internalCall, 'reconnectFast') + .mockImplementation(async () => { + if (phase === 'reconnect failure') await pending.promise; + throw new ErrorFromResponse( + fromPartial({ unrecoverable: phase !== 'backoff' }), + ); + }); + const get = vi.spyOn(call, 'get').mockImplementation(async () => { + await pending.promise; + return fromPartial({}); + }); + const task = call['reconnect'](WebsocketReconnectStrategy.FAST, 'test'); + const paused = { + 'reconnect failure': reconnect, + 'failure refresh': get, + backoff, + }[phase]; + await vi.waitFor(() => expect(paused).toHaveBeenCalled()); + await call.leave(); + pending.resolve(); + await task; + + expect(call.state.callingState).toBe(CallingState.LEFT); + expect(call.clientStore.calls).not.toContain(call); + expect(get).toHaveBeenCalledTimes(phase === 'failure refresh' ? 1 : 0); + expect(reconnect).toHaveBeenCalledOnce(); + expect(call['reconnectStrategy']).toBe( + WebsocketReconnectStrategy.UNSPECIFIED, + ); + }, + ); + + it.each([false, true])( + 'get() respects leave during fetch: %s', + async (leaveDuringFetch) => { + const pending = promiseWithResolvers(); + const response = fromPartial({ call: { settings: {} } }); + const request = vi + .spyOn(call.streamClient, 'get') + .mockReturnValue(pending.promise); + vi.spyOn(call.streamClient, '_hasConnectionID').mockReturnValue(true); + const update = vi + .spyOn(call, 'updateFromCallStateResponse') + .mockImplementation(() => {}); + const configure = vi + .spyOn(internalCall, 'applyDeviceConfig') + .mockResolvedValue(undefined); + await call.leave(); + const task = call.get(); // deliberate reuse must still work + await vi.waitFor(() => expect(request).toHaveBeenCalled()); + if (leaveDuringFetch) await call.leave(); + pending.resolve(response); + await expect(task).resolves.toBe(response); + + expect(call.clientStore.calls.includes(call)).toBe(!leaveDuringFetch); + expect(update).toHaveBeenCalledTimes(leaveDuringFetch ? 0 : 1); + expect(configure).toHaveBeenCalledTimes(leaveDuringFetch ? 0 : 1); + expect(call.state.callingState).toBe( + leaveDuringFetch ? CallingState.LEFT : CallingState.IDLE, + ); + if (!leaveDuringFetch) await call.leave(); + }, + ); + + it.each([ + 'stats flush', + 'subscriber disposal', + 'publisher disposal', + ] as const)( + 'call.join() does not recreate peers after leave() during %s', + async (phase) => { + const pending = promiseWithResolvers(); + vi.spyOn(call, 'setup').mockResolvedValue(undefined); + call['credentials'] = fromPartial({ ice_servers: [] }); + call['lastStatsOptions'] = fromPartial({ reporting_interval_ms: 0 }); + call['sfuClient'] = fromPartial({ + isHealthy: true, + sessionId: 'test-session', + leaveAndClose: vi.fn().mockResolvedValue(undefined), + }); + const flush = vi.fn().mockResolvedValue(undefined); + const disposeSubscriber = vi.fn().mockResolvedValue(undefined); + const disposePublisher = vi.fn().mockResolvedValue(undefined); + call['sfuStatsReporter'] = fromPartial({ flush, stop: vi.fn() }); + call['subscriber'] = fromPartial({ dispose: disposeSubscriber }); + call['publisher'] = fromPartial({ dispose: disposePublisher }); + const pause = { + 'stats flush': flush, + 'subscriber disposal': disposeSubscriber, + 'publisher disposal': disposePublisher, + }[phase]; + // Only pause initialization; leave's own cleanup must remain able to finish. + pause.mockReturnValueOnce(pending.promise); + const createSubscriber = vi + .spyOn(rtc, 'Subscriber') + .mockImplementation(function () { + return fromPartial(makePeer()); + }); + const createPublisher = vi + .spyOn(rtc, 'Publisher') + .mockImplementation(function () { + return fromPartial(makePeer()); + }); + const logInfo = vi.spyOn(call['logger'], 'info'); + + const joinTask = call.join(); + await vi.waitFor(() => expect(pause).toHaveBeenCalledTimes(1)); + const subscriberCount = createSubscriber.mock.calls.length; + const publisherCount = createPublisher.mock.calls.length; + await call.leave(); + pending.resolve(); + + await expect(joinTask).resolves.toBeUndefined(); + expect(createSubscriber).toHaveBeenCalledTimes(subscriberCount); + expect(createPublisher).toHaveBeenCalledTimes(publisherCount); + expect(call['subscriber']).toBeUndefined(); + expect(call['publisher']).toBeUndefined(); + expect(call['sfuStatsReporter']).toBeUndefined(); + expect(call.state.callingState).toBe(CallingState.LEFT); + expect(logInfo).not.toHaveBeenCalledWith(`Joined call ${call.cid}`); + }, + ); + + // Controls: without a leave, the retry loop must behave exactly as before. + it('call.join() still retries a recoverable failure', async () => { + sleepControl.onSleep = () => Promise.resolve(); + const doJoin = mockJoin() + .mockRejectedValueOnce(new Error('transient failure')) + .mockResolvedValue(undefined); + + await expect(call.join({ maxJoinRetries: 3 })).resolves.toBeUndefined(); + expect(doJoin).toHaveBeenCalledTimes(2); + }); + + it('call.join() rejects and ends the call once retries are exhausted', async () => { + const callingX = installCallingX(); + sleepControl.onSleep = () => Promise.resolve(); + const doJoin = mockJoin().mockRejectedValue(new Error('transient failure')); + + await expect(call.join({ maxJoinRetries: 2 })).rejects.toThrow( + 'transient failure', + ); + expect(doJoin).toHaveBeenCalledTimes(2); + expect(callingX.endCall).toHaveBeenCalledWith(call, 'error'); + }); + + it('call.join() is still allowed after a completed leave()', async () => { + const doJoin = mockJoin(); + + await call.leave(); + await expect(call.join()).resolves.toBeUndefined(); + expect(doJoin).toHaveBeenCalledTimes(1); + }); }); From fc9547ad9a10b8af87a82c72b5aefa3e9f483137 Mon Sep 17 00:00:00 2001 From: Artem Grintsevich Date: Mon, 21 Sep 2026 13:38:01 +0200 Subject: [PATCH 2/7] fix: replaced RTCView with camera preview for outgoing call component (#2450) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaced RTCView with new camera preview view for outgoing call component 🎫 Ticket: https://linear.app/stream/issue/RN-427/outgoing-call-component-fix * **Bug Fixes** * Improved the outgoing call screen’s camera preview behavior by reflecting the current camera mute state immediately. * Updated the preview presentation for better consistency with the call lobby experience. Backport note: adapted to `release-v1`, which does not have the shared i18n runtime (#2436): `useI18n` keeps coming from `@stream-io/video-react-bindings` instead of `../../../i18n`. No behaviour change. (cherry picked from commit e8e360b559309267177a64df58385bb67b8a90da) --- .../Call/RingingCallContent/OutgoingCall.tsx | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/packages/react-native-sdk/src/components/Call/RingingCallContent/OutgoingCall.tsx b/packages/react-native-sdk/src/components/Call/RingingCallContent/OutgoingCall.tsx index 3d607312f7..8354ebe3d4 100644 --- a/packages/react-native-sdk/src/components/Call/RingingCallContent/OutgoingCall.tsx +++ b/packages/react-native-sdk/src/components/Call/RingingCallContent/OutgoingCall.tsx @@ -3,12 +3,12 @@ import { StyleSheet, Text, View, type ViewStyle } from 'react-native'; import { UserInfo } from './UserInfo'; import { Z_INDEX } from '../../../constants'; import { useCallStateHooks, useI18n } from '@stream-io/video-react-bindings'; -import { MediaStream, RTCView } from '@stream-io/react-native-webrtc'; import { useTheme } from '../../../contexts/ThemeContext'; import { OutgoingCallControls as DefaultOutgoingCallControls, type OutgoingCallControlsProps, } from '../CallControls'; +import { LobbyCameraPreview } from '../Lobby'; /** * Props for the OutgoingCall Component. @@ -107,11 +107,9 @@ const Background = () => { theme: { colors, outgoingCall }, } = useTheme(); const { useCameraState } = useCallStateHooks(); - const { isMute, camera } = useCameraState(); - const localVideoStream = camera.state.mediaStream as unknown as - MediaStream | undefined; + const { optimisticIsMute } = useCameraState(); - if (isMute || !localVideoStream) { + if (optimisticIsMute) { return ( { outgoingCall.background, ]} > - + ); }; From 7cc5677d2e27885f71caba128c5e06a34edbba6a Mon Sep 17 00:00:00 2001 From: Santhosh Vaiyapuri <3846977+santhoshvai@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:37:46 +0200 Subject: [PATCH 3/7] fix(react-native-callingx): use : as the Android Telecom address (#2453) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### πŸ’‘ Overview On ColorOS / Realme / Oppo devices, registering a Telecom call whose address has a null scheme crashes `system_server` and reboots the device (`PhoneNumberUtilsExtImpl.getNumberFromIntent` NPE β†’ `UserCallIntentProcessor.blockAndLaunchSystemDialer`). callingx built the address with `String.toUri()` from an opaque Stream user id, which parses with `scheme == null`. Reproduced with `enableOngoingCalls: true`; Pixel/Samsung are unaffected. `main` (1.0.0-beta) still ships this. This PR: 1. Cherry-picks the contributor's fix from #2452 (authorship preserved), which introduced `toTelecomAddress` and a `sip:` scheme. 2. Aligns the Android address with the Stream Android SDK: scheme = app package name, so the address is `:`. ### πŸ“ Implementation notes **Why the package name instead of `sip:`.** Nothing displays the address for self-managed calls; Telecom only needs a non-null scheme. AOSP `PhoneNumberUtils.getNumberFromIntent` returns `null` for an unknown scheme, whereas `sip:`/`tel:` make Telecom treat the handle as a dialable number. This is exactly what the Android SDK does (`ServiceLauncher`: `"$appSchema:${callId.id}".toUri()`, with the demo app passing `TelecomConfig(context.packageName)`). The handle is always wrapped, never parsed: parsing would promote anything before a `:` in the handle to the scheme, and the address is never displayed or dialed. With every address carrying the package-name scheme, the `tel`/`mailto` `Person.setUri` branch in `CallNotificationManager` could no longer fire and is removed. Contact lookup for real phone numbers can return later as an explicit `handleType`-driven scheme (`tel:`/`mailto:`), alongside the UUID-based `ACTION_CALL_BACK` work for core-telecom 1.1 unified call history. **Per-platform handle, decided in JS.** `getCallingxCallArgs` in the RN SDK is the single source of truth for what each platform receives as `phoneNumber`; Kotlin only guarantees a non-null scheme. | SDK | Handle / address | | --- | --- | | stream-video-swift | `CXHandle(.generic, created_by_id)` β€” visible in Recents, used for call-back | | stream-video-android | `:` β€” never displayed | | callingx iOS (unchanged) | `CXHandle(.generic, createdBy.id ?? displayName)` | | callingx Android (this PR) | `:`; JS sends `call.id`, the FCM push path (no JS) uses `call_cid` after the `:` | The call type is stripped because `"default:abc".toUri()` would make `default` the scheme. **Side effect.** `CallNotificationManager.createPerson` uses `Person.setKey(address.toString())`, so the key moves from per-user to per-call. Harmless for CallStyle notifications. **Verification.** callingx Kotlin compile with zero warnings, `yarn lint:ci:packages`, callingx `typecheck`, `yarn test:react-native:sdk`. No Android test source set exists in callingx; on-device ColorOS verification with `enableOngoingCalls: true` is still pending, hence draft. **Out of scope.** The fate of #2452 on `release-v1` (merge as a 0.11.x hotfix or close in favour of this) is decided separately. ## Summary by CodeRabbit * **Bug Fixes** * Improved Android call addressing for devices requiring valid Telecom addresses. * Android Telecom routing now uses the call ID, while iOS continues using the caller handle for CallKit display and recents. * Standardized Android call handles as app-scoped Telecom addresses, including incoming calls and empty or scheme-based handles. * Android caller entries now use stable call keys without platform contact-URI lookup. * **Documentation** * Clarified `phoneNumber` handling and platform-specific call address behavior. --------- Co-authored-by: sujeet kumar (cherry picked from commit 22d18d8b55bab81946989d791b5fa18569428712) --- packages/react-native-callingx/README.md | 2 ++ .../io/getstream/rn/callingx/CallService.kt | 11 ++++-- .../rn/callingx/CallingxModuleImpl.kt | 5 ++- .../notifications/CallNotificationManager.kt | 36 +++++-------------- .../src/utils/internal/callingx/callingx.ts | 7 +++- 5 files changed, 27 insertions(+), 34 deletions(-) diff --git a/packages/react-native-callingx/README.md b/packages/react-native-callingx/README.md index 12f48fc3dc..00ab44c1d6 100644 --- a/packages/react-native-callingx/README.md +++ b/packages/react-native-callingx/README.md @@ -58,6 +58,8 @@ await CallingxModule.displayIncomingCall( - `acquireBackgroundTask(owner)` / `releaseBackgroundTask(owner)` (Android) β€” ref-counted keep-alive task that keeps the JS runtime/timers alive in the background; the underlying HeadlessJS task starts on the first acquire and stops once all owners release. - `stopService()` (Android) β€” asks the call service to stop. A request, not a command: the service hosts every call, so it stays alive while any call is registered or is being registered. Use `endCallWithReason(callId, reason)` to tear down an individual call. +`phoneNumber` is the call handle. iOS uses it as the CallKit `CXHandle`. Android always wraps it as the opaque Telecom address `:` (a scheme-less address reboots ColorOS devices); it is never displayed or dialed. The Stream Video SDK passes the caller's user id on iOS and the call id on Android. + ## Event names Call events: diff --git a/packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/CallService.kt b/packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/CallService.kt index 4b19f80ac2..ba7bad42d0 100644 --- a/packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/CallService.kt +++ b/packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/CallService.kt @@ -16,7 +16,6 @@ import android.os.IBinder import android.telecom.DisconnectCause import android.util.Log import androidx.core.content.ContextCompat -import androidx.core.net.toUri import io.getstream.rn.callingx.model.Call import io.getstream.rn.callingx.model.CallAction import io.getstream.rn.callingx.notifications.CallNotificationManager @@ -58,6 +57,13 @@ class CallService : Service(), CallRepository.Listener { internal const val EXTRA_CALL_ID = "extra_call_id" internal const val EXTRA_NAME = "extra_name" internal const val EXTRA_URI = "extra_uri" + /** + * A null-scheme address reboots ColorOS devices (Telecom NPE), so the handle is wrapped + * with the package name like the Android SDK ("$packageName:$callId"). The address is + * never displayed or dialed. + */ + internal fun toTelecomAddress(context: Context, handle: String): Uri = + Uri.fromParts(context.packageName, handle, null) internal const val EXTRA_IS_VIDEO = "extra_is_video" internal const val EXTRA_DISPLAY_TITLE = "displayTitle" internal const val EXTRA_DISPLAY_OPTIONS = "display_options" @@ -118,7 +124,6 @@ class CallService : Service(), CallRepository.Listener { return } - val createdById = data["created_by_id"] val createdName = data["created_by_display_name"].orEmpty() val displayName = data["call_display_name"].orEmpty() val callDisplayName = displayName.ifEmpty { createdName.ifEmpty { DEFAULT_DISPLAY_NAME } } @@ -131,7 +136,7 @@ class CallService : Service(), CallRepository.Listener { Intent(context, CallService::class.java).apply { action = ACTION_INCOMING_CALL putExtra(EXTRA_CALL_ID, callCid) - putExtra(EXTRA_URI, createdById?.toUri() ?: callDisplayName.toUri()) + putExtra(EXTRA_URI, toTelecomAddress(context, callCid.substringAfter(':'))) putExtra(EXTRA_NAME, callDisplayName) putExtra(EXTRA_IS_VIDEO, isVideo) } diff --git a/packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/CallingxModuleImpl.kt b/packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/CallingxModuleImpl.kt index 8e7804f048..d0c6b2c766 100644 --- a/packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/CallingxModuleImpl.kt +++ b/packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/CallingxModuleImpl.kt @@ -7,7 +7,6 @@ import android.os.ParcelUuid import android.telecom.DisconnectCause import android.util.Log import androidx.core.content.ContextCompat -import androidx.core.net.toUri import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext @@ -280,7 +279,7 @@ class CallingxModuleImpl( this.action = CallService.ACTION_UPDATE_CALL putExtra(CallService.EXTRA_CALL_ID, callId) putExtra(CallService.EXTRA_NAME, callerName) - putExtra(CallService.EXTRA_URI, phoneNumber.toUri()) + putExtra(CallService.EXTRA_URI, CallService.toTelecomAddress(reactApplicationContext, phoneNumber)) putExtra(CallService.EXTRA_IS_VIDEO, true) putExtra(CallService.EXTRA_DISPLAY_OPTIONS, Arguments.toBundle(displayOptions)) } @@ -419,7 +418,7 @@ class CallingxModuleImpl( this.action = action putExtra(CallService.EXTRA_CALL_ID, callId) putExtra(CallService.EXTRA_NAME, callerName) - putExtra(CallService.EXTRA_URI, phoneNumber.toUri()) + putExtra(CallService.EXTRA_URI, CallService.toTelecomAddress(reactApplicationContext, phoneNumber)) putExtra(CallService.EXTRA_IS_VIDEO, hasVideo) putExtra(CallService.EXTRA_DISPLAY_OPTIONS, Arguments.toBundle(displayOptions)) } diff --git a/packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/notifications/CallNotificationManager.kt b/packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/notifications/CallNotificationManager.kt index 8faac7918c..c723026889 100644 --- a/packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/notifications/CallNotificationManager.kt +++ b/packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/notifications/CallNotificationManager.kt @@ -41,17 +41,6 @@ class CallNotificationManager( internal companion object { private const val TAG = "[Callingx] CallNotificationManager" private const val DISABLED_COLOR = "#757575" // NOTE: hint color might be ignored by OS - - /** - * Schemes the platform documents as resolvable for `Person.setUri()`: "tel:" is looked up - * through PhoneLookup and "mailto:" through the contacts email column. - * - * A contacts `CONTENT_LOOKUP_URI` is also resolvable, but any "content:" URI in a - * notification is run through the system's URI grant check, which throws a - * SecurityException from notify() when the app cannot grant it. Since no caller passes - * one, it is left out rather than risking that crash. - */ - private val CONTACT_URI_SCHEMES = setOf("tel", "mailto") } enum class OptimisticState { NONE, ACCEPTING, REJECTING } @@ -491,22 +480,15 @@ class CallNotificationManager( .firstOrNull { it.isNotEmpty() } ?: CallService.DEFAULT_DISPLAY_NAME - val builder = - Person.Builder() - .setName(name) - .setKey(address.toString()) - .setIcon(IconCompat.createWithResource(context, R.drawable.ic_user)) - .setImportant(true) - - // setUri() feeds the platform's contact lookup, so it only makes sense for a handle the - // provider can resolve (e.g. "tel:+15551234"). Opaque ids such as Stream user ids never - // match a contact, and are carried by setKey() above instead. See CONTACT_URI_SCHEMES. - val scheme = address.scheme?.lowercase() - if (scheme != null && scheme in CONTACT_URI_SCHEMES) { - builder.setUri(address.toString()) - } - - return builder.build() + // The address is an opaque `:` URI (see toTelecomAddress), so it + // only identifies the Person via setKey(); setUri() would feed the platform's contact + // lookup, which can never resolve it. + return Person.Builder() + .setName(name) + .setKey(address.toString()) + .setIcon(IconCompat.createWithResource(context, R.drawable.ic_user)) + .setImportant(true) + .build() } } 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 b9cb20e200..2939055d8b 100644 --- a/packages/react-native-sdk/src/utils/internal/callingx/callingx.ts +++ b/packages/react-native-sdk/src/utils/internal/callingx/callingx.ts @@ -67,9 +67,14 @@ function getCallingxCallArgs(call: Call): [string, string, string, boolean] { call.state.participants, call.currentUserId, ); + const createdByHandle = call.state.createdBy?.id ?? callDisplayName; return [ call.cid, // unique id for call - call.state.createdBy?.id ?? callDisplayName, // handle for native call UI (prefer createdBy user id, fallback to call display name) + // handle for the native call UI. On iOS it is shown in CallKit Recents and used for + // call-back, so we send the caller's user id (matches the Swift SDK). On Android the + // self-managed Telecom address is never displayed and callingx wraps it as + // `:`, so we send the call id (matches the Android SDK). + Platform.OS === 'android' ? call.id : createdByHandle, callDisplayName, // display name for display in call screen call.state.settings?.video?.enabled ?? false, // is video call? ]; From e222e44c229bd62a90aef7fd0ebcb5b77ca870b9 Mon Sep 17 00:00:00 2001 From: Santhosh Vaiyapuri <3846977+santhoshvai@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:28:27 +0200 Subject: [PATCH 4/7] fix(react-native): iOS Picture in Picture window size must be reported (#2448) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### πŸ’‘ Overview On iOS, the native Picture in Picture window renders one participant's track, but the SFU was still asked for the dimensions of the hidden inline layout behind it instead of the actual PiP window size. This PR reports the actual window bounds to the SFU while PiP is on screen, stops the hidden inline views from overwriting them, and hands the demand back to the inline views when PiP ends. Android subscription behaviour is unchanged. ### πŸ“ Implementation notes **Why it needs coordination.** Each participant has one `videoDimension` / `screenShareDimension` slot per track type, and every mounted `TrackSubscriber` writes into it, last writer wins. The PiP window is one more writer for the same slot. **Native (`RTCViewPip.swift`, `RTCViewPipManager.mm`)** * New `onPiPBoundsChange` event with the laid out window size in logical points, truncated and deduplicated, wired through the existing `onSizeUpdate` of the PiP controller. * Controller callbacks check that they still belong to the current controller, and are cleared in `onCallClosed`, so a disposed controller cannot emit. **JS** * `iosPipTrack.ts`: one `BehaviorSubject` per `Call` instance holding the track the PiP window currently renders. `setIosPipTrack` clears the previous track's demand *before* flipping the subject, so inline views replay their cached layout synchronously and overwrite the clear. If no inline view is mounted, the clear stands and the SFU stops sending that track. * `TrackSubscriber`: on iOS, one extra `combineLatest` input gates writes. Inline subscribers stop writing while their track is owned by PiP; the PiP writer (`isPipWriter`) only writes while it owns the track. The writer releases ownership in a microtask so sibling inline views finish unmounting first on whole-screen teardown. * `RTCViewPipIOS`: feeds the bounds event into a `BehaviorSubject` and renders a `TrackSubscriber` with `isPipWriter` for the spotlight participant while PiP is active (never for the local participant). The native view is keyed per `Call` instance and the handlers are bound to that key, so events queued by a replaced call's controller are rejected. Lifecycle events are deduplicated before reaching `isInPiPMode$` and `onPiPChange`. * `CallContent`: renders `RTCViewPipIOS` on iOS only. **Tests** * `RTCViewPipIOS.test.tsx`: bounds win over hidden layout (start-first and bounds-first), demand transfer between participants and from camera to screen share, clearing a track without an inline view, local-only PiP, hand-back on unmount / `call.ended` / `LEFT`, teardown ordering, restart during a pending stop, per-`Call` isolation with the same cid, and rejection of stale events after call replacement. * `TrackSubscriber.test.tsx`: PiP writer follows publication and rejoin, inline visibility, and unchanged Android behaviour. **Validation** SDK lint, Prettier, spec type check, and the RN SDK test suite pass. Coverage of the touched files is unchanged. Physical-device validation is still pending. 🎫 Ticket: https://linear.app/stream/issue/XYZ-123 πŸ“‘ Docs: https://github.com/GetStream/docs-content/pull/ ## Summary by CodeRabbit * **Bug Fixes** * iOS Picture-in-Picture now waits for the PiP window’s dimensions before requesting video quality, and updates sizing when the window is resized. * Video demand transfers more reliably between inline and PiP views when participants or shared content change. * Events from a closed or replaced PiP window no longer affect the active call. * PiP lifecycle callbacks now report state changes consistently after calls end or are replaced. * **Platform Compatibility** * The iOS-specific PiP view is rendered only on iOS when PiP is enabled. (cherry picked from commit b7e4165113ad3277d2b32fab690ecef81b582b4a) --- .../components/RTCViewPipIOS.test.tsx | 411 ++++++++++++++++++ .../components/TrackSubscriber.test.tsx | 175 +++++++- .../react-native-sdk/ios/RTCViewPip.swift | 41 +- .../react-native-sdk/ios/RTCViewPipManager.mm | 1 + .../Call/CallContent/CallContent.tsx | 2 +- .../Call/CallContent/RTCViewPipIOS.tsx | 102 ++++- .../Call/CallContent/RTCViewPipNative.tsx | 13 + .../VideoRenderer/TrackSubscriber.tsx | 63 ++- .../src/utils/internal/iosPipTrack.ts | 39 ++ .../react-native/dogfood/ios/Podfile.lock | 188 ++++---- 10 files changed, 902 insertions(+), 133 deletions(-) create mode 100644 packages/react-native-sdk/__tests__/components/RTCViewPipIOS.test.tsx create mode 100644 packages/react-native-sdk/src/utils/internal/iosPipTrack.ts diff --git a/packages/react-native-sdk/__tests__/components/RTCViewPipIOS.test.tsx b/packages/react-native-sdk/__tests__/components/RTCViewPipIOS.test.tsx new file mode 100644 index 0000000000..ff34f4d6ba --- /dev/null +++ b/packages/react-native-sdk/__tests__/components/RTCViewPipIOS.test.tsx @@ -0,0 +1,411 @@ +import React from 'react'; +import { UIManager } from 'react-native'; +import { act } from '@testing-library/react-native'; +import { + type Call, + CallingState, + SfuModels, + type StreamVideoParticipant, +} from '@stream-io/video-client'; +import { BehaviorSubject } from 'rxjs'; +import { StreamCallProvider } from '@stream-io/video-react-bindings'; +import { render, screen } from '../utils/RNTLTools'; +import { RTCViewPipIOS } from '../../src/components/Call/CallContent/RTCViewPipIOS'; +import { RTCViewPipNative } from '../../src/components/Call/CallContent/RTCViewPipNative'; +import TrackSubscriber from '../../src/components/Participant/ParticipantView/VideoRenderer/TrackSubscriber'; +import { isInPiPMode$ } from '../../src/utils/internal/rxSubjects'; +import mockParticipant from '../mocks/participant'; +import { mockCall } from '../mocks/call'; +import { mockClientWithUser } from '../mocks/client'; + +jest.useFakeTimers(); + +// the view manager only exists in a real app; the commands the component +// dispatches to it are irrelevant to the subscription demand under test. +jest.spyOn(UIManager, 'getViewManagerConfig').mockReturnValue({ + Commands: { onCallClosed: 1, setPreferredContentSize: 2 }, +} as any); + +const sessionId = 'remote-session-1'; +const otherSessionId = 'remote-session-2'; +const inlineLayout = { width: 390, height: 725 }; +const pipBounds = { width: 180, height: 240 }; + +const remoteParticipant = (custom?: Partial) => + mockParticipant({ + sessionId, + publishedTracks: [SfuModels.TrackType.VIDEO], + ...custom, + }); + +const joinedCall = ( + participants: StreamVideoParticipant[] = [remoteParticipant()], + client = mockClientWithUser({ id: 'test-user-id' }), +): Call => { + const call = mockCall(client, participants); + call.state.setCallingState(CallingState.JOINED); + return call; +}; + +/** Counts the subscription updates actually sent to the SFU. */ +const mockSfuClient = (call: Call) => { + const updateSubscriptions = jest.fn().mockResolvedValue({}); + call.trackSubscriptionManager.setSfuClient({ updateSubscriptions } as any); + return updateSubscriptions; +}; + +const dimensionOf = ( + call: Call, + session = sessionId, + trackType = SfuModels.TrackType.VIDEO, +) => + call.trackSubscriptionManager.subscriptions.find( + (subscription) => + subscription.sessionId === session && + subscription.trackType === trackType, + )?.dimension; + +/** Drives the native events of the rendered picture in picture view. */ +const pipView = () => { + const props = screen.UNSAFE_getByType(RTCViewPipNative).props; + return { + change: (active: boolean) => + act(() => props.onPiPChange({ nativeEvent: { active } })), + bounds: (width: number, height: number) => + act(() => props.onPiPBoundsChange({ nativeEvent: { width, height } })), + }; +}; + +const dimensions$ = (dimension?: SfuModels.VideoDimension) => + new BehaviorSubject(dimension); + +const inlineSubscriber = ( + call: Call, + inline$: BehaviorSubject, + session = sessionId, +) => ( + +); + +const renderPip = ( + call: Call, + inline$?: BehaviorSubject, + props: React.ComponentProps = {}, +) => + render( + <> + + {inline$ && inlineSubscriber(call, inline$)} + , + { call }, + ); + +/** Lets the debounced participant selection and the debounced apply run. */ +const settle = () => act(() => jest.advanceTimersByTime(1000)); + +describe('RTCViewPipIOS', () => { + beforeEach(() => { + isInPiPMode$.next(false); + }); + + it.each(['start-first', 'bounds-first'])( + 'requests the native bounds instead of the hidden inline layout (%s)', + (order) => { + const call = joinedCall(); + const updates = mockSfuClient(call); + const inline$ = dimensions$(inlineLayout); + renderPip(call, inline$); + settle(); + expect(dimensionOf(call)).toEqual(inlineLayout); + + if (order === 'start-first') { + pipView().change(true); + settle(); + // nothing to request until the window reports its bounds. + expect(dimensionOf(call)).toEqual(inlineLayout); + } + pipView().bounds(pipBounds.width, pipBounds.height); + if (order === 'bounds-first') pipView().change(true); + settle(); + expect(dimensionOf(call)).toEqual(pipBounds); + + // the hidden inline layout can no longer overwrite the window. + act(() => inline$.next({ width: 390, height: 800 })); + settle(); + expect(dimensionOf(call)).toEqual(pipBounds); + + pipView().bounds(320, 180); + settle(); + expect(dimensionOf(call)).toEqual({ width: 320, height: 180 }); + + // leaving PiP hands the track back to the inline view in one update. + updates.mockClear(); + pipView().change(false); + settle(); + expect(dimensionOf(call)).toEqual({ width: 390, height: 800 }); + expect(updates).toHaveBeenCalledTimes(1); + expect(updates).toHaveBeenLastCalledWith([ + expect.objectContaining({ dimension: { width: 390, height: 800 } }), + ]); + }, + ); + + it('transfers the demand between participants and clears a track without an inline view', () => { + const call = joinedCall([ + remoteParticipant(), + remoteParticipant({ sessionId: otherSessionId, videoStream: undefined }), + ]); + const updates = mockSfuClient(call); + const inline$ = dimensions$(inlineLayout); + renderPip(call, inline$); + pipView().change(true); + pipView().bounds(pipBounds.width, pipBounds.height); + settle(); + expect(dimensionOf(call)).toEqual(pipBounds); + expect(dimensionOf(call, otherSessionId)).toBeUndefined(); + + const switchSpotlight = () => { + act(() => + call.state.setParticipants((participants) => + [...participants].reverse(), + ), + ); + settle(); + }; + + // the other participant has no inline view: it is subscribed from PiP only. + switchSpotlight(); + expect(dimensionOf(call, otherSessionId)).toEqual(pipBounds); + expect(dimensionOf(call)).toEqual(inlineLayout); + + // and dropped again when the window moves on. + switchSpotlight(); + expect(dimensionOf(call)).toEqual(pipBounds); + expect(dimensionOf(call, otherSessionId)).toBeUndefined(); + + pipView().change(false); + settle(); + expect(dimensionOf(call)).toEqual(inlineLayout); + expect(updates).toHaveBeenLastCalledWith([ + expect.objectContaining({ sessionId, dimension: inlineLayout }), + ]); + }); + + it('transfers the demand from the camera to the screen share track', () => { + const call = joinedCall(); + mockSfuClient(call); + const inline$ = dimensions$(inlineLayout); + renderPip(call, inline$); + pipView().change(true); + pipView().bounds(pipBounds.width, pipBounds.height); + settle(); + expect(dimensionOf(call)).toEqual(pipBounds); + + const publish = (...tracks: SfuModels.TrackType[]) => { + act(() => + call.state.updateParticipant(sessionId, { publishedTracks: tracks }), + ); + settle(); + }; + const screenShare = () => + dimensionOf(call, sessionId, SfuModels.TrackType.SCREEN_SHARE); + + publish(SfuModels.TrackType.VIDEO, SfuModels.TrackType.SCREEN_SHARE); + expect(screenShare()).toEqual(pipBounds); + expect(dimensionOf(call)).toEqual(inlineLayout); + + publish(SfuModels.TrackType.VIDEO); + expect(dimensionOf(call)).toEqual(pipBounds); + expect(screenShare()).toBeUndefined(); + }); + + it('deduplicates lifecycle transitions and uses the latest callback', () => { + const call = joinedCall(); + const firstCallback = jest.fn(); + const nextCallback = jest.fn(); + const { rerender } = renderPip(call, undefined, { + onPiPChange: firstCallback, + }); + pipView().change(true); + pipView().change(true); + pipView().bounds(pipBounds.width, pipBounds.height); + pipView().bounds(320, 180); + expect(firstCallback.mock.calls).toEqual([[true]]); + expect(isInPiPMode$.getValue()).toBe(true); + rerender(); + expect(dimensionOf(call)).toEqual({ width: 320, height: 180 }); + pipView().change(false); + pipView().change(false); + expect(nextCallback.mock.calls).toEqual([[false]]); + expect(isInPiPMode$.getValue()).toBe(false); + }); + + it('creates no subscription for a local only picture in picture', () => { + const call = joinedCall([ + mockParticipant({ + sessionId: 'local-session', + isLocalParticipant: true, + publishedTracks: [SfuModels.TrackType.VIDEO], + }), + ]); + const updates = mockSfuClient(call); + render(, { call }); + pipView().change(true); + pipView().bounds(pipBounds.width, pipBounds.height); + settle(); + expect(call.trackSubscriptionManager.subscriptions).toHaveLength(0); + expect(updates).not.toHaveBeenCalled(); + }); + + it('gives the track back when the view is unmounted', () => { + const call = joinedCall(); + mockSfuClient(call); + const inline$ = dimensions$(inlineLayout); + const { rerender } = renderPip(call, inline$); + pipView().change(true); + pipView().bounds(pipBounds.width, pipBounds.height); + settle(); + expect(dimensionOf(call)).toEqual(pipBounds); + + rerender(<>{inlineSubscriber(call, inline$)}); + settle(); + expect(dimensionOf(call)).toEqual(inlineLayout); + expect(isInPiPMode$.getValue()).toBe(false); + }); + + it.each(['call.ended event', 'LEFT calling state'])( + 'gives the track back on %s and ignores later native events', + (reason) => { + const call = joinedCall(); + mockSfuClient(call); + const onPiPChange = jest.fn(); + let endCall: (() => void) | undefined; + const originalOn = call.on.bind(call); + jest.spyOn(call, 'on').mockImplementation(((name: any, cb: any) => { + if (name === 'call.ended') endCall = cb; + return originalOn(name, cb); + }) as typeof call.on); + const inline$ = dimensions$(inlineLayout); + renderPip(call, inline$, { onPiPChange }); + const view = pipView(); + view.change(true); + view.bounds(pipBounds.width, pipBounds.height); + settle(); + expect(dimensionOf(call)).toEqual(pipBounds); + + act(() => { + if (reason === 'call.ended event') endCall?.(); + else call.state.setCallingState(CallingState.LEFT); + }); + expect(isInPiPMode$.getValue()).toBe(false); + expect(onPiPChange.mock.calls).toEqual([[true], [false]]); + + // the disposed window is not valid geometry anymore. + act(() => call.state.setCallingState(CallingState.JOINED)); + view.change(true); + view.bounds(400, 500); + settle(); + expect(dimensionOf(call)).toEqual(inlineLayout); + expect(onPiPChange).toHaveBeenCalledTimes(2); + }, + ); + + it('does not let a pending stop clear a restarted PiP subscription', () => { + const call = joinedCall(); + renderPip(call); + pipView().change(true); + pipView().bounds(pipBounds.width, pipBounds.height); + pipView().change(false); + pipView().change(true); + pipView().bounds(200, 300); + settle(); + expect(dimensionOf(call)).toEqual({ width: 200, height: 300 }); + }); + + it.each([false, true])( + 'releases PiP demand when both views unmount (inline first: %s)', + (inlineFirst) => { + const call = joinedCall(); + const updates = mockSfuClient(call); + const inline = inlineSubscriber(call, dimensions$(inlineLayout)); + const { unmount } = render( + <> + {inlineFirst && inline} + + {!inlineFirst && inline} + , + { call }, + ); + pipView().change(true); + pipView().bounds(pipBounds.width, pipBounds.height); + settle(); + unmount(); + settle(); + expect(dimensionOf(call)).toBeUndefined(); + expect(updates).toHaveBeenLastCalledWith([]); + expect(isInPiPMode$.getValue()).toBe(false); + }, + ); + + it('keeps the demand of each Call instance apart, even with the same cid', () => { + const client = mockClientWithUser({ id: 'test-user-id' }); + const call = joinedCall([remoteParticipant()], client); + const otherCall = joinedCall([remoteParticipant()], client); + expect(otherCall.cid).toBe(call.cid); + mockSfuClient(call); + mockSfuClient(otherCall); + const otherInline$ = dimensions$(inlineLayout); + const { rerender } = render( + <> + + {inlineSubscriber(otherCall, otherInline$)} + , + { call }, + ); + pipView().change(true); + pipView().bounds(pipBounds.width, pipBounds.height); + settle(); + expect(dimensionOf(call)).toEqual(pipBounds); + expect(dimensionOf(otherCall)).toEqual(inlineLayout); + + rerender(<>{inlineSubscriber(otherCall, otherInline$)}); + settle(); + expect(dimensionOf(otherCall)).toEqual(inlineLayout); + }); + + it('starts with fresh bounds when the provider replaces a call with the same cid', () => { + const call = joinedCall(); + const nextCall = joinedCall(); + expect(nextCall.cid).toBe(call.cid); + const content = (current: Call) => ( + + + + ); + const { rerender } = render(content(call), { call }); + const first = pipView(); + first.change(true); + first.bounds(pipBounds.width, pipBounds.height); + rerender(content(nextCall)); + settle(); + expect(isInPiPMode$.getValue()).toBe(false); + + // events the replaced window queued before the switch are rejected. + first.change(true); + first.bounds(400, 500); + expect(isInPiPMode$.getValue()).toBe(false); + expect(dimensionOf(nextCall)).toBeUndefined(); + + pipView().change(true); + expect(dimensionOf(nextCall)).toBeUndefined(); + pipView().bounds(200, 300); + expect(dimensionOf(nextCall)).toEqual({ width: 200, height: 300 }); + expect(dimensionOf(call)).toBeUndefined(); + }); +}); diff --git a/packages/react-native-sdk/__tests__/components/TrackSubscriber.test.tsx b/packages/react-native-sdk/__tests__/components/TrackSubscriber.test.tsx index 3ecc979dfd..7563498d86 100644 --- a/packages/react-native-sdk/__tests__/components/TrackSubscriber.test.tsx +++ b/packages/react-native-sdk/__tests__/components/TrackSubscriber.test.tsx @@ -1,6 +1,12 @@ import React from 'react'; +import { Platform } from 'react-native'; import { act, render } from '@testing-library/react-native'; -import { CallingState, SfuModels } from '@stream-io/video-client'; +import { + type Call, + CallingState, + SfuModels, + type VideoTrackType, +} from '@stream-io/video-client'; import { BehaviorSubject } from 'rxjs'; import TrackSubscriber from '../../src/components/Participant/ParticipantView/VideoRenderer/TrackSubscriber'; import mockParticipant from '../mocks/participant'; @@ -9,6 +15,72 @@ import { mockClientWithUser } from '../mocks/client'; jest.useFakeTimers(); +const sessionId = 'remote-session-1'; + +const joinedCall = (participants = [sessionId]): Call => { + const call = mockCall( + mockClientWithUser({ id: 'test-user-id' }), + participants.map((session) => + mockParticipant({ + sessionId: session, + publishedTracks: [ + SfuModels.TrackType.VIDEO, + SfuModels.TrackType.SCREEN_SHARE, + ], + }), + ), + ); + call.state.setCallingState(CallingState.JOINED); + return call; +}; + +const dimensionOf = ( + call: Call, + session = sessionId, + trackType = SfuModels.TrackType.VIDEO, +) => + call.trackSubscriptionManager.subscriptions.find( + (subscription) => + subscription.sessionId === session && + subscription.trackType === trackType, + )?.dimension; + +const inlineSubscriber = ( + call: Call, + dimensions$: BehaviorSubject, + options: { + session?: string; + trackType?: VideoTrackType; + isVisible?: boolean; + } = {}, +) => ( + +); + +const pipSubscriber = ( + call: Call, + dimensions$: BehaviorSubject, + options: { session?: string; trackType?: VideoTrackType } = {}, +) => ( + +); + +const inlineDimensions$ = (dimension?: SfuModels.VideoDimension) => + new BehaviorSubject(dimension); + describe('TrackSubscriber', () => { it('requests the video track when the participant appears in state after the subscriber mounted', () => { const client = mockClientWithUser({ id: 'test-user-id' }); @@ -17,20 +89,9 @@ describe('TrackSubscriber', () => { const call = mockCall(client, []); call.state.setCallingState(CallingState.JOINED); - const sessionId = 'remote-session-1'; - const dimensions$ = new BehaviorSubject< - SfuModels.VideoDimension | undefined - >(undefined); - - render( - , - ); + const dimensions$ = inlineDimensions$(); + + render(inlineSubscriber(call, dimensions$)); // Nothing to subscribe to yet: the participant is not in state. expect(call.trackSubscriptionManager.subscriptions).toHaveLength(0); @@ -59,4 +120,88 @@ describe('TrackSubscriber', () => { }), ); }); + + it('follows the publication and the rejoin of the track it renders', () => { + const call = joinedCall(); + const dimensions$ = inlineDimensions$(); + + render(pipSubscriber(call, dimensions$)); + act(() => dimensions$.next({ width: 180, height: 240 })); + expect(dimensionOf(call)).toEqual({ width: 180, height: 240 }); + + act(() => call.state.updateParticipant(sessionId, { publishedTracks: [] })); + expect(dimensionOf(call)).toBeUndefined(); + act(() => + call.state.updateParticipant(sessionId, { + publishedTracks: [SfuModels.TrackType.VIDEO], + }), + ); + expect(dimensionOf(call)).toEqual({ width: 180, height: 240 }); + + act(() => { + call.state.setCallingState(CallingState.RECONNECTING); + call.state.updateParticipantTracks('videoTrack', { + [sessionId]: { dimension: undefined }, + }); + call.state.setCallingState(CallingState.JOINED); + }); + expect(dimensionOf(call)).toEqual({ width: 180, height: 240 }); + }); + + it('unsubscribes from the video track when the view becomes invisible', () => { + const call = joinedCall(); + const dimensions$ = inlineDimensions$({ width: 200, height: 200 }); + + const { rerender } = render( + inlineSubscriber(call, dimensions$, { isVisible: true }), + ); + expect(dimensionOf(call)).toEqual({ width: 200, height: 200 }); + + rerender(inlineSubscriber(call, dimensions$, { isVisible: false })); + expect(dimensionOf(call)).toBeUndefined(); + + rerender(inlineSubscriber(call, dimensions$, { isVisible: true })); + expect(dimensionOf(call)).toEqual({ width: 200, height: 200 }); + }); + + it('preserves Android layout, visibility, publication and rejoin behavior', () => { + Platform.OS = 'android'; + try { + const call = joinedCall(); + const dimensions$ = inlineDimensions$(); + + const { rerender } = render(inlineSubscriber(call, dimensions$)); + + act(() => dimensions$.next({ width: 200, height: 200 })); + expect(dimensionOf(call)).toEqual({ width: 200, height: 200 }); + + act(() => dimensions$.next({ width: 0, height: 0 })); + expect(dimensionOf(call)).toBeUndefined(); + + act(() => dimensions$.next({ width: 320, height: 180 })); + act(() => + call.state.updateParticipant(sessionId, { publishedTracks: [] }), + ); + expect(dimensionOf(call)).toBeUndefined(); + act(() => + call.state.updateParticipant(sessionId, { + publishedTracks: [SfuModels.TrackType.VIDEO], + }), + ); + expect(dimensionOf(call)).toEqual({ width: 320, height: 180 }); + rerender(inlineSubscriber(call, dimensions$, { isVisible: false })); + expect(dimensionOf(call)).toBeUndefined(); + rerender(inlineSubscriber(call, dimensions$)); + act(() => { + call.state.setCallingState(CallingState.RECONNECTING); + call.state.updateParticipantTracks('videoTrack', { + [sessionId]: { dimension: undefined }, + }); + call.state.setCallingState(CallingState.JOINED); + }); + expect(dimensionOf(call)).toEqual({ width: 320, height: 180 }); + } finally { + Platform.OS = 'ios'; + } + }); }); diff --git a/packages/react-native-sdk/ios/RTCViewPip.swift b/packages/react-native-sdk/ios/RTCViewPip.swift index 6660b40ef6..d82191db47 100644 --- a/packages/react-native-sdk/ios/RTCViewPip.swift +++ b/packages/react-native-sdk/ios/RTCViewPip.swift @@ -17,6 +17,9 @@ class RTCViewPip: UIView { weak var manager: RTCViewPipManager? @objc var onPiPChange: RCTBubblingEventBlock? + /// Actual PiP window bounds in logical points. + @objc var onPiPBoundsChange: RCTBubblingEventBlock? + private var lastEmittedBounds: CGSize? // MARK: - Avatar Placeholder Properties @@ -156,8 +159,11 @@ class RTCViewPip: UIView { @objc func onCallClosed() { PictureInPictureLogger.log("pictureInPictureController cleanup called") + self.pictureInPictureController?.onPiPStateChange = nil + self.pictureInPictureController?.onSizeUpdate = nil self.pictureInPictureController?.cleanup() self.pictureInPictureController = nil + self.lastEmittedBounds = nil } @objc @@ -191,9 +197,8 @@ class RTCViewPip: UIView { } self.pictureInPictureController?.sourceView = self self.pictureInPictureController?.isMirrored = self.mirror - // Set up PiP state change callback - self.pictureInPictureController?.onPiPStateChange = { [weak self] isActive in - self?.sendPiPChangeEvent(isActive: isActive) + if let controller = self.pictureInPictureController { + self.installCallbacks(on: controller) } if let reactTag = self.reactTag, let size = self.manager?.getCachedSize(for: reactTag) { @@ -277,12 +282,32 @@ class RTCViewPip: UIView { } } - private func sendPiPChangeEvent(isActive: Bool) { - guard let onPiPChange = onPiPChange else { - return + // MARK: - Picture in Picture Events + + /// Ignore callbacks from a controller that has already been disposed. + private func installCallbacks(on controller: StreamPictureInPictureController) { + controller.onPiPStateChange = { [weak self, weak controller] isActive in + guard let self, let controller, + self.pictureInPictureController === controller else { return } + self.onPiPChange?(["active": isActive]) + } + controller.onSizeUpdate = { [weak self, weak controller] size in + guard let self, let controller, + self.pictureInPictureController === controller else { return } + self.handleSizeUpdate(size) } + } - PictureInPictureLogger.log("Sending PiP state change event: \(isActive)") - onPiPChange(["active": isActive]) + private func handleSizeUpdate(_ size: CGSize) { + guard size.width.isFinite, size.height.isFinite else { return } + // truncated to match the integer dimensions the inline views report. + let bounds = CGSize( + width: size.width.rounded(.towardZero), + height: size.height.rounded(.towardZero) + ) + guard bounds.width > 0, bounds.height > 0 else { return } + guard let onPiPBoundsChange, lastEmittedBounds != bounds else { return } + lastEmittedBounds = bounds + onPiPBoundsChange(["width": bounds.width, "height": bounds.height]) } } diff --git a/packages/react-native-sdk/ios/RTCViewPipManager.mm b/packages/react-native-sdk/ios/RTCViewPipManager.mm index 86d122d5e6..d8253b128c 100644 --- a/packages/react-native-sdk/ios/RTCViewPipManager.mm +++ b/packages/react-native-sdk/ios/RTCViewPipManager.mm @@ -13,6 +13,7 @@ @interface RCT_EXTERN_MODULE(RTCViewPipManager, RCTViewManager) RCT_EXPORT_VIEW_PROPERTY(streamURL, NSString) RCT_EXPORT_VIEW_PROPERTY(mirror, BOOL) RCT_EXPORT_VIEW_PROPERTY(onPiPChange, RCTBubblingEventBlock) +RCT_EXPORT_VIEW_PROPERTY(onPiPBoundsChange, RCTBubblingEventBlock) RCT_EXPORT_VIEW_PROPERTY(participantName, NSString) RCT_EXPORT_VIEW_PROPERTY(participantImageURL, NSString) RCT_EXPORT_VIEW_PROPERTY(isReconnecting, BOOL) diff --git a/packages/react-native-sdk/src/components/Call/CallContent/CallContent.tsx b/packages/react-native-sdk/src/components/Call/CallContent/CallContent.tsx index 7926497430..77ef33498d 100644 --- a/packages/react-native-sdk/src/components/Call/CallContent/CallContent.tsx +++ b/packages/react-native-sdk/src/components/Call/CallContent/CallContent.tsx @@ -267,7 +267,7 @@ export const CallContent = ({ return ( <> - {!disablePictureInPicture && ( + {Platform.OS === 'ios' && !disablePictureInPicture && ( (); +let nextNativeViewKey = 0; +const nativeViewKeyOf = (call: Call | undefined) => { + if (!call) return 0; + let key = nativeViewKeys.get(call); + if (key === undefined) { + key = ++nextNativeViewKey; + nativeViewKeys.set(call, key); + } + return key; +}; type Props = { includeLocalParticipantVideo?: boolean; @@ -52,6 +71,39 @@ export const RTCViewPipIOS = React.memo((props: Props) => { StreamVideoParticipant[] >(call?.state.participants ?? []); + // bounds of the native window, in logical points. While the window is on + // screen they are the demand of the track it renders. + const [pipDimensions$] = useState( + () => new BehaviorSubject(undefined), + ); + const [isPipActive, setIsPipActive] = useState(false); + // synchronous mirror of isPipActive: native may emit the same lifecycle + // state twice (e.g. failedToStart after didStop) before React re-renders. + const isPipActiveRef = useRef(false); + // set once the call is closed: later native events describe a disposed window. + const isClosedRef = useRef(false); + // the handlers below are bound to the view they were rendered for; an event + // of a replaced view is rejected even if it were delivered. + const nativeViewKey = nativeViewKeyOf(call); + const activeNativeViewKeyRef = useRef(nativeViewKey); + useEffect(() => { + activeNativeViewKeyRef.current = nativeViewKey; + }, [nativeViewKey]); + const isStaleNativeEvent = () => + isClosedRef.current || nativeViewKey !== activeNativeViewKeyRef.current; + const onPiPChangeRef = useRef(onPiPChange); + useEffect(() => { + onPiPChangeRef.current = onPiPChange; + }, [onPiPChange]); + + const updatePipState = useCallback((active: boolean) => { + if (isPipActiveRef.current === active) return; + isPipActiveRef.current = active; + setIsPipActive(active); + isInPiPMode$.next(active); + onPiPChangeRef.current?.(active); + }, []); + // we debounce the participants to avoid unnecessary rerenders // that happen when participant tracks are all subscribed simultaneously useEffect(() => { @@ -86,17 +138,19 @@ export const RTCViewPipIOS = React.memo((props: Props) => { const nativeRef = React.useRef(null); React.useEffect(() => { - let callClosedInvokedOnce = false; + const node = findNodeHandle(nativeRef.current); + isClosedRef.current = false; const onCallClosed = () => { - if (callClosedInvokedOnce) { + if (isClosedRef.current) { return; } - callClosedInvokedOnce = true; - const node = findNodeHandle(nativeRef.current); + isClosedRef.current = true; if (node !== null) { onNativeCallClosed(node); } shouldDisableIOSLocalVideoOnBackgroundRef.current = true; + pipDimensions$.next(undefined); + updatePipState(false); }; const unsubFunc = call?.on('call.ended', () => { videoLoggerSystem @@ -117,7 +171,7 @@ export const RTCViewPipIOS = React.memo((props: Props) => { unsubFunc?.(); subscription?.unsubscribe(); }; - }, [call]); + }, [call, pipDimensions$, updatePipState]); const onDimensionsUpdated = useCallback((width: number, height: number) => { const node = findNodeHandle(nativeRef.current); @@ -150,9 +204,17 @@ export const RTCViewPipIOS = React.memo((props: Props) => { ? mirrorOverride : !!participantInSpotlight?.isLocalParticipant && direction === 'front'; - const handlePiPChange = (event: { nativeEvent: { active: boolean } }) => { - isInPiPMode$.next(event.nativeEvent.active); - onPiPChange?.(event.nativeEvent.active); + const handlePiPChange = (event: { nativeEvent: PiPChangeEvent }) => { + if (isStaleNativeEvent()) return; + updatePipState(event.nativeEvent.active); + }; + + const handlePiPBoundsChange = (event: { + nativeEvent: PiPBoundsChangeEvent; + }) => { + if (isStaleNativeEvent()) return; + const { width, height } = event.nativeEvent; + pipDimensions$.next({ width, height }); }; // Get participant info for avatar placeholder @@ -193,13 +255,23 @@ export const RTCViewPipIOS = React.memo((props: Props) => { participantInSpotlight?.connectionQuality ?? SfuModels.ConnectionQuality.UNSPECIFIED; + // while the native window is on screen, its bounds - and not the hidden + // inline layout behind it - are the demand of the track it renders. The local + // preview is never subscribed to. + const pipTrackOwner = + isPipActive && participantInSpotlight?.isLocalParticipant !== true + ? participantInSpotlight + : undefined; + return ( <> { isSpeaking={participantIsSpeaking} connectionQuality={participantConnectionQuality} /> + {pipTrackOwner && call && ( + + )} {participantInSpotlight && ( void; + /** + * Reports the actual laid out bounds of the native Picture in Picture window. + */ + onPiPBoundsChange?: (event: { nativeEvent: PiPBoundsChangeEvent }) => void; /** The participant's name for the avatar placeholder when video is disabled */ participantName?: string; /** The URL string for the participant's profile image */ @@ -84,6 +95,8 @@ export const RTCViewPipNative = React.memo( // eslint-disable-next-line react/prop-types onPiPChange={props.onPiPChange} // eslint-disable-next-line react/prop-types + onPiPBoundsChange={props.onPiPBoundsChange} + // eslint-disable-next-line react/prop-types participantName={props.participantName} // eslint-disable-next-line react/prop-types participantImageURL={props.participantImageURL} diff --git a/packages/react-native-sdk/src/components/Participant/ParticipantView/VideoRenderer/TrackSubscriber.tsx b/packages/react-native-sdk/src/components/Participant/ParticipantView/VideoRenderer/TrackSubscriber.tsx index b25e486e97..4e6e39dca3 100644 --- a/packages/react-native-sdk/src/components/Participant/ParticipantView/VideoRenderer/TrackSubscriber.tsx +++ b/packages/react-native-sdk/src/components/Participant/ParticipantView/VideoRenderer/TrackSubscriber.tsx @@ -1,4 +1,5 @@ import { useEffect } from 'react'; +import { Platform } from 'react-native'; import { Call, CallingState, @@ -16,7 +17,12 @@ import { distinctUntilKeyChanged, filter, map, + of, } from 'rxjs'; +import { + getIosPipTrack$, + setIosPipTrack, +} from '../../../../utils/internal/iosPipTrack'; type TrackSubscriberProps = { participantSessionId: string; @@ -28,6 +34,13 @@ type TrackSubscriberProps = { * the last reported layout survives a remount of this component. */ dimensions$: BehaviorSubject; + /** + * Set by the native iOS Picture in Picture window, whose bounds this + * subscriber requests. While it does, the inline views of the same track + * stop requesting their own, hidden layout. Only rendered on iOS; on other + * platforms the gate is always open. + */ + isPipWriter?: boolean; }; /** @@ -40,10 +53,20 @@ type TrackSubscriberProps = { * This component is used to unsubscribe to video track and subscribe only to the audio track of the participant (by passing undefined dimensions) in the following cases: * 1. When the participant stops publishing the video track * 2. When the participant becomes invisible + * + * The same component serves the native iOS Picture in Picture window, which + * renders one of the tracks too. Its bounds take precedence over the inline + * views of that track for as long as it is on screen. */ const TrackSubscriber = (props: TrackSubscriberProps) => { - const { call, participantSessionId, trackType, isVisible, dimensions$ } = - props; + const { + call, + participantSessionId, + trackType, + isVisible, + dimensions$, + isPipWriter, + } = props; useEffect(() => { const requestTrackWithDimensions = ( @@ -75,12 +98,28 @@ const TrackSubscriber = (props: TrackSubscriberProps) => { map((callingState) => callingState === CallingState.JOINED), ); + const trackKey = { sessionId: participantSessionId, trackType }; + const pipTrack$ = Platform.OS === 'ios' ? getIosPipTrack$(call) : undefined; + if (isPipWriter && pipTrack$) setIosPipTrack(call, trackKey); + const canWrite$ = pipTrack$ + ? pipTrack$.pipe( + map((track) => { + const sameTrack = + track?.sessionId === participantSessionId && + track?.trackType === trackType; + return isPipWriter ? sameTrack : !sameTrack; + }), + distinctUntilChanged(), + ) + : of(true); + const subscription = combineLatest([ dimensions$, isPublishingTrack$, isJoinedState$, - ]).subscribe(([dimension, isPublishing, isJoined]) => { - if (isJoined) { + canWrite$, + ]).subscribe(([dimension, isPublishing, isJoined, canWrite]) => { + if (isJoined && canWrite) { if (!isVisible || !isPublishing) { requestTrackWithDimensions(DebounceType.MEDIUM, undefined); } else if (dimension) { @@ -91,8 +130,22 @@ const TrackSubscriber = (props: TrackSubscriberProps) => { return () => { subscription.unsubscribe(); + if (isPipWriter && pipTrack$) { + // Let sibling inline views finish unmounting before handing back demand. + queueMicrotask(() => { + if (pipTrack$.getValue() === trackKey) + setIosPipTrack(call, undefined); + }); + } }; - }, [call, participantSessionId, trackType, isVisible, dimensions$]); + }, [ + call, + participantSessionId, + trackType, + isVisible, + dimensions$, + isPipWriter, + ]); return null; }; diff --git a/packages/react-native-sdk/src/utils/internal/iosPipTrack.ts b/packages/react-native-sdk/src/utils/internal/iosPipTrack.ts new file mode 100644 index 0000000000..40cabebaab --- /dev/null +++ b/packages/react-native-sdk/src/utils/internal/iosPipTrack.ts @@ -0,0 +1,39 @@ +import { + type Call, + DebounceType, + type VideoTrackType, +} from '@stream-io/video-client'; +import { BehaviorSubject } from 'rxjs'; + +export type PipTrack = { sessionId: string; trackType: VideoTrackType }; + +/** + * The track currently rendered by the native iOS Picture in Picture window, + * per Call instance. While set, the window's bounds are the demand of that + * track and the inline views of the same track stop requesting their layout. + */ +const tracks = new WeakMap>(); + +export const getIosPipTrack$ = (call: Call) => { + let track$ = tracks.get(call); + if (!track$) { + track$ = new BehaviorSubject(undefined); + tracks.set(call, track$); + } + return track$; +}; + +export const setIosPipTrack = (call: Call, track: PipTrack | undefined) => { + const track$ = getIosPipTrack$(call); + const previous = track$.getValue(); + if (previous) { + // Clear before opening the gate: mounted inline views replay their layout + // synchronously on the gate change and overwrite this. If none is mounted, + // the cleared demand stands. + call.state.updateParticipantTracks(previous.trackType, { + [previous.sessionId]: { dimension: undefined }, + }); + call.trackSubscriptionManager.apply(DebounceType.MEDIUM); + } + track$.next(track); +}; diff --git a/sample-apps/react-native/dogfood/ios/Podfile.lock b/sample-apps/react-native/dogfood/ios/Podfile.lock index c878cb653b..25132a65a1 100644 --- a/sample-apps/react-native/dogfood/ios/Podfile.lock +++ b/sample-apps/react-native/dogfood/ios/Podfile.lock @@ -1,5 +1,5 @@ PODS: - - Callingx (0.9.0): + - Callingx (1.0.0-beta.0): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2502,7 +2502,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - stream-io-noise-cancellation-react-native (0.10.0): + - stream-io-noise-cancellation-react-native (1.0.0-beta.0): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2526,7 +2526,7 @@ PODS: - stream-react-native-webrtc - StreamVideoNoiseCancellation - Yoga - - stream-io-video-filters-react-native (0.15.0): + - stream-io-video-filters-react-native (1.0.0-beta.0): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2552,7 +2552,7 @@ PODS: - stream-react-native-webrtc (145.3.1): - React-Core - StreamWebRTC (= 145.15.0) - - stream-video-react-native (1.43.0): + - stream-video-react-native (2.0.0-beta.1): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2949,112 +2949,112 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon/yoga" SPEC CHECKSUMS: - Callingx: 1e13711cc1b85d266438b5c4412ecb1bd6bc511d + Callingx: 46bb3332e292def34ea06d724b8baf7b57485242 FBLazyVector: 3c3be9a019176b5699455f7f66c5444b78a411c6 - hermes-engine: 5da8d7eeb13a48d932b021d8d9df9e27d1acc5be + hermes-engine: bea2770719c59875d56e955d6b155b36f4b5a08e MMKVCore: 3d16ce9f7d411e135020915fde98a056859a1efa - NitroMmkv: 716bf336b20feaf2e72ebf2fd877bae778f19203 - NitroModules: 1c3563bec5c8af1ebf57c053efe08f708448a669 + NitroMmkv: 38b271a19e7e152025a6893160c98326872ea05f + NitroModules: e0ac5f9a04e23cb2f378b51810ebc07ed63aeae9 RCTDeprecation: bccb6545c26db881ecddfd83a3f9ea82aba1605f RCTRequired: b2f74764d596fc0051f00fee94b49bf41a6f7f5a RCTSwiftUI: c6d6a31b849b9dfa64c33b55dc91ac15dd55774c - RCTSwiftUIWrapper: 0106c38ccc929e3b2f8815c50ab873e345ab2777 + RCTSwiftUIWrapper: bdff268d65d662a79b1e571e841e1512275f9319 RCTTypeSafety: 159e394bdab42023fbdd8fa022dfdc5577a8b9ad React: 4b2532a459d15e1adf6c22d3e399e5c85a94220f React-callinvoker: 0b8ce4057e02a0bd15cf0532596e8eb8c0392e92 - React-Core: 52b990bb36640833e636d5efc817ad4700dd7f93 - React-Core-prebuilt: 39eb00d8ba4922167a3a70e2039b102fe42b6fe1 - React-CoreModules: 43516d84b3851a850a835d32ec740db319e7bf9e - React-cxxreact: 9254233b543b9f49bdbcad329603e70be5582a0e + React-Core: 5af045531a540ba3f65f07de1e3f585ddfb27948 + React-Core-prebuilt: 405cf395d66cf694faf9aed3483a21b5515cec85 + React-CoreModules: 99b194a721de84ccfc1be149a0de52647dc38c0e + React-cxxreact: b7e8e254074fd8111d147202b391ccf7816946a6 React-debug: 3281bfefe5ece9a9d8b28bec3f871db229f9d8d8 - React-defaultsnativemodule: 92c7b0c67121be15ecf592a51fb5a380258e95d1 - React-domnativemodule: c6517298e22a92d209d176e924b3af7ddfcc58b2 - React-Fabric: 023ee019c7070478c769260a60933eaf4dd3243e - React-FabricComponents: d6072c495c9a8599b92cee4b0450fb64647f06eb - React-FabricImage: af1c23d16347204ec0a367f646cd073365866f96 - React-featureflags: db64c938d45f7551854382c7cd3a7cf25fbdeb3b - React-featureflagsnativemodule: 9f7d235d716bdec416a1e26c3800835a6a738f13 - React-graphics: 96b40932178005f3e5f2f8b454d109d5bd9d9050 - React-hermes: 9f44a83eca48842bd3f60fdc16bdb4fa29105eae - React-idlecallbacksnativemodule: 2339ec52bc6c86c00d457e49dfee2382107eed8d - React-ImageManager: d9b380862bae15a882354435cb9978fdf014c9bb - React-intersectionobservernativemodule: 759abe72f3cd20b3bedb8257f2b9a9dc587d9da5 - React-jserrorhandler: c396b1908915fbdc88fb5fa264c2922bc97561b8 - React-jsi: 40bd06de577b224175f2ce5b4eb2cffa449c86df - React-jsiexecutor: 0c6068034234dd6910fa6d82f378f75bcc02a8b7 - React-jsinspector: 00e8c1b836e93bed2f36ca38ba40bd99400c9cda - React-jsinspectorcdp: ae77a36d099a875244db0c35ef22741e8c64dc4e - React-jsinspectornetwork: 77357fb7aa231b8a7127a1e8ca930cc4664d665f - React-jsinspectortracing: b76744a010e642ac16c97ce19fd97657a72ed903 - React-jsitooling: d57fbc06240118518c5af1d930ac78c5fca96546 - React-jsitracing: 15b31360fc82a0dcd4b653c219f535564f7678c3 - React-logger: de0bd725e905e9be3ab919a4206666eec7c3e248 - React-Mapbuffer: 329c2dc6d6a6a4579d858891a7bbc00933316ea9 - React-microtasksnativemodule: c960394926ca5dc8f22636f6da162497192415c3 - React-mutationobservernativemodule: 4fcd10e492236d5de412df84c904c8ca62ac1b29 - react-native-blob-util: 37e8b9921fe7bfa1c83c156bdba73992e19b9bde - react-native-image-picker: 9dce42d17f5917de55bdff39a587390fd496beda - react-native-netinfo: 10fc5ca4331d893efc2b90adb43869a85d66654e - react-native-safe-area-context: bcea1b7671431001d60db53f48c2e768698d2860 - react-native-video: c8a32ec11cf5134121fa8bf07017a2522f736b65 - React-NativeModulesApple: f313ed47b56405d621e12ecda106a72020959ff3 - React-networking: e424e5e77a7b124143ceac8acf44352e0baae8bd + React-defaultsnativemodule: 1bd0a6e02f816f0c939baeaf2b7a9e799327274e + React-domnativemodule: f7d8ab3fbd37453301d69a866bc21456dc8c9bd8 + React-Fabric: 9bb75bdf09a445e74d49dc2152cd2176d1379432 + React-FabricComponents: f5c87bd4421ed262c25f57e8dea560221074e065 + React-FabricImage: 39be2035c85a1004004c3dc62ddc3f17073fe105 + React-featureflags: de5b3e964937f9dfbc05b98e51f3f4d0472789f4 + React-featureflagsnativemodule: 6bebf75aeddd8799107a13f1db99f1a0ce2a0977 + React-graphics: 9d482a1031375b90704be420d445879bed3132f7 + React-hermes: 6fd579ceeb680830fc508697acdd5e8cd8dbf29f + React-idlecallbacksnativemodule: b5dacdd51c63ab15ed8a9bb1c875c6f0e92160d4 + React-ImageManager: 1e86a5fbe7c49cc39f4bfb49f02df410d53dd96c + React-intersectionobservernativemodule: 13011eeac1a10a76e63888b1baab1ca2839fed39 + React-jserrorhandler: 8c7e83f8259120207965c61e430733218d8fb58e + React-jsi: 7b14065a285f91b3dbe5448371ff575bbb523d59 + React-jsiexecutor: 10b4ba40ec9fd571370dfee20251201f57712e79 + React-jsinspector: fb62fdf5baea797b121e5566492536e3fab928ff + React-jsinspectorcdp: 2a0a882d722a17cea6bebcce23464156c470f384 + React-jsinspectornetwork: b1dc2b219dd1c9bdbf13dccc8bfe313c39cf6cd7 + React-jsinspectortracing: c57b2e67ee3b56f98d2b507d3a29c1b5d44049e6 + React-jsitooling: 9fc594ea389d97893904f8ddafe1bec63313321b + React-jsitracing: ecbbc6d7f3de28197849da27f0e273e34a55626d + React-logger: 35ab012027cc552057f7ca5d031c6721f896e6bc + React-Mapbuffer: 75518c85e76e5117a6f7cac4e0bec4ba0a45723f + React-microtasksnativemodule: 9d2adb05be26b36bff9e3f24b0cc5bf0a0511c65 + React-mutationobservernativemodule: e450c29376a09a0f6dd23170096f3e46f1ba6d57 + react-native-blob-util: c29d5e7db12b72d656144276ee89bbb94d5c4c6d + react-native-image-picker: 09cea417bef3ee25008ad21d4426f9a1fc54ad84 + react-native-netinfo: a05f9b897e76ad24b53f615fed1cbb731932363d + react-native-safe-area-context: 6255354c51cdabe084b19ed7bd2cd9cd6b0f57e2 + react-native-video: 950a8bb07646654716ebf0dd78acf16f95fa3b6c + React-NativeModulesApple: 167e532fb9bae30f3c66636b8ee0092bab263667 + React-networking: e382e6f2f815e801a34a630e708a551c7feb2090 React-oscompat: db6675ddaef3bddd1b41533627f3d26ec710c05f - React-perflogger: 21ca12b8deb8411c6fa4a564e6e4e0800390b556 - React-performancecdpmetrics: 5043a827acb64f0931311eec1d894bd785e49622 - React-performancetimeline: 6d332fd2b2d17a43e49621bfe380cffe15321704 + React-perflogger: c34660c72849d23319f5e544c97e6c6ed687b186 + React-performancecdpmetrics: c852458c139c8c2a15284ddb7d4927d907b38731 + React-performancetimeline: cf6cd525e8d8a1c625985d54d9ee68296073f281 React-RCTActionSheet: 7d18777c531c516ab9f86342583057b15fb7fd7d - React-RCTAnimation: 14a2fdc1165d73dc96abd0935e89c2127a119fbd - React-RCTAppDelegate: 0b5a06df526fe79b2a5cbe90b889e28867e14574 - React-RCTBlob: f2ebb238c43a631f541377ec18ed60bb70665363 - React-RCTFabric: d1148be6b253d86ffcbac455a5c472acdf3cdc19 - React-RCTFBReactNativeSpec: 4fb3182f19d21880f3d09198a0ef718e155bf46a - React-RCTImage: 1d8c5b8786a0f52d0d2613b8ba58c02c1da67c1e - React-RCTLinking: 2560d59e85c82f260bff81fffc4ea30bc66a500b - React-RCTNetwork: d331a823911e70fd1de7eeddaa739f4c54dcacc4 - React-RCTRuntime: 7ebe23fb1fa548c7d59d2aa30e6ad8a76230c0a8 - React-RCTSettings: b7896598aa87af4b907628164240e87457e6f830 - React-RCTText: 1eb82d0798487adcf394a12d8fad825722d1a376 - React-RCTVibration: 0c76a15901bb1c8d47a8a02dc16f95f39ed3ffb5 + React-RCTAnimation: f2505067d2d83268f5619192f8f5ff14b2606c7f + React-RCTAppDelegate: 7bfa4d752f45a0b9195b8da0f188f8904806a86f + React-RCTBlob: 7b4d25eca2a6ecd83766d76790879774b73b4cd5 + React-RCTFabric: 2f4e25dd2a256cbba5d95d8ec2c03a876fddd8ec + React-RCTFBReactNativeSpec: 1f8c18b3344dee44d60d750958a0d0f94fcf4f3d + React-RCTImage: 9123b010921479b5bcd3771a4a4d4e788227a427 + React-RCTLinking: 358d42629d7012c5d75060fd3e25023b72ebae31 + React-RCTNetwork: 32f5274abd61e937bdcbfd85810ae784d3f0e38a + React-RCTRuntime: 606364977ba254a416e85ef7a9f6e08b89818a32 + React-RCTSettings: 86aa6e6a3a335d989e3fcd1bd98b9ea6331adfa9 + React-RCTText: 59376611225f3c6fdc75d57571d7c345bdd0be1e + React-RCTVibration: 3c7d17d3373c114220be9ac3b99df1646009b8fe React-rendererconsistency: 3238990615931ff327b5f1d98852cd6603dc5d10 - React-renderercss: 95ecdb01c38d79c99d91d8cfdbd7e86e53cd5ec0 - React-rendererdebug: 04ee94e461f37f2bc636e4e87cc58d9c7d29fd11 - React-RuntimeApple: 529b641fb6d9b191393b740e20e5ace72d65556f - React-RuntimeCore: ea394fde02dbdd63159603aa99df473f21a51d18 - React-runtimeexecutor: b50e7e6e028eb62aa561f2efb9d9a3cd79271929 - React-RuntimeHermes: 270988ef200429fc26d90fb0a858c51d9e5427ad - React-runtimescheduler: b2a156eb703fdcbdcac286db7073e15474e4fe0e - React-timing: 84d125637f03b6fa478830aeafcb41e3657054f8 - React-utils: 2490f15bfc04de9ae46d3feda964d85483fa76ae - React-viewtransitionnativemodule: 7b3f3f1b3bd0d6890733838cb3dac3b28acc848a - React-webperformancenativemodule: cf21b4a6de564da1275f866cf939d77241f4cd29 - ReactAppDependencyProvider: 082ef199e27011a4314f499b6b2e2ac29e141267 - ReactCodegen: 908cd0191a680b54042374076c28eaf3b5fdfd0e - ReactCommon: 00d8d68c7aa036745334d3a3844102ecd9bf099d + React-renderercss: a0142ecb1e580926514c05c70205e825230005d0 + React-rendererdebug: d475dc238a8f39c248d328605bc48268b2111625 + React-RuntimeApple: 2aab0db6b710869c4e995e0b9054edbe01c1f182 + React-RuntimeCore: 701d4a24f5a4a5e990b1cafea443e7bffb707de8 + React-runtimeexecutor: 9a0a090bf8183a45ddda95be72ba0d2d7bc5a6d4 + React-RuntimeHermes: c0a3a63d306daf45646862ce09afa00d8e654873 + React-runtimescheduler: 2af097dd7630e559e7f0a740d875857c4a02f90d + React-timing: 329b5e88f59491a5e893f6c549bd10883ef30e5a + React-utils: 25a0beadc5eacbffeb965c7024826b1233dbedc6 + React-viewtransitionnativemodule: 8f1d4895e081e0b2f0a98069c098eb40be032138 + React-webperformancenativemodule: 5bddf6c7eecda8a3ce48b82609008965cc9d94c2 + ReactAppDependencyProvider: 0e13d430eadac8a2ef18515a860d5c59df05b475 + ReactCodegen: b4e2c5f0d9415b8fe2faf163fca6f40c62f9666a + ReactCommon: 9002f006f571256348994183f7d4387aa7cf84e8 ReactNativeDependencies: 2bd6854ade79bf1b60586d1ab7813a389df1b6bf - RNCClipboard: 815e7ed527ba8c00ffa9fcdeb578510278beb0cb - RNCPushNotificationIOS: b9dcb4f28c85f77483c3f86d2fcb1e0f0ab396fa - RNDeviceInfo: d79872e11c8e9c4de0d65b0ee6e0cee719f37fea - RNGestureHandler: 25d3ab36590ef2d9ca578fc56436e5e3b72ad077 - RNNotifee: 4a6ee5c7deaf00e005050052d73ee6315dff7ec9 - RNPermissions: ec9ae245c85cbd3ba0b04eed47c5c992c23310db - RNReactNativeHapticFeedback: 48e6c9d9ad40906a260a207367c701d3baaa8d92 - RNReanimated: d7984411500b072f713444cc127d9c51af38e0b6 - RNScreens: fd17d67cc5fefe79ab4d9811c8b668312d9057b8 - RNShare: 489793682cecee51a0ece2772d9462a2949ffc9c - RNSVG: 10edb4a8a7e6bb61313c0a2311cc321644ff5e0a - RNWorklets: ca7e0a49681a74e09f09e389aefd9e453fa0079b - stream-chat-react-native: baf7e1c3fc101f8a1b95ad0c6693ddaf2bab384e - stream-io-noise-cancellation-react-native: 1dc825d5b6e69da05dcd7342498e0546a5ffdf49 - stream-io-video-filters-react-native: d9e5695d2fe09dc897236aecc8527ba45656482d - stream-react-native-webrtc: ab1633ad73f2eb1b8baf9f13858c78f2f62d1986 - stream-video-react-native: 5747603aad92ea28792951bc6cb84a549941a172 + RNCClipboard: 7a7d4557bfd3370b35c99dfecd92ae7b9fc4948a + RNCPushNotificationIOS: 85957a0534fd1309b4c028ccbe5b1baf9ab5cf57 + RNDeviceInfo: 4c852998208b60dc192ae3529e5867817719ad1e + RNGestureHandler: 7b07d9192bc65c6883fb37fdecc05dc6b61c814f + RNNotifee: 5e3b271e8ea7456a36eec994085543c9adca9168 + RNPermissions: 19510b9e4f2de1679a3213fc9be68eee4980769c + RNReactNativeHapticFeedback: e55b7b6240c89ca0865903cce8eac4cb2f9c1a6e + RNReanimated: fe41f7fdb22db30ada8954493cf9178757cb231a + RNScreens: 5b4b260e28d8263d97fddcb53e59649515797bcc + RNShare: 26c9524aee8cc3eedbab6d6b98cacce2f5247893 + RNSVG: 394cfd0518613b144c65ffe69045f827b78a7a7c + RNWorklets: 43af89f696342f91118a2f1f3a879056e2f47331 + stream-chat-react-native: e97f6d3ed0c2828b20610ffc0023ad7f9c90738d + stream-io-noise-cancellation-react-native: 1c97675f11979a8f144706aa1105eeebc5ca380d + stream-io-video-filters-react-native: 7a2c93ed95c7213f44cb8a9a15246f8301d2f67c + stream-react-native-webrtc: 971cdfc04e294bfa9ef85fa9504402816f1d65df + stream-video-react-native: 177671d5813623f097d311a4d6eb9e59394d693d StreamVideoNoiseCancellation: 41f5a712aba288f9636b64b17ebfbdff52c61490 StreamWebRTC: 89743e7e21ec6e388462919246051e8ed6b3dfc1 - Teleport: 4cb9855422c04666fbeee9a52e5342466e27b009 - VisionCamera: 68d40255fa8866e815cbbb063730102d3da276fd + Teleport: c56b30b08bd20d10da1efb0f21c6bc50c269ee6a + VisionCamera: b51af005669c6642dd1b4fca434eda4c1778de87 Yoga: 542a30dafe5b0f5f1d9f185ea7b2a3811ac54801 PODFILE CHECKSUM: c29d9efec31064fe74fffdba980d3dc6a9a83ae9 -COCOAPODS: 1.16.2 +COCOAPODS: 1.15.2 From 935d01c9ef13657f5411dc93eec9b353954e0bea Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Mon, 28 Sep 2026 11:17:37 +0200 Subject: [PATCH 5/7] fix(client): never publish unencrypted media before the E2EE transform is attached (#2460) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### πŸ’‘ Overview Closes a gap where an E2EE publisher could put unencrypted media on the wire. The sender used to be created with its track and the encryptor attached only after an `await`, so another negotiation (ICE restart, a second publish) could send the track without a transform. If E2EE setup failed partway, the half-initialized sender stayed in the transceiver cache and a retry reused it unencrypted. ### πŸ“ Implementation notes - E2EE senders are created with no track (`addTransceiver(kind)`). `encrypt()` runs in the same task, then sender parameters, then `replaceTrack(track)`. The sender has no media until all of these succeed. - On any setup failure the sender is retired: removed from `TransceiverCache` (new `remove()`), stopped, and its clone released (not on React Native, where clones share the native source). A sender whose encryption succeeded but negotiation failed is kept and reused. - `EncryptionManager.pipe` marks a target only after `createEncodedStreams()` succeeds, so a failed attach can be retried and a successful one is never piped twice. - **Behavior change for custom `E2EEManager` implementations:** `encrypt()` now receives a sender whose `track` is `null`. Use the `codec` and `trackType` arguments instead of reading the track. Documented on the interface and in `SPEC.md`. - Removes the unused `e2ee/workerMessages.ts`. - Known follow-up: if an ICE restart happens while the first and only E2EE sender is still being set up, the restart has no tracks to announce and falls back to a rejoin. Nothing unencrypted is sent. - Tested with unit tests covering every failure point, the ICE-restart race and cleanup, plus a manual E2EE call in react-dogfood. 🎫 Ticket: https://linear.app/stream/issue/REACT-1182 πŸ“‘ Docs: https://github.com/GetStream/docs-content/pull/ ## Summary by CodeRabbit * **Bug Fixes** * Improved reliability when starting encrypted audio and video publishing. Media is attached only after encryption setup succeeds, and failed setup is cleaned up so publishing can be retried. * Prevented incomplete encrypted media from being announced during connection recovery, reducing the chance of publishing interruptions or inconsistent media state. * Improved cleanup after encryption setup errors, helping preserve the original error and preventing failed publishing attempts from interfering with later retries. (cherry picked from commit 2419b016341f345c8eff960f49bf7dee3b4e5a4e) --- packages/client/src/rtc/Publisher.ts | 78 ++++++- packages/client/src/rtc/TransceiverCache.ts | 17 ++ .../src/rtc/__tests__/Publisher.test.ts | 209 ++++++++++++++++++ .../src/rtc/__tests__/mocks/webrtc.mocks.ts | 1 + packages/client/src/rtc/e2ee/E2EEManager.ts | 11 +- .../client/src/rtc/e2ee/EncryptionManager.ts | 2 +- packages/client/src/rtc/e2ee/SPEC.md | 2 +- 7 files changed, 305 insertions(+), 15 deletions(-) diff --git a/packages/client/src/rtc/Publisher.ts b/packages/client/src/rtc/Publisher.ts index 8422c6e0c0..da3f53747a 100644 --- a/packages/client/src/rtc/Publisher.ts +++ b/packages/client/src/rtc/Publisher.ts @@ -150,7 +150,9 @@ export class Publisher extends BasePeerConnection { const sendEncodings = isSvcCodec(publishOption.codec?.name) ? toSvcEncodings(encodings) : encodings; - const transceiver = this.pc.addTransceiver(track, { + // An E2EE sender must never have media before its transform is attached, + // including when another track or an ICE restart negotiates this PC. + const transceiver = this.pc.addTransceiver(this.e2ee ? track.kind : track, { direction: 'sendonly', sendEncodings, }); @@ -161,23 +163,75 @@ export class Publisher extends BasePeerConnection { this.trackIdToTrackType.set(track.id, trackType); this.logger.debug(`Added ${TrackType[trackType]} transceiver`); - const params = transceiver.sender.getParameters(); - params.degradationPreference = - toRTCDegradationPreference(publishOption.degradationPreference) ?? - 'maintain-framerate'; - await transceiver.sender.setParameters(params); if (this.e2ee) { - this.e2ee.encrypt( - transceiver.sender, - publishOption.codec?.name.toLowerCase(), - TrackType[publishOption.trackType], - ); - this.logger.debug('E2EE encryptor attached to sender'); + await this.initEncryptedSender(transceiver, track, publishOption); + } else { + await this.setDegradationPreference(transceiver.sender, publishOption); } await this.negotiate(); }; + /** + * Attaches the encryptor, applies sender parameters, then attaches the track. + * + * `encrypt` must run before the first await: Chrome short-circuits a sender + * whose encoded streams aren't created in the task that created it, and the + * track goes on last so the sender has no media until both steps succeed. + */ + private initEncryptedSender = async ( + transceiver: RTCRtpTransceiver, + track: MediaStreamTrack, + publishOption: PublishOption, + ) => { + try { + const { sender } = transceiver; + const codec = publishOption.codec?.name.toLowerCase(); + this.e2ee!.encrypt(sender, codec, TrackType[publishOption.trackType]); + this.logger.debug('E2EE encryptor attached to sender'); + await this.setDegradationPreference(sender, publishOption); + await sender.replaceTrack(track); + } catch (err) { + this.retireTransceiver(transceiver, track); + throw err; + } + }; + + private setDegradationPreference = async ( + sender: RTCRtpSender, + publishOption: PublishOption, + ) => { + const params = sender.getParameters(); + params.degradationPreference = + toRTCDegradationPreference(publishOption.degradationPreference) ?? + 'maintain-framerate'; + await sender.setParameters(params); + }; + + /** + * Discards a sender whose E2EE initialization failed. + * + * A partially initialized sender may carry no transform, so publish() must + * not reuse it: retiring it from the cache is what keeps a later publish or + * ICE restart from putting cleartext on the wire. Cleanup cannot throw, so + * the initialization failure is what reaches the caller. + */ + private retireTransceiver = ( + transceiver: RTCRtpTransceiver, + track: MediaStreamTrack, + ) => { + this.transceiverCache.remove(transceiver); + this.trackIdToTrackType.delete(track.id); + // on React Native a clone shares its native source, so stopping it here + // would stop the track the caller passed in. dispose() releases it. + if (!isReactNative()) this.stopTrack(track); + try { + transceiver.stop(); + } catch (err) { + this.logger.debug('Failed to stop a retired transceiver', err); + } + }; + /** * Updates the transceiver with the given track and track type. */ diff --git a/packages/client/src/rtc/TransceiverCache.ts b/packages/client/src/rtc/TransceiverCache.ts index 493e02bc64..9b1295bfb0 100644 --- a/packages/client/src/rtc/TransceiverCache.ts +++ b/packages/client/src/rtc/TransceiverCache.ts @@ -21,6 +21,23 @@ export class TransceiverCache { this.transceiverOrder.push(bundle.transceiver); }; + /** + * Retires a failed sender from the reusable cache. + * + * A transceiver that never reached an m-section also leaves the creation + * history: once stopped, it is left out of every later offer, so counting + * it would shift the `mid` index of every transceiver added after it. One + * that is already associated keeps its slot, since its m-section survives + * as a rejected one. + */ + remove = (transceiver: RTCRtpTransceiver) => { + const index = this.cache.findIndex((b) => b.transceiver === transceiver); + if (index !== -1) this.cache.splice(index, 1); + if (transceiver.mid) return; + const orderIndex = this.transceiverOrder.indexOf(transceiver); + if (orderIndex !== -1) this.transceiverOrder.splice(orderIndex, 1); + }; + /** * Gets the transceiver for the given publish option. */ diff --git a/packages/client/src/rtc/__tests__/Publisher.test.ts b/packages/client/src/rtc/__tests__/Publisher.test.ts index 3813e59059..6dcf012983 100644 --- a/packages/client/src/rtc/__tests__/Publisher.test.ts +++ b/packages/client/src/rtc/__tests__/Publisher.test.ts @@ -26,6 +26,7 @@ import { TransceiverCache } from '../TransceiverCache'; import { promiseWithResolvers } from '../../helpers/promise'; import { settled } from '../../helpers/concurrency'; import { isFirefox } from '../../helpers/browsers'; +import { isReactNative } from '../../helpers/platforms'; vi.mock('../../StreamSfuClient', () => { console.log('MOCKING StreamSfuClient'); @@ -40,6 +41,12 @@ vi.mock('../../helpers/browsers', async (importOriginal) => { return { ...actual, isFirefox: vi.fn().mockReturnValue(false) }; }); +vi.mock('../../helpers/platforms', async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, isReactNative: vi.fn().mockReturnValue(false) }; +}); + describe('Publisher', () => { const sessionId = 'session-id-test'; let publisher: Publisher; @@ -106,6 +113,208 @@ describe('Publisher', () => { }); describe('Publishing', () => { + describe('E2EE initialization failures', () => { + afterEach(() => { + vi.mocked(isReactNative).mockReturnValue(false); + }); + + const setup = async () => { + await publisher.dispose(); + const e2ee = { encrypt: vi.fn(), decrypt: vi.fn() }; + publisher = new Publisher( + { + sfuClient, + dispatcher, + state, + tag: 'test', + enableTracing: false, + e2ee, + }, + [ + fromPartial({ + id: 1, + trackType: TrackType.VIDEO, + codec: { name: 'vp9' }, + }), + ], + ); + const transceivers: RTCRtpTransceiver[] = []; + vi.mocked(publisher['pc'].addTransceiver).mockImplementation((kind) => { + expect(kind).toBe('video'); + const transceiver = new RTCRtpTransceiver(); + vi.mocked(transceiver.sender.replaceTrack).mockImplementation( + async (track) => { + expect(e2ee.encrypt).toHaveBeenCalledWith( + transceiver.sender, + 'vp9', + 'VIDEO', + ); + Object.assign(transceiver.sender, { track }); + }, + ); + transceivers.push(transceiver); + return transceiver; + }); + const track = new MediaStreamTrack(); + vi.mocked(track.clone).mockImplementation(() => new MediaStreamTrack()); + const realNegotiate = publisher['negotiate']; + const negotiate = vi.fn().mockResolvedValue(); + publisher['negotiate'] = negotiate; + return { e2ee, transceivers, track, negotiate, realNegotiate }; + }; + + it.each([ + 'encrypt', + 'getParameters', + 'setParameters', + 'replaceTrack', + ] as const)( + 'retires the sender after %s fails and attaches encryption on retry', + async (failurePoint) => { + const { e2ee, transceivers, track, negotiate } = await setup(); + const error = new Error('initialization failed'); + e2ee.encrypt.mockImplementationOnce((sender: RTCRtpSender) => { + expect(sender.track).toBeNull(); + if (failurePoint === 'encrypt') throw error; + if (failurePoint === 'getParameters') { + vi.mocked(sender.getParameters).mockImplementationOnce(() => { + throw error; + }); + } else { + vi.mocked(sender[failurePoint]).mockRejectedValueOnce(error); + } + }); + await expect(publisher.publish(track, TrackType.VIDEO)).rejects.toBe( + error, + ); + const failed = transceivers[0]; + expect(failed.sender.track).toBeNull(); + expect(failed.stop).toHaveBeenCalledOnce(); + expect(publisher['transceiverCache'].items()).toHaveLength(0); + expect(publisher['clonedTracks'].size).toBe(0); + expect(negotiate).not.toHaveBeenCalled(); + + await publisher.publish(track, TrackType.VIDEO); + expect(transceivers).toHaveLength(2); + expect(e2ee.encrypt).toHaveBeenLastCalledWith( + transceivers[1].sender, + 'vp9', + 'VIDEO', + ); + expect(transceivers[1].sender.track).not.toBeNull(); + expect(publisher['transceiverCache'].indexOf(failed)).toBe(-1); + expect(publisher['transceiverCache'].indexOf(transceivers[1])).toBe( + 0, + ); + expect(negotiate).toHaveBeenCalledOnce(); + }, + ); + + it('keeps the mid index of a retired sender that reached an m-section', async () => { + const { e2ee, transceivers, track } = await setup(); + const error = new Error('initialization failed'); + e2ee.encrypt.mockImplementationOnce((sender: RTCRtpSender) => { + Object.assign(transceivers[0], { mid: '0' }); + vi.mocked(sender.replaceTrack).mockRejectedValueOnce(error); + }); + await expect(publisher.publish(track, TrackType.VIDEO)).rejects.toBe( + error, + ); + expect(publisher['transceiverCache'].items()).toHaveLength(0); + expect(publisher['transceiverCache'].indexOf(transceivers[0])).toBe(0); + + await publisher.publish(track, TrackType.VIDEO); + expect(publisher['transceiverCache'].indexOf(transceivers[1])).toBe(1); + }); + + it('reports the initialization failure even when cleanup throws', async () => { + const { e2ee, transceivers, track } = await setup(); + const error = new Error('initialization failed'); + e2ee.encrypt.mockImplementationOnce(() => { + vi.mocked(transceivers[0].stop).mockImplementationOnce(() => { + throw new Error('peer connection is closed'); + }); + throw error; + }); + await expect(publisher.publish(track, TrackType.VIDEO)).rejects.toBe( + error, + ); + expect(publisher['transceiverCache'].items()).toHaveLength(0); + expect(publisher['clonedTracks'].size).toBe(0); + }); + + it('leaves the cloned track alone on React Native', async () => { + vi.mocked(isReactNative).mockReturnValue(true); + const { e2ee, transceivers, track } = await setup(); + const error = new Error('initialization failed'); + e2ee.encrypt.mockImplementationOnce(() => { + throw error; + }); + await expect(publisher.publish(track, TrackType.VIDEO)).rejects.toBe( + error, + ); + const [clone] = publisher['clonedTracks']; + expect(clone.stop).not.toHaveBeenCalled(); + expect(transceivers[0].stop).toHaveBeenCalledOnce(); + expect(publisher['transceiverCache'].items()).toHaveLength(0); + }); + + it('reuses an encrypted sender when only negotiation failed', async () => { + const { e2ee, transceivers, track, negotiate } = await setup(); + const error = new Error('negotiation failed'); + negotiate.mockRejectedValueOnce(error); + await expect(publisher.publish(track, TrackType.VIDEO)).rejects.toBe( + error, + ); + expect(transceivers[0].sender.track).not.toBeNull(); + expect(transceivers[0].stop).not.toHaveBeenCalled(); + await publisher.publish(track, TrackType.VIDEO); + expect(transceivers).toHaveLength(1); + expect(e2ee.encrypt).toHaveBeenCalledOnce(); + expect(negotiate).toHaveBeenCalledTimes(2); + }); + + it('has no media to expose if ICE restart negotiates during parameter setup', async () => { + const { e2ee, transceivers, track, negotiate, realNegotiate } = + await setup(); + negotiate.mockImplementation(realNegotiate); + publisher['publishOptions'].push( + fromPartial({ + id: 2, + trackType: TrackType.VIDEO, + codec: { name: 'vp9' }, + }), + ); + sfuClient.setPublisher = vi + .fn() + .mockResolvedValue({ response: { sdp: 'answer-sdp' } }); + const parameters = promiseWithResolvers(); + e2ee.encrypt + .mockImplementationOnce(() => {}) + .mockImplementationOnce((sender: RTCRtpSender) => { + vi.mocked(sender.setParameters).mockReturnValueOnce( + parameters.promise, + ); + }); + const publish = publisher.publish(track, TrackType.VIDEO); + await vi.waitFor(() => expect(e2ee.encrypt).toHaveBeenCalledTimes(2)); + const [published, pending] = transceivers; + expect(pending.sender.track).toBeNull(); + expect(pending.sender.replaceTrack).not.toHaveBeenCalled(); + + await publisher.restartIce(); + const restart = vi.mocked(sfuClient.setPublisher).mock.calls[1][0]; + expect(restart.tracks.map((t) => t.trackId)).toEqual([ + published.sender.track!.id, + ]); + + parameters.resolve(); + await publish; + expect(pending.sender.track).not.toBeNull(); + expect(sfuClient.setPublisher).toHaveBeenCalledTimes(3); + }); + }); + it('should throw when publishing ended tracks', async () => { const track = new MediaStreamTrack(); // @ts-expect-error readonly field diff --git a/packages/client/src/rtc/__tests__/mocks/webrtc.mocks.ts b/packages/client/src/rtc/__tests__/mocks/webrtc.mocks.ts index c51307c849..7485874f8c 100644 --- a/packages/client/src/rtc/__tests__/mocks/webrtc.mocks.ts +++ b/packages/client/src/rtc/__tests__/mocks/webrtc.mocks.ts @@ -60,6 +60,7 @@ const RTCRtpTransceiverMock = vi.fn(function (): Partial { transform: null, }, setCodecPreferences: vi.fn(), + stop: vi.fn(), mid: '', }; }); diff --git a/packages/client/src/rtc/e2ee/E2EEManager.ts b/packages/client/src/rtc/e2ee/E2EEManager.ts index 82b6583262..409665c85d 100644 --- a/packages/client/src/rtc/e2ee/E2EEManager.ts +++ b/packages/client/src/rtc/e2ee/E2EEManager.ts @@ -5,7 +5,16 @@ * attaching their own encoded transform in these two methods. */ export interface E2EEManager { - /** `trackType` only groups perf stats, keeping a camera and screen share apart. */ + /** + * Synchronously attach a transform that encrypts the sender's outgoing frames. + * Must support a sender without a track, and throw if attachment fails. + * + * `sender.track` is always null here: the track is attached only after this + * returns, so implementations must not read it. Use `codec` and `trackType` + * instead. + * + * `trackType` only groups perf stats, keeping a camera and screen share apart. + */ encrypt(sender: RTCRtpSender, codec?: string, trackType?: string): void; /** `trackType` only groups perf stats, keeping a peer's audio and video apart. */ decrypt(receiver: RTCRtpReceiver, userId: string, trackType?: string): void; diff --git a/packages/client/src/rtc/e2ee/EncryptionManager.ts b/packages/client/src/rtc/e2ee/EncryptionManager.ts index 3c3ad90486..10533f68c0 100644 --- a/packages/client/src/rtc/e2ee/EncryptionManager.ts +++ b/packages/client/src/rtc/e2ee/EncryptionManager.ts @@ -263,9 +263,9 @@ export class EncryptionManager } if ((this.piped ??= new WeakSet()).has(target)) return; - this.piped.add(target); // @ts-expect-error createEncodedStreams is not in the standard typedefs const { readable, writable } = target.createEncodedStreams(); + this.piped.add(target); this.worker.postMessage( { type: 'cmd.setup_transform', ...options, readable, writable }, [readable, writable], diff --git a/packages/client/src/rtc/e2ee/SPEC.md b/packages/client/src/rtc/e2ee/SPEC.md index 68cbe0a519..7da726e08f 100644 --- a/packages/client/src/rtc/e2ee/SPEC.md +++ b/packages/client/src/rtc/e2ee/SPEC.md @@ -79,7 +79,7 @@ await call.join(); - **Removal comes in per-epoch and per-user forms, and the names must stay distinct.** `removeKey(userId, keyIndex)` retires one epoch; `removeAllKeys(userId)` revokes the participant entirely. Do not render these as overloads of one name: dropping the index argument would then silently mean "revoke everything", which is the opposite of the caller's intent and impossible to catch in review. - **Every key operation's outcome is tabulated in Appendix B.** Implement the edge cases (removing the latest or active epoch, a failed import, an absent index) from that table rather than from prose. - **Join request carries `e2ee: true`** so the backend knows the call is encrypted. -- The internal attach points (`encrypt(sender, codec, trackType)` / `decrypt(receiver, userId, trackType)`) are called by the RTC layer, not by apps. Keeping them behind a small interface lets an integrator plug in a different scheme (e.g. SFrame). +- The internal attach points (`encrypt(sender, codec, trackType)` / `decrypt(receiver, userId, trackType)`) are called by the RTC layer, not by apps. The publisher creates an E2EE sender without a track and attaches media only after `encrypt` returns and sender parameters succeed. `encrypt` must synchronously attach the transform or throw, and must support a sender whose `track` is null. A failed initialization retires that transceiver so retries cannot bypass attachment. Keeping these points behind a small interface lets an integrator plug in a different scheme (e.g. SFrame). --- From f79db2be5429b1b5b17303a430aec5ce9f8cd8bb Mon Sep 17 00:00:00 2001 From: Santhosh Vaiyapuri <3846977+santhoshvai@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:36:49 +0200 Subject: [PATCH 6/7] feat(rn): add end-to-end encryption support (#2427) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit πŸ’‘ Overview Adds end-to-end encryption to the React Native SDK. Encryption runs natively in @stream-io/react-native-webrtc, using the same wire format as the web and iOS SDKs, so encrypted calls interoperate across platforms. - EncryptionManager β€” native-backed implementation of the core E2EEManager interface, API-compatible with the web manager. - StreamVideoRN.setRingingCallLifecycleHooks() β€” ringing calls are joined by the SDK, not by app code, so there is no moment where the app can attach a manager before the join. These hooks are that moment, on every ringing path. - Dogfood: passphrase entry, lock badge, live re-keying, key-mismatch warning. πŸ“ Implementation notes - Call.ts: globalThis.streamRNVideoSDK bridge plus cancellation checks reusing leaveGeneration. No new Call fields; web and non-ringing calls are untouched. - Setup failure or the 5s timeout aborts the join and ends the ringing flow. Joining unencrypted on a call the user believes is private is the worse outcome. - One Call is one call flow β€” discard it after leave/cancel/failed join. Documented contract, not an enforced ban; inherited reuse behaviour and tests are untouched. - dispose() is mandatory on RN (no native detach, closing peer connections doesn't release it), and gated on teardown succeeding. webrtc PR: https://github.com/GetStream/react-native-webrtc/pull/68 docs PR: https://github.com/GetStream/docs-content/pull/1586 🎫 Ticket: https://linear.app/stream/issue/RN-434/ - **New Features** - Added React Native end-to-end encryption with AES-GCM key management and status handling. - Added encryption key entry, mismatch notifications, and an encryption indicator during calls. - Encrypted deep links now preserve and apply meeting keys securely. - Added configurable ringing-call lifecycle hooks for call preparation and cleanup. - **Bug Fixes** - Improved cancellation and cleanup when calls are left during joining. - Failed push-call joins now correctly report failure to the calling platform. - **Documentation** - Added guidance and testing documentation for React Native encryption and sample-app workflows. --------- Co-authored-by: GitHub Actions Bot <> Co-authored-by: Gabriel Donadel Dall'Agnol Co-authored-by: Oliver Lazoroski Backport note: adapted to `release-v1`, which does not have the `ClientState` refactor (#2422) or the shared i18n runtime (#2436). - `clientState` -> `clientStore` and `ClientState` -> `StreamVideoWriteableStateStore` in `Call.ts` and the two new ringing lifecycle test files. - `ringingJoinIntegration.test.ts` hands the Call `client['writeableStateStore']`: v1 still has two stores, and `client.state` is the read-only one. - RN SDK peer ranges for the sibling packages stay `>=0.1.0`; only the `@stream-io/react-native-webrtc` range and dev dependency change. - Dogfood: adds a v1 `hooks/useAppI18n.ts` that maps the `t(key, fallback, options)` calls to v1's `t(key, options)`. `CallErrorComponent` and `ParticipantsInfoListModal` keep their v1 strings and carry only this PR's own change. - `yarn.lock` regenerated from v1's with `yarn install --mode=update-lockfile`, not merged from `main`. (cherry picked from commit fd70b3597beb5867b21f254c76fd6d4e6e2e731b) --- packages/client/index.ts | 1 + packages/client/src/Call.ts | 95 +++- .../__tests__/Call.ringingLifecycle.test.ts | 242 ++++++++ packages/client/src/types.ts | 38 +- .../@stream-io/react-native-webrtc.tsx | 68 +++ .../callingx/cancelledJoinCleanup.test.ts | 140 +++++ .../callingx/joinCallingxCall.test.ts | 209 +++++++ .../dogfood/meetingUiLifecycle.test.tsx | 520 ++++++++++++++++++ .../encryption/EncryptionManager.test.ts | 421 ++++++++++++++ .../__tests__/encryption/eventMapping.test.ts | 228 ++++++++ .../__tests__/encryption/legacyWebrtc.test.ts | 44 ++ .../__tests__/push/ringingJoin.test.ts | 275 +++++++++ .../push/ringingJoinIntegration.test.ts | 291 ++++++++++ packages/react-native-sdk/package.json | 4 +- packages/react-native-sdk/src/index.ts | 5 + .../modules/encryption/EncryptionManager.ts | 360 ++++++++++++ .../src/modules/encryption/eventMapping.ts | 151 +++++ .../src/modules/encryption/index.ts | 1 + .../src/modules/encryption/parity.ts | 83 +++ .../src/utils/StreamVideoRN/index.ts | 25 +- .../src/utils/StreamVideoRN/types.ts | 68 +++ .../src/utils/internal/callingx/callingx.ts | 32 +- .../src/utils/internal/registerSDKGlobals.ts | 2 + .../utils/internal/ringingCallLifecycle.ts | 148 +++++ .../src/utils/push/internal/utils.ts | 4 + sample-apps/react-native/dogfood/App.tsx | 6 +- sample-apps/react-native/dogfood/CLAUDE.md | 2 + sample-apps/react-native/dogfood/README.md | 16 + .../dogfood/__tests__/e2ee.test.tsx | 159 ++++++ .../__tests__/encryptedDeepLink.test.tsx | 211 +++++++ .../dogfood/__tests__/inviteLink.test.ts | 15 + .../react-native/dogfood/ios/Podfile.lock | 64 ++- .../react-native/dogfood/jest.config.js | 8 + sample-apps/react-native/dogfood/package.json | 13 +- .../dogfood/src/components/ActiveCall.tsx | 2 + .../CallControls/TopControls/E2EEBadge.tsx | 54 ++ .../CallControls/TopControls/index.tsx | 5 + .../src/components/CallErrorComponent.tsx | 15 +- .../dogfood/src/components/E2EEKeyInput.tsx | 101 ++++ .../src/components/E2EEKeyNotification.tsx | 171 ++++++ .../src/components/LobbyEncryption.tsx | 274 +++++++++ .../src/components/LobbyViewComponent.tsx | 127 +++-- .../dogfood/src/components/LockIcon.tsx | 27 + .../src/components/MeetingEncryptionSetup.tsx | 178 ++++++ .../dogfood/src/components/MeetingUI.tsx | 164 +++++- .../components/ParticipantsInfoListModal.tsx | 12 +- .../dogfood/src/components/VideoWrapper.tsx | 2 + .../dogfood/src/contexts/AppContext.tsx | 4 + .../dogfood/src/contexts/LobbyE2EEContext.tsx | 28 + .../dogfood/src/hooks/useAppI18n.ts | 35 ++ .../dogfood/src/hooks/useDeepLinkEffect.tsx | 91 ++- .../dogfood/src/hooks/useE2eeKeyStatus.ts | 115 ++++ .../src/modules/helpers/randomWords.ts | 29 + .../src/screens/Call/JoinCallScreen.tsx | 10 + .../screens/Meeting/GuestMeetingScreen.tsx | 50 +- .../src/screens/Meeting/GuestModeScreen.tsx | 2 + .../src/screens/Meeting/JoinMeetingScreen.tsx | 55 +- .../src/screens/Meeting/MeetingScreen.tsx | 61 +- .../react-native/dogfood/src/utils/e2ee.ts | 286 ++++++++++ .../dogfood/src/utils/inviteLink.ts | 22 + .../dogfood/src/utils/notificationUtils.ts | 6 +- .../dogfood/src/utils/setPushConfig.ts | 10 + sample-apps/react-native/dogfood/types.ts | 5 +- yarn.lock | 148 ++++- 64 files changed, 5895 insertions(+), 143 deletions(-) create mode 100644 packages/client/src/__tests__/Call.ringingLifecycle.test.ts create mode 100644 packages/react-native-sdk/__tests__/callingx/cancelledJoinCleanup.test.ts create mode 100644 packages/react-native-sdk/__tests__/callingx/joinCallingxCall.test.ts create mode 100644 packages/react-native-sdk/__tests__/dogfood/meetingUiLifecycle.test.tsx create mode 100644 packages/react-native-sdk/__tests__/encryption/EncryptionManager.test.ts create mode 100644 packages/react-native-sdk/__tests__/encryption/eventMapping.test.ts create mode 100644 packages/react-native-sdk/__tests__/encryption/legacyWebrtc.test.ts create mode 100644 packages/react-native-sdk/__tests__/push/ringingJoin.test.ts create mode 100644 packages/react-native-sdk/__tests__/push/ringingJoinIntegration.test.ts create mode 100644 packages/react-native-sdk/src/modules/encryption/EncryptionManager.ts create mode 100644 packages/react-native-sdk/src/modules/encryption/eventMapping.ts create mode 100644 packages/react-native-sdk/src/modules/encryption/index.ts create mode 100644 packages/react-native-sdk/src/modules/encryption/parity.ts create mode 100644 packages/react-native-sdk/src/utils/internal/ringingCallLifecycle.ts create mode 100644 sample-apps/react-native/dogfood/__tests__/e2ee.test.tsx create mode 100644 sample-apps/react-native/dogfood/__tests__/encryptedDeepLink.test.tsx create mode 100644 sample-apps/react-native/dogfood/__tests__/inviteLink.test.ts create mode 100644 sample-apps/react-native/dogfood/jest.config.js create mode 100644 sample-apps/react-native/dogfood/src/components/CallControls/TopControls/E2EEBadge.tsx create mode 100644 sample-apps/react-native/dogfood/src/components/E2EEKeyInput.tsx create mode 100644 sample-apps/react-native/dogfood/src/components/E2EEKeyNotification.tsx create mode 100644 sample-apps/react-native/dogfood/src/components/LobbyEncryption.tsx create mode 100644 sample-apps/react-native/dogfood/src/components/LockIcon.tsx create mode 100644 sample-apps/react-native/dogfood/src/components/MeetingEncryptionSetup.tsx create mode 100644 sample-apps/react-native/dogfood/src/contexts/LobbyE2EEContext.tsx create mode 100644 sample-apps/react-native/dogfood/src/hooks/useAppI18n.ts create mode 100644 sample-apps/react-native/dogfood/src/hooks/useE2eeKeyStatus.ts create mode 100644 sample-apps/react-native/dogfood/src/modules/helpers/randomWords.ts create mode 100644 sample-apps/react-native/dogfood/src/utils/e2ee.ts create mode 100644 sample-apps/react-native/dogfood/src/utils/inviteLink.ts 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 7706b8574e..340a16cffc 100644 --- a/packages/client/src/Call.ts +++ b/packages/client/src/Call.ts @@ -794,6 +794,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(); @@ -1137,6 +1142,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( @@ -1156,6 +1165,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`); } @@ -1175,43 +1186,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.clientStore.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.clientStore.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; @@ -1267,6 +1298,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; } }, @@ -2605,10 +2639,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..b80c02e98f --- /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 { StreamVideoWriteableStateStore } 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, + }), + clientStore: new StreamVideoWriteableStateStore(), + }); +}; + +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 c0ff39dd34..c85bc6f247 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__/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..f375a6f766 --- /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'], + clientStore: client['writeableStateStore'], + }); + 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/package.json b/packages/react-native-sdk/package.json index a043d4682f..5ea7d27c63 100644 --- a/packages/react-native-sdk/package.json +++ b/packages/react-native-sdk/package.json @@ -65,7 +65,7 @@ "@react-native-firebase/messaging": ">=17.5.0", "@stream-io/noise-cancellation-react-native": ">=0.1.0", "@stream-io/react-native-callingx": ">=0.1.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": ">=0.1.0", "expo": ">=47.0.0", "expo-notifications": "*", @@ -119,7 +119,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 ab55ca1eb5..2a4fe19a6e 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/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 12f3766946..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; } 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 a540de7714..922c9a06db 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 929b50b11b..cdb52be2f0 100644 --- a/sample-apps/react-native/dogfood/README.md +++ b/sample-apps/react-native/dogfood/README.md @@ -49,6 +49,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. + * **Chores** * Updated the React Native WebRTC version used by calling, noise cancellation, video filters, and sample apps to 145.4.1. * The React Native SDK now uses and supports the 145.4.1 release, replacing its earlier and prerelease versions. Included sample apps and development setups are aligned with this version. Backport note: the version bump is re-applied to `release-v1`'s own `package.json` files, whose sibling peer ranges differ from `main`'s, and `yarn.lock` is regenerated from v1's with `yarn install --mode=update-lockfile` rather than merged from `main`. (cherry picked from commit 891c4c9fc45339eb08f4f7902d93144921f407ef) --- .../package.json | 2 +- packages/react-native-callingx/package.json | 2 +- packages/react-native-sdk/package.json | 4 +-- .../video-filters-react-native/package.json | 2 +- .../react-native/dogfood/ios/Podfile.lock | 8 ++--- sample-apps/react-native/dogfood/package.json | 2 +- .../expo-video-sample/package.json | 2 +- .../ringing-tutorial/package.json | 2 +- yarn.lock | 36 +++++++------------ 9 files changed, 24 insertions(+), 36 deletions(-) diff --git a/packages/noise-cancellation-react-native/package.json b/packages/noise-cancellation-react-native/package.json index eb39ccd87a..c954ca1cb6 100644 --- a/packages/noise-cancellation-react-native/package.json +++ b/packages/noise-cancellation-react-native/package.json @@ -48,7 +48,7 @@ }, "homepage": "https://github.com/GetStream/stream-video-js#readme", "devDependencies": { - "@stream-io/react-native-webrtc": "145.3.1", + "@stream-io/react-native-webrtc": "145.4.1", "@stream-io/typescript-config": "workspace:^", "react": "19.2.3", "react-native": "0.86.2", diff --git a/packages/react-native-callingx/package.json b/packages/react-native-callingx/package.json index e2b9d98746..5f2f1aecc8 100644 --- a/packages/react-native-callingx/package.json +++ b/packages/react-native-callingx/package.json @@ -61,7 +61,7 @@ "devDependencies": { "@react-native-community/cli": "20.2.0", "@react-native/babel-preset": "0.86.2", - "@stream-io/react-native-webrtc": "145.3.1", + "@stream-io/react-native-webrtc": "145.4.1", "@stream-io/typescript-config": "workspace:^", "@types/react": "^19.2.18", "del-cli": "^6.0.0", diff --git a/packages/react-native-sdk/package.json b/packages/react-native-sdk/package.json index 5ea7d27c63..a75d7960af 100644 --- a/packages/react-native-sdk/package.json +++ b/packages/react-native-sdk/package.json @@ -65,7 +65,7 @@ "@react-native-firebase/messaging": ">=17.5.0", "@stream-io/noise-cancellation-react-native": ">=0.1.0", "@stream-io/react-native-callingx": ">=0.1.0", - "@stream-io/react-native-webrtc": "^145.3.1 || >=145.4.0-alpha.1", + "@stream-io/react-native-webrtc": "^145.4.1", "@stream-io/video-filters-react-native": ">=0.1.0", "expo": ">=47.0.0", "expo-notifications": "*", @@ -119,7 +119,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.4.0-alpha.1", + "@stream-io/react-native-webrtc": "145.4.1", "@stream-io/typescript-config": "workspace:^", "@stream-io/video-filters-react-native": "workspace:^", "@testing-library/jest-native": "^5.4.3", diff --git a/packages/video-filters-react-native/package.json b/packages/video-filters-react-native/package.json index b304effe76..72dcf2e456 100644 --- a/packages/video-filters-react-native/package.json +++ b/packages/video-filters-react-native/package.json @@ -48,7 +48,7 @@ }, "homepage": "https://github.com/GetStream/stream-video-js#readme", "devDependencies": { - "@stream-io/react-native-webrtc": "145.3.1", + "@stream-io/react-native-webrtc": "145.4.1", "@stream-io/typescript-config": "workspace:^", "react": "19.2.3", "react-native": "0.86.2", diff --git a/sample-apps/react-native/dogfood/ios/Podfile.lock b/sample-apps/react-native/dogfood/ios/Podfile.lock index 140e1dd791..516d2d117a 100644 --- a/sample-apps/react-native/dogfood/ios/Podfile.lock +++ b/sample-apps/react-native/dogfood/ios/Podfile.lock @@ -2595,7 +2595,7 @@ PODS: - ReactNativeDependencies - stream-react-native-webrtc - Yoga - - stream-react-native-webrtc (145.4.0-alpha.1): + - stream-react-native-webrtc (145.4.1): - React-Core - StreamWebRTC (= 145.17.0) - stream-video-react-native (2.0.0-beta.1): @@ -3007,7 +3007,7 @@ SPEC CHECKSUMS: MMKVCore: 3d16ce9f7d411e135020915fde98a056859a1efa NitroMmkv: 38b271a19e7e152025a6893160c98326872ea05f NitroModules: e0ac5f9a04e23cb2f378b51810ebc07ed63aeae9 - QuickCrypto: 52d2f094ec7051e1ce8e66333088fefe6c253a7a + QuickCrypto: 61ed57f6cdce53a5bc1017054fa1ea4e24e0f14d RCTDeprecation: bccb6545c26db881ecddfd83a3f9ea82aba1605f RCTRequired: b2f74764d596fc0051f00fee94b49bf41a6f7f5a RCTSwiftUI: c6d6a31b849b9dfa64c33b55dc91ac15dd55774c @@ -3099,9 +3099,9 @@ SPEC CHECKSUMS: RNSVG: 394cfd0518613b144c65ffe69045f827b78a7a7c RNWorklets: 43af89f696342f91118a2f1f3a879056e2f47331 stream-chat-react-native: e97f6d3ed0c2828b20610ffc0023ad7f9c90738d - stream-io-noise-cancellation-react-native: 1c97675f11979a8f144706aa1105eeebc5ca380d + stream-io-noise-cancellation-react-native: b4c0401d5663ef11f6b59a6df5e4c157ec307f55 stream-io-video-filters-react-native: 7a2c93ed95c7213f44cb8a9a15246f8301d2f67c - stream-react-native-webrtc: 8347bb401bfe33b52fd0da1a0b66b2b3016aef2f + stream-react-native-webrtc: 5c18524689456e8ad7e7dbc0f05d1ac11f5e7056 stream-video-react-native: 177671d5813623f097d311a4d6eb9e59394d693d StreamVideoNoiseCancellation: 41f5a712aba288f9636b64b17ebfbdff52c61490 StreamWebRTC: 8543c52d49cadcbe051582d0b13de01c8085735f diff --git a/sample-apps/react-native/dogfood/package.json b/sample-apps/react-native/dogfood/package.json index 106a9173a2..d641599740 100644 --- a/sample-apps/react-native/dogfood/package.json +++ b/sample-apps/react-native/dogfood/package.json @@ -25,7 +25,7 @@ "@react-navigation/native-stack": "^7.18.6", "@stream-io/noise-cancellation-react-native": "workspace:^", "@stream-io/react-native-callingx": "workspace:^", - "@stream-io/react-native-webrtc": "145.4.0-alpha.1", + "@stream-io/react-native-webrtc": "145.4.1", "@stream-io/video-filters-react-native": "workspace:^", "@stream-io/video-react-native-sdk": "workspace:^", "axios": "^1.19.0", diff --git a/sample-apps/react-native/expo-video-sample/package.json b/sample-apps/react-native/expo-video-sample/package.json index faf21ef7a2..81f47a5e54 100644 --- a/sample-apps/react-native/expo-video-sample/package.json +++ b/sample-apps/react-native/expo-video-sample/package.json @@ -19,7 +19,7 @@ "@react-native-firebase/messaging": "^24.1.1", "@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.1", "@stream-io/video-filters-react-native": "workspace:^", "@stream-io/video-react-native-sdk": "workspace:^", "expo": "~57.0.9", diff --git a/sample-apps/react-native/ringing-tutorial/package.json b/sample-apps/react-native/ringing-tutorial/package.json index 35763bde74..c4ab1be8ce 100644 --- a/sample-apps/react-native/ringing-tutorial/package.json +++ b/sample-apps/react-native/ringing-tutorial/package.json @@ -18,7 +18,7 @@ "@react-native-firebase/app": "^24.1.1", "@react-native-firebase/messaging": "^24.1.1", "@stream-io/react-native-callingx": "workspace:^", - "@stream-io/react-native-webrtc": "145.3.1", + "@stream-io/react-native-webrtc": "145.4.1", "@stream-io/video-react-native-sdk": "workspace:^", "expo": "~57.0.9", "expo-build-properties": "~57.0.8", diff --git a/yarn.lock b/yarn.lock index ce4b426eff..3050b0da84 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6832,7 +6832,7 @@ __metadata: "@react-native/metro-config": "npm:^0.86.2" "@stream-io/noise-cancellation-react-native": "workspace:^" "@stream-io/react-native-callingx": "workspace:^" - "@stream-io/react-native-webrtc": "npm:145.3.1" + "@stream-io/react-native-webrtc": "npm:145.4.1" "@stream-io/video-filters-react-native": "workspace:^" "@stream-io/video-react-native-sdk": "workspace:^" "@types/react": "npm:~19.2.18" @@ -6935,7 +6935,7 @@ __metadata: version: 0.0.0-use.local resolution: "@stream-io/noise-cancellation-react-native@workspace:packages/noise-cancellation-react-native" dependencies: - "@stream-io/react-native-webrtc": "npm:145.3.1" + "@stream-io/react-native-webrtc": "npm:145.4.1" "@stream-io/typescript-config": "workspace:^" react: "npm:19.2.3" react-native: "npm:0.86.2" @@ -6954,7 +6954,7 @@ __metadata: dependencies: "@react-native-community/cli": "npm:20.2.0" "@react-native/babel-preset": "npm:0.86.2" - "@stream-io/react-native-webrtc": "npm:145.3.1" + "@stream-io/react-native-webrtc": "npm:145.4.1" "@stream-io/typescript-config": "workspace:^" "@types/react": "npm:^19.2.18" del-cli: "npm:^6.0.0" @@ -6971,27 +6971,15 @@ __metadata: languageName: unknown linkType: soft -"@stream-io/react-native-webrtc@npm:145.3.1": - version: 145.3.1 - resolution: "@stream-io/react-native-webrtc@npm:145.3.1" +"@stream-io/react-native-webrtc@npm:145.4.1": + version: 145.4.1 + resolution: "@stream-io/react-native-webrtc@npm:145.4.1" dependencies: base64-js: "npm:^1.5.1" debug: "npm:^4.4.3" peerDependencies: react-native: ">=0.73.0" - checksum: 10/be8ad331e1e7943ac674c5f8804f717dd81869221ab4a109980f4f19744ca43479a8b783aa3790d4f042a581e7d807a9fd2ca775646a5b7983865d4db49da7fc - languageName: node - linkType: hard - -"@stream-io/react-native-webrtc@npm:145.4.0-alpha.1": - version: 145.4.0-alpha.1 - resolution: "@stream-io/react-native-webrtc@npm:145.4.0-alpha.1" - dependencies: - base64-js: "npm:^1.5.1" - debug: "npm:^4.4.3" - peerDependencies: - react-native: ">=0.73.0" - checksum: 10/0bb59a5e2e5f3b804b2e41a1ce6f006a3ade457ef71a07c9dbdaf575e9fcfa76534276ba12c4e39d18ce61c43d2c3a97dd558c56a75380400bd5ed3a5e14fd96 + checksum: 10/3be1dffd3c284310e54348b91f10ce8d2e3ec3b3cef933a27f271b4acb9ae0498b2a44bfa414e0c97d82706e0e596e5a2c8e5e2c9ce42950e59e8dc08afd9c12 languageName: node linkType: hard @@ -7091,7 +7079,7 @@ __metadata: version: 0.0.0-use.local resolution: "@stream-io/video-filters-react-native@workspace:packages/video-filters-react-native" dependencies: - "@stream-io/react-native-webrtc": "npm:145.3.1" + "@stream-io/react-native-webrtc": "npm:145.4.1" "@stream-io/typescript-config": "workspace:^" react: "npm:19.2.3" react-native: "npm:0.86.2" @@ -7262,7 +7250,7 @@ __metadata: "@react-navigation/native-stack": "npm:^7.18.6" "@stream-io/noise-cancellation-react-native": "workspace:^" "@stream-io/react-native-callingx": "workspace:^" - "@stream-io/react-native-webrtc": "npm:145.4.0-alpha.1" + "@stream-io/react-native-webrtc": "npm:145.4.1" "@stream-io/video-filters-react-native": "workspace:^" "@stream-io/video-react-native-sdk": "workspace:^" "@testing-library/react-native": "npm:13.3.3" @@ -7318,7 +7306,7 @@ __metadata: "@react-native-firebase/messaging": "npm:^24.1.1" "@react-native/metro-config": "npm:^0.86.2" "@stream-io/react-native-callingx": "workspace:^" - "@stream-io/react-native-webrtc": "npm:145.3.1" + "@stream-io/react-native-webrtc": "npm:145.4.1" "@stream-io/video-react-native-sdk": "workspace:^" "@types/react": "npm:~19.2.18" expo: "npm:~57.0.9" @@ -7362,7 +7350,7 @@ __metadata: "@react-native/metro-config": "npm:0.86.2" "@stream-io/noise-cancellation-react-native": "workspace:^" "@stream-io/react-native-callingx": "workspace:^" - "@stream-io/react-native-webrtc": "npm:145.4.0-alpha.1" + "@stream-io/react-native-webrtc": "npm:145.4.1" "@stream-io/typescript-config": "workspace:^" "@stream-io/video-client": "workspace:*" "@stream-io/video-filters-react-native": "workspace:^" @@ -7397,7 +7385,7 @@ __metadata: "@react-native-firebase/messaging": ">=17.5.0" "@stream-io/noise-cancellation-react-native": ">=0.1.0" "@stream-io/react-native-callingx": ">=0.1.0" - "@stream-io/react-native-webrtc": ^145.3.1 || >=145.4.0-alpha.1 + "@stream-io/react-native-webrtc": ^145.4.1 "@stream-io/video-filters-react-native": ">=0.1.0" expo: ">=47.0.0" expo-notifications: "*"