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 f2a7a13ec2..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(); @@ -983,12 +988,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); @@ -1135,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( @@ -1154,10 +1165,19 @@ 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`); } + // 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; @@ -1166,35 +1186,67 @@ 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; + } + } - await this.setup(); + 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; + } + } - this.clientEventReporter.registerCall(this.cid, { - callType: this.type, - callId: this.id, - getCallSessionId: () => this.state.session?.id ?? '', - getSfuId: () => this.credentials?.server.edge_name ?? '', - }); + await this.setup(); + if (supersededByLeave()) { + this.logger.debug('Join superseded by leave; not registering'); + return; + } - 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.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); 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; + } try { this.logger.trace(`Joining call (${attempt})`, this.cid); await this.doJoin(data); @@ -1202,6 +1254,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) || @@ -1242,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; } }, @@ -1270,6 +1329,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 +1360,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 +1425,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 +1462,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 +1479,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 +1494,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 +1525,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 +1714,7 @@ export class Call { closePreviousInstances: boolean; unifiedSessionId: string; }) => { + const joinLeaveGeneration = this.leaveGeneration; const { sfuClient, connectionConfig, @@ -1642,10 +1731,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 +1783,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 +1827,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 +1847,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 +1868,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 +1973,7 @@ export class Call { callingState === CallingState.JOINING || callingState === CallingState.RECONNECTING || callingState === CallingState.MIGRATING || + callingState === CallingState.LEFT || callingState === CallingState.RECONNECTING_FAILED ) return; @@ -1873,19 +1984,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 +2073,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 +2124,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 +2168,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 +2199,7 @@ export class Call { ); } } while ( + !supersededByLeave() && this.state.callingState !== CallingState.JOINED && this.state.callingState !== CallingState.RECONNECTING_FAILED && this.state.callingState !== CallingState.LEFT @@ -2091,10 +2213,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 +2231,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 +2244,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 +2259,7 @@ export class Call { * @internal */ private reconnectMigrate = async () => { + const reconnectLeaveGeneration = this.leaveGeneration; const reconnectStartTime = Date.now(); const currentSfuClient = this.sfuClient; if (!currentSfuClient) { @@ -2149,32 +2277,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 @@ -2508,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.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); + }); }); 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/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). --- 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/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/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-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/__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__/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/__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/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/package.json b/packages/react-native-sdk/package.json index a043d4682f..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", + "@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.3.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/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/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, ]} > - + ); }; 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/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 b9cb20e200..8ded1b72dc 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? ]; @@ -110,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 || @@ -146,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); @@ -161,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/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/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/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/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. +