From 78557b551915bac6153d09b1375b5172540e8e2e Mon Sep 17 00:00:00 2001 From: Santhosh Vaiyapuri Date: Thu, 27 Aug 2026 13:37:41 +0200 Subject: [PATCH 01/18] init: ringing + general -- needs polish --- packages/client/index.ts | 1 + .../io/getstream/rn/callingx/CallService.kt | 3 +- .../@stream-io/react-native-webrtc.tsx | 68 +++ .../encryption/EncryptionManager.test.ts | 421 ++++++++++++++++++ .../__tests__/encryption/eventMapping.test.ts | 228 ++++++++++ .../__tests__/encryption/legacyWebrtc.test.ts | 44 ++ .../__tests__/push/pushJoinHooks.test.ts | 262 +++++++++++ packages/react-native-sdk/package.json | 2 +- packages/react-native-sdk/src/index.ts | 4 + .../modules/encryption/EncryptionManager.ts | 360 +++++++++++++++ .../src/modules/encryption/eventMapping.ts | 151 +++++++ .../src/modules/encryption/index.ts | 1 + .../src/modules/encryption/parity.ts | 83 ++++ .../src/utils/StreamVideoRN/types.ts | 36 ++ .../src/utils/push/internal/utils.ts | 135 ++++++ .../react-native/dogfood/ios/Podfile.lock | 246 ++++++---- sample-apps/react-native/dogfood/package.json | 4 +- .../dogfood/src/components/ActiveCall.tsx | 2 + .../CallControls/TopControls/E2EEBadge.tsx | 54 +++ .../CallControls/TopControls/index.tsx | 5 + .../dogfood/src/components/E2EEKeyInput.tsx | 74 +++ .../src/components/E2EEKeyNotification.tsx | 149 +++++++ .../src/components/LobbyViewComponent.tsx | 44 +- .../dogfood/src/components/LockIcon.tsx | 27 ++ .../dogfood/src/components/MeetingUI.tsx | 21 +- .../dogfood/src/contexts/AppContext.tsx | 4 + .../dogfood/src/hooks/useE2eeKeyStatus.ts | 108 +++++ .../dogfood/src/navigators/Call.tsx | 31 ++ .../src/screens/Call/JoinCallScreen.tsx | 13 + .../screens/Meeting/GuestMeetingScreen.tsx | 12 +- .../src/screens/Meeting/JoinMeetingScreen.tsx | 2 + .../src/screens/Meeting/MeetingScreen.tsx | 8 +- .../react-native/dogfood/src/utils/e2ee.ts | 252 +++++++++++ .../dogfood/src/utils/setPushConfig.ts | 6 + yarn.lock | 138 +++++- 35 files changed, 2883 insertions(+), 116 deletions(-) create mode 100644 packages/react-native-sdk/__tests__/encryption/EncryptionManager.test.ts create mode 100644 packages/react-native-sdk/__tests__/encryption/eventMapping.test.ts create mode 100644 packages/react-native-sdk/__tests__/encryption/legacyWebrtc.test.ts create mode 100644 packages/react-native-sdk/__tests__/push/pushJoinHooks.test.ts create mode 100644 packages/react-native-sdk/src/modules/encryption/EncryptionManager.ts create mode 100644 packages/react-native-sdk/src/modules/encryption/eventMapping.ts create mode 100644 packages/react-native-sdk/src/modules/encryption/index.ts create mode 100644 packages/react-native-sdk/src/modules/encryption/parity.ts create mode 100644 sample-apps/react-native/dogfood/src/components/CallControls/TopControls/E2EEBadge.tsx create mode 100644 sample-apps/react-native/dogfood/src/components/E2EEKeyInput.tsx create mode 100644 sample-apps/react-native/dogfood/src/components/E2EEKeyNotification.tsx create mode 100644 sample-apps/react-native/dogfood/src/components/LockIcon.tsx create mode 100644 sample-apps/react-native/dogfood/src/hooks/useE2eeKeyStatus.ts create mode 100644 sample-apps/react-native/dogfood/src/utils/e2ee.ts diff --git a/packages/client/index.ts b/packages/client/index.ts index 5e71ea5c23..4a0c38e34a 100644 --- a/packages/client/index.ts +++ b/packages/client/index.ts @@ -26,6 +26,7 @@ export * from './src/helpers/sound-detector'; export * from './src/helpers/loopback'; export * from './src/helpers/MediaStreamRecorder'; export * from './src/helpers/participantUtils'; +export * from './src/helpers/TypedEventEmitter'; export * from './src/rtc/e2ee/E2EEManager'; export * from './src/rtc/e2ee/EncryptionManager'; export * as Browsers from './src/helpers/browsers'; diff --git a/packages/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 7a0702f45d..8ff6849617 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 @@ -511,9 +511,9 @@ class CallService : Service(), CallRepository.Listener { } return } - startForegroundForCall(callInfo, incoming) + scope.launch { try { callRepository.registerCall( @@ -531,6 +531,7 @@ class CallService : Service(), CallRepository.Listener { "[service] registerCall: Registration canceled for ${callInfo.callId} during teardown" ) } catch (e: Exception) { + // we dont remove the call from store Log.e(TAG, "[service] registerCall: Error registering call: ${e.message}") sendBroadcastEvent(CallingxModuleImpl.CALL_REGISTRATION_FAILED_ACTION) { 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__/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/pushJoinHooks.test.ts b/packages/react-native-sdk/__tests__/push/pushJoinHooks.test.ts new file mode 100644 index 0000000000..84acdd7570 --- /dev/null +++ b/packages/react-native-sdk/__tests__/push/pushJoinHooks.test.ts @@ -0,0 +1,262 @@ +import { BehaviorSubject } from 'rxjs'; +import { CallingState } from '@stream-io/video-client'; +import { processCallFromPushInBackground } from '../../src/utils/push/internal/utils'; +import { pushUnsubscriptionCallbacks } from '../../src/utils/push/internal/constants'; + +/** + * Covers the two `setPushConfig` lifecycle hooks on the push-accept path. + * + * This path is the reason they exist: the call is created and joined inside the SDK + * from the push payload, so an app that needs per-call setup before the join - an + * E2EE manager above all - has no other window. The tests therefore care about + * ordering (setup strictly before join) and about the fail-closed contract, not just + * that the callbacks fire. + */ + +const CALL_CID = 'default:push-hook-test'; + +type FakeCall = { + cid: string; + state: { + callingState: CallingState; + callingState$: BehaviorSubject; + session: undefined; + endedAt: undefined; + members: never[]; + }; + currentUserId: string; + join: jest.Mock; + leave: jest.Mock; + updatePublishOptions: jest.Mock; +}; + +const createFakeCall = (): FakeCall => { + const callingState$ = new BehaviorSubject(CallingState.RINGING); + return { + cid: CALL_CID, + state: { + callingState: CallingState.RINGING, + callingState$, + session: undefined, + endedAt: undefined, + members: [], + }, + currentUserId: 'me', + join: jest.fn().mockResolvedValue(undefined), + leave: jest.fn().mockResolvedValue(undefined), + updatePublishOptions: jest.fn(), + }; +}; + +const createPushConfig = (call: FakeCall, overrides: object = {}) => + ({ + createStreamVideoClient: jest + .fn() + .mockResolvedValue({ onRingingCall: jest.fn().mockResolvedValue(call) }), + ...overrides, + }) as any; + +const accept = (pushConfig: any) => + processCallFromPushInBackground( + pushConfig, + CALL_CID, + 'accept', + () => undefined, + ); + +describe('push accept lifecycle hooks', () => { + afterEach(() => { + pushUnsubscriptionCallbacks.delete(CALL_CID); + jest.restoreAllMocks(); + }); + + describe('onBeforeCallJoin', () => { + it('is awaited before the call joins', async () => { + const call = createFakeCall(); + const order: string[] = []; + call.join.mockImplementation(async () => { + order.push('join'); + }); + const onBeforeCallJoin = jest.fn(async () => { + // resolves on a later tick, so a non-awaiting implementation would + // record 'join' first and fail this + await new Promise((resolve) => setTimeout(resolve, 10)); + order.push('hook'); + }); + + await accept(createPushConfig(call, { onBeforeCallJoin })); + + expect(order).toEqual(['hook', 'join']); + expect(onBeforeCallJoin).toHaveBeenCalledWith(call); + }); + + it('aborts the join when it throws', async () => { + const call = createFakeCall(); + const onBeforeCallJoin = jest + .fn() + .mockRejectedValue(new Error('no key available')); + + await accept(createPushConfig(call, { onBeforeCallJoin })); + + // fail closed: joining anyway would publish unencrypted media on a call + // the user believes is private, with no UI on this path to reveal it + expect(call.join).not.toHaveBeenCalled(); + }); + + it('reports the failure to iOS so CallKit does not hang', async () => { + const call = createFakeCall(); + const onIOSActionCanBeFulfilled = jest.fn(); + + await processCallFromPushInBackground( + createPushConfig(call, { + onBeforeCallJoin: jest.fn().mockRejectedValue(new Error('boom')), + }), + CALL_CID, + 'accept', + onIOSActionCanBeFulfilled, + ); + + expect(onIOSActionCanBeFulfilled).toHaveBeenCalledWith(true); + }); + + it('aborts the join when it outruns the timeout', async () => { + jest.useFakeTimers(); + try { + const call = createFakeCall(); + const pending = accept( + createPushConfig(call, { + onBeforeCallJoin: jest.fn(() => new Promise(() => {})), + }), + ); + // let createStreamVideoClient/onRingingCall settle, then burn the budget + await Promise.resolve(); + await Promise.resolve(); + await jest.advanceTimersByTimeAsync(5_000); + await pending; + + expect(call.join).not.toHaveBeenCalled(); + } finally { + jest.useRealTimers(); + } + }); + + it('joins normally when absent', async () => { + const call = createFakeCall(); + + await accept(createPushConfig(call)); + + expect(call.join).toHaveBeenCalledTimes(1); + }); + }); + + describe('onAfterCallLeave', () => { + it('fires exactly once when the call leaves', async () => { + const call = createFakeCall(); + const onAfterCallLeave = jest.fn(); + + await accept( + createPushConfig(call, { + onBeforeCallJoin: jest.fn().mockResolvedValue(undefined), + onAfterCallLeave, + }), + ); + expect(onAfterCallLeave).not.toHaveBeenCalled(); + + call.state.callingState$.next(CallingState.JOINED); + expect(onAfterCallLeave).not.toHaveBeenCalled(); + + call.state.callingState$.next(CallingState.LEFT); + // a second LEFT must not double-release a manager that is already disposed + call.state.callingState$.next(CallingState.LEFT); + + expect(onAfterCallLeave).toHaveBeenCalledTimes(1); + expect(onAfterCallLeave).toHaveBeenCalledWith(call); + }); + + it('fires when the join fails after the pre-join hook already ran', async () => { + const call = createFakeCall(); + call.join.mockRejectedValue(new Error('sfu unreachable')); + const onAfterCallLeave = jest.fn(); + + await accept( + createPushConfig(call, { + onBeforeCallJoin: jest.fn().mockResolvedValue(undefined), + onAfterCallLeave, + }), + ); + + // the call never joins and may never reach LEFT, so whatever the pre-join + // hook installed would otherwise be stranded for the process lifetime + expect(onAfterCallLeave).toHaveBeenCalledTimes(1); + }); + + it('does not fire when the pre-join hook aborted the join', async () => { + const call = createFakeCall(); + const onAfterCallLeave = jest.fn(); + + await accept( + createPushConfig(call, { + onBeforeCallJoin: jest.fn().mockRejectedValue(new Error('boom')), + onAfterCallLeave, + }), + ); + call.state.callingState$.next(CallingState.LEFT); + + // nothing was set up, so there is nothing to release + expect(onAfterCallLeave).not.toHaveBeenCalled(); + }); + + it('swallows a rejection rather than leaking an unhandled one', async () => { + const call = createFakeCall(); + const unhandled = jest.fn(); + process.on('unhandledRejection', unhandled); + try { + await accept( + createPushConfig(call, { + onAfterCallLeave: jest + .fn() + .mockRejectedValue(new Error('cleanup failed')), + }), + ); + call.state.callingState$.next(CallingState.LEFT); + await new Promise((resolve) => setImmediate(resolve)); + + expect(unhandled).not.toHaveBeenCalled(); + } finally { + process.off('unhandledRejection', unhandled); + } + }); + + it('swallows a synchronous throw', async () => { + const call = createFakeCall(); + const onAfterCallLeave = jest.fn(() => { + throw new Error('cleanup exploded'); + }); + + await accept(createPushConfig(call, { onAfterCallLeave })); + + expect(() => + call.state.callingState$.next(CallingState.LEFT), + ).not.toThrow(); + expect(onAfterCallLeave).toHaveBeenCalledTimes(1); + }); + + it('registers an unsubscribe so foreground processing can clear it', async () => { + const call = createFakeCall(); + + await accept(createPushConfig(call, { onAfterCallLeave: jest.fn() })); + + expect(pushUnsubscriptionCallbacks.get(CALL_CID)?.length).toBeGreaterThan( + 0, + ); + }); + + it('registers nothing when absent', async () => { + const call = createFakeCall(); + + await accept(createPushConfig(call)); + + expect(pushUnsubscriptionCallbacks.get(CALL_CID)).toBeUndefined(); + }); + }); +}); diff --git a/packages/react-native-sdk/package.json b/packages/react-native-sdk/package.json index 6aa41c4945..bf5cdb8c97 100644 --- a/packages/react-native-sdk/package.json +++ b/packages/react-native-sdk/package.json @@ -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": "file:/Users/santhoshvaiyapuri/Documents/.stream-webrtc-local/stream-io-react-native-webrtc-0.0.0-local.1787231534.tgz", "@stream-io/typescript-config": "workspace:^", "@stream-io/video-filters-react-native": "workspace:^", "@testing-library/jest-native": "^5.4.3", diff --git a/packages/react-native-sdk/src/index.ts b/packages/react-native-sdk/src/index.ts index ab55ca1eb5..5f54e2888f 100644 --- a/packages/react-native-sdk/src/index.ts +++ b/packages/react-native-sdk/src/index.ts @@ -36,6 +36,10 @@ 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 and +// delegates to the web one on React Native Web. +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..77e5f55179 --- /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 once the call object + * is done with, and attach a fresh manager rather than reusing this one when + * re-joining (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/types.ts b/packages/react-native-sdk/src/utils/StreamVideoRN/types.ts index 59321063f9..fc78db6fcc 100644 --- a/packages/react-native-sdk/src/utils/StreamVideoRN/types.ts +++ b/packages/react-native-sdk/src/utils/StreamVideoRN/types.ts @@ -164,6 +164,42 @@ export type StreamVideoConfig = { * } */ createStreamVideoClient: () => Promise; + /** + * Awaited before a call accepted from a push notification joins. + * **Throwing aborts the join** and the call is not entered. + * + * This is the only window in which per-call setup that must precede the join is + * possible on this path: the call is created inside the SDK from the push payload, + * so no app code ever holds it. `call.setE2EEManager()` in particular throws once + * the call has peer connections. + * + * Keep it fast. The iOS CallKit accept has a hard deadline and this hook runs + * inside it, so anything slower than a few seconds is treated as a failure. + * + * @example + * onBeforeCallJoin: async (call) => { + * await attachE2EEIfConfigured(call); + * } + */ + onBeforeCallJoin?: (call: Call) => Promise; + /** + * Called once, when a call accepted from a push notification is finished with: + * normally when it leaves, and also when the join fails after + * {@link onBeforeCallJoin} has already run - otherwise whatever that hook + * installed would never be released. + * + * 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 this path the call can end while the app is still in the + * background, so no React cleanup ever runs. + * + * Only called when {@link onBeforeCallJoin} was reached, so it always pairs with + * a setup that actually happened. + * + * 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; }; foregroundService: { android: { 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..33906eaf89 100644 --- a/packages/react-native-sdk/src/utils/push/internal/utils.ts +++ b/packages/react-native-sdk/src/utils/push/internal/utils.ts @@ -14,6 +14,118 @@ type PushConfig = NonNullable; const logger = videoLoggerSystem.getLogger('callingx'); type CanAddPushWSSubscriptionsRef = { current: boolean }; +/** + * How long `onBeforeCallJoin` may take before it is treated as failed. + * + * 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; + +/** + * Runs the app's `onBeforeCallJoin` hook, if any, bounded by a timeout. + * + * Rejects when the hook rejects or outruns the timeout. Callers must treat that as + * fail-closed and skip the join: the hook is where a call gets its E2EE manager, and + * joining without one would publish unencrypted media on a call the user believes is + * private, with no UI on this path to reveal it. + */ +const runOnBeforeCallJoin = async ( + pushConfig: PushConfig, + call: Call, +): Promise => { + if (!pushConfig.onBeforeCallJoin) { + return; + } + let timeout: ReturnType | undefined; + try { + await Promise.race([ + pushConfig.onBeforeCallJoin(call), + 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, + ); + }), + ]); + } finally { + clearTimeout(timeout); + } +}; + +/** + * Calls the app's `onAfterCallLeave` hook once the call has left. + * + * Nothing is gated on it, so a sync throw and an async rejection are both merely + * logged - but they are logged rather than escaping, since an unhandled rejection + * here would surface as unrelated-looking noise far from its cause. + */ +const notifyAfterCallLeave = (pushConfig: PushConfig, call: Call): void => { + if (!pushConfig.onAfterCallLeave) { + return; + } + try { + Promise.resolve(pushConfig.onAfterCallLeave(call)).catch((e) => { + logger.warn(`onAfterCallLeave failed for callCid: ${call.cid}`, e); + }); + } catch (e) { + logger.warn(`onAfterCallLeave threw for callCid: ${call.cid}`, e); + } +}; + +/** + * Fires `onAfterCallLeave` the first time the call reaches LEFT. + * + * Deliberately driven off the call's own state rather than wired into each place + * that ends a call - there are several (decline here, the iOS close-condition + * watcher, the Android endCall listener) and a per-site hook would silently skip + * whichever one was missed. Resources the app releases here, an E2EE manager above + * all, have no other cleanup on this path: the call can end while the app is still + * in the background, so no React unmount ever runs. + */ +const notifyAfterCallLeaveOnce = ( + pushConfig: PushConfig, + call: Call, +): + | { + unsubscribe: () => void; + /** + * Fires the hook immediately, for the case where the call is finished with + * without ever reaching LEFT - a join that throws after the pre-join hook + * already ran, whose resources would otherwise never be released. + */ + notifyNow: () => void; + } + | undefined => { + if (!pushConfig.onAfterCallLeave) { + return undefined; + } + let notified = false; + const notifyOnce = () => { + if (notified) { + return; + } + notified = true; + notifyAfterCallLeave(pushConfig, call); + }; + const subscription = call.state.callingState$.subscribe((callingState) => { + if (callingState === CallingState.LEFT) { + notifyOnce(); + } + }); + return { + unsubscribe: () => subscription.unsubscribe(), + notifyNow: notifyOnce, + }; +}; + /** * This function is used to check if the call should be ended based on the push notification * Useful for callkeep management to end the call if necessary (with reportEndCallWithUUID) @@ -120,6 +232,26 @@ export const processCallFromPushInBackground = async ( onIOSActionCanBeFulfilled(true); return; } + // Fail closed: the join is the point of no return for per-call setup, so a hook + // that throws or stalls aborts it rather than proceeding without whatever it was + // meant to install. + try { + await runOnBeforeCallJoin(pushConfig, callFromPush); + } catch (e) { + logger.error( + `processCallFromPushInBackground: onBeforeCallJoin failed, not joining callCid: ${callFromPush.cid}`, + e, + ); + onIOSActionCanBeFulfilled(true); + return; + } + const afterCallLeave = notifyAfterCallLeaveOnce(pushConfig, callFromPush); + if (afterCallLeave) { + pushUnsubscriptionCallbacks.set(call_cid, [ + ...(pushUnsubscriptionCallbacks.get(call_cid) ?? []), + afterCallLeave.unsubscribe, + ]); + } try { onIOSActionCanBeFulfilled(false); await callFromPush.join(); @@ -128,6 +260,9 @@ export const processCallFromPushInBackground = async ( 'processCallFromPushInBackground: failed to join call from push notification', e, ); + // The pre-join hook already ran, so anything it installed is live on a call + // that will never join and may never reach LEFT. Release it here. + afterCallLeave?.notifyNow(); } } else if (action === 'decline') { const alreadyLeft = callFromPush.state.callingState === CallingState.LEFT; diff --git a/sample-apps/react-native/dogfood/ios/Podfile.lock b/sample-apps/react-native/dogfood/ios/Podfile.lock index c878cb653b..4b07faf6f0 100644 --- a/sample-apps/react-native/dogfood/ios/Podfile.lock +++ b/sample-apps/react-native/dogfood/ios/Podfile.lock @@ -1,5 +1,5 @@ PODS: - - Callingx (0.9.0): + - Callingx (0.10.0): - hermes-engine - RCTRequired - RCTTypeSafety @@ -75,6 +75,30 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga + - QuickCrypto (1.1.7): + - hermes-engine + - NitroModules + - RCTRequired + - RCTTypeSafety + - React-callinvoker + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga - RCTDeprecation (0.86.2) - RCTRequired (0.86.2) - RCTSwiftUI (0.86.2) @@ -1564,6 +1588,28 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga + - react-native-quick-base64 (3.0.1): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga - react-native-safe-area-context (5.8.1): - hermes-engine - RCTRequired @@ -2502,7 +2548,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - stream-io-noise-cancellation-react-native (0.10.0): + - stream-io-noise-cancellation-react-native (0.11.0): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2526,7 +2572,7 @@ PODS: - stream-react-native-webrtc - StreamVideoNoiseCancellation - Yoga - - stream-io-video-filters-react-native (0.15.0): + - stream-io-video-filters-react-native (0.16.0): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2551,8 +2597,7 @@ PODS: - Yoga - stream-react-native-webrtc (145.3.1): - React-Core - - StreamWebRTC (= 145.15.0) - - stream-video-react-native (1.43.0): + - stream-video-react-native (1.44.0): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2576,7 +2621,6 @@ PODS: - stream-react-native-webrtc - Yoga - StreamVideoNoiseCancellation (1.0.3) - - StreamWebRTC (145.15.0) - Teleport (1.1.12): - hermes-engine - RCTRequired @@ -2636,6 +2680,7 @@ DEPENDENCIES: - hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`) - NitroMmkv (from `../node_modules/react-native-mmkv`) - NitroModules (from `../node_modules/react-native-nitro-modules`) + - QuickCrypto (from `../node_modules/react-native-quick-crypto`) - RCTDeprecation (from `../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`) - RCTRequired (from `../node_modules/react-native/Libraries/Required`) - RCTSwiftUI (from `../node_modules/react-native/ReactApple/RCTSwiftUI`) @@ -2677,6 +2722,7 @@ DEPENDENCIES: - react-native-blob-util (from `../node_modules/react-native-blob-util`) - react-native-image-picker (from `../node_modules/react-native-image-picker`) - "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)" + - react-native-quick-base64 (from `../node_modules/react-native-quick-base64`) - react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`) - react-native-video (from `../node_modules/react-native-video`) - React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) @@ -2739,7 +2785,6 @@ SPEC REPOS: trunk: - MMKVCore - StreamVideoNoiseCancellation - - StreamWebRTC EXTERNAL SOURCES: Callingx: @@ -2753,6 +2798,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-mmkv" NitroModules: :path: "../node_modules/react-native-nitro-modules" + QuickCrypto: + :path: "../node_modules/react-native-quick-crypto" RCTDeprecation: :path: "../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation" RCTRequired: @@ -2833,6 +2880,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-image-picker" react-native-netinfo: :path: "../node_modules/@react-native-community/netinfo" + react-native-quick-base64: + :path: "../node_modules/react-native-quick-base64" react-native-safe-area-context: :path: "../node_modules/react-native-safe-area-context" react-native-video: @@ -2949,112 +2998,113 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon/yoga" SPEC CHECKSUMS: - Callingx: 1e13711cc1b85d266438b5c4412ecb1bd6bc511d + Callingx: 631a3ee7de8214aa735585ec810528bf3f91c553 FBLazyVector: 3c3be9a019176b5699455f7f66c5444b78a411c6 - hermes-engine: 5da8d7eeb13a48d932b021d8d9df9e27d1acc5be + hermes-engine: bea2770719c59875d56e955d6b155b36f4b5a08e MMKVCore: 3d16ce9f7d411e135020915fde98a056859a1efa - NitroMmkv: 716bf336b20feaf2e72ebf2fd877bae778f19203 - NitroModules: 1c3563bec5c8af1ebf57c053efe08f708448a669 + NitroMmkv: 38b271a19e7e152025a6893160c98326872ea05f + NitroModules: e0ac5f9a04e23cb2f378b51810ebc07ed63aeae9 + QuickCrypto: 52d2f094ec7051e1ce8e66333088fefe6c253a7a RCTDeprecation: bccb6545c26db881ecddfd83a3f9ea82aba1605f RCTRequired: b2f74764d596fc0051f00fee94b49bf41a6f7f5a RCTSwiftUI: c6d6a31b849b9dfa64c33b55dc91ac15dd55774c - RCTSwiftUIWrapper: 0106c38ccc929e3b2f8815c50ab873e345ab2777 + RCTSwiftUIWrapper: bdff268d65d662a79b1e571e841e1512275f9319 RCTTypeSafety: 159e394bdab42023fbdd8fa022dfdc5577a8b9ad React: 4b2532a459d15e1adf6c22d3e399e5c85a94220f React-callinvoker: 0b8ce4057e02a0bd15cf0532596e8eb8c0392e92 - React-Core: 52b990bb36640833e636d5efc817ad4700dd7f93 - React-Core-prebuilt: 39eb00d8ba4922167a3a70e2039b102fe42b6fe1 - React-CoreModules: 43516d84b3851a850a835d32ec740db319e7bf9e - React-cxxreact: 9254233b543b9f49bdbcad329603e70be5582a0e + React-Core: 5af045531a540ba3f65f07de1e3f585ddfb27948 + React-Core-prebuilt: 405cf395d66cf694faf9aed3483a21b5515cec85 + React-CoreModules: 99b194a721de84ccfc1be149a0de52647dc38c0e + React-cxxreact: b7e8e254074fd8111d147202b391ccf7816946a6 React-debug: 3281bfefe5ece9a9d8b28bec3f871db229f9d8d8 - React-defaultsnativemodule: 92c7b0c67121be15ecf592a51fb5a380258e95d1 - React-domnativemodule: c6517298e22a92d209d176e924b3af7ddfcc58b2 - React-Fabric: 023ee019c7070478c769260a60933eaf4dd3243e - React-FabricComponents: d6072c495c9a8599b92cee4b0450fb64647f06eb - React-FabricImage: af1c23d16347204ec0a367f646cd073365866f96 - React-featureflags: db64c938d45f7551854382c7cd3a7cf25fbdeb3b - React-featureflagsnativemodule: 9f7d235d716bdec416a1e26c3800835a6a738f13 - React-graphics: 96b40932178005f3e5f2f8b454d109d5bd9d9050 - React-hermes: 9f44a83eca48842bd3f60fdc16bdb4fa29105eae - React-idlecallbacksnativemodule: 2339ec52bc6c86c00d457e49dfee2382107eed8d - React-ImageManager: d9b380862bae15a882354435cb9978fdf014c9bb - React-intersectionobservernativemodule: 759abe72f3cd20b3bedb8257f2b9a9dc587d9da5 - React-jserrorhandler: c396b1908915fbdc88fb5fa264c2922bc97561b8 - React-jsi: 40bd06de577b224175f2ce5b4eb2cffa449c86df - React-jsiexecutor: 0c6068034234dd6910fa6d82f378f75bcc02a8b7 - React-jsinspector: 00e8c1b836e93bed2f36ca38ba40bd99400c9cda - React-jsinspectorcdp: ae77a36d099a875244db0c35ef22741e8c64dc4e - React-jsinspectornetwork: 77357fb7aa231b8a7127a1e8ca930cc4664d665f - React-jsinspectortracing: b76744a010e642ac16c97ce19fd97657a72ed903 - React-jsitooling: d57fbc06240118518c5af1d930ac78c5fca96546 - React-jsitracing: 15b31360fc82a0dcd4b653c219f535564f7678c3 - React-logger: de0bd725e905e9be3ab919a4206666eec7c3e248 - React-Mapbuffer: 329c2dc6d6a6a4579d858891a7bbc00933316ea9 - React-microtasksnativemodule: c960394926ca5dc8f22636f6da162497192415c3 - React-mutationobservernativemodule: 4fcd10e492236d5de412df84c904c8ca62ac1b29 - react-native-blob-util: 37e8b9921fe7bfa1c83c156bdba73992e19b9bde - react-native-image-picker: 9dce42d17f5917de55bdff39a587390fd496beda - react-native-netinfo: 10fc5ca4331d893efc2b90adb43869a85d66654e - react-native-safe-area-context: bcea1b7671431001d60db53f48c2e768698d2860 - react-native-video: c8a32ec11cf5134121fa8bf07017a2522f736b65 - React-NativeModulesApple: f313ed47b56405d621e12ecda106a72020959ff3 - React-networking: e424e5e77a7b124143ceac8acf44352e0baae8bd + React-defaultsnativemodule: 1bd0a6e02f816f0c939baeaf2b7a9e799327274e + React-domnativemodule: f7d8ab3fbd37453301d69a866bc21456dc8c9bd8 + React-Fabric: 9bb75bdf09a445e74d49dc2152cd2176d1379432 + React-FabricComponents: f5c87bd4421ed262c25f57e8dea560221074e065 + React-FabricImage: 39be2035c85a1004004c3dc62ddc3f17073fe105 + React-featureflags: de5b3e964937f9dfbc05b98e51f3f4d0472789f4 + React-featureflagsnativemodule: 6bebf75aeddd8799107a13f1db99f1a0ce2a0977 + React-graphics: 9d482a1031375b90704be420d445879bed3132f7 + React-hermes: 6fd579ceeb680830fc508697acdd5e8cd8dbf29f + React-idlecallbacksnativemodule: b5dacdd51c63ab15ed8a9bb1c875c6f0e92160d4 + React-ImageManager: 1e86a5fbe7c49cc39f4bfb49f02df410d53dd96c + React-intersectionobservernativemodule: 13011eeac1a10a76e63888b1baab1ca2839fed39 + React-jserrorhandler: 8c7e83f8259120207965c61e430733218d8fb58e + React-jsi: 7b14065a285f91b3dbe5448371ff575bbb523d59 + React-jsiexecutor: 10b4ba40ec9fd571370dfee20251201f57712e79 + React-jsinspector: fb62fdf5baea797b121e5566492536e3fab928ff + React-jsinspectorcdp: 2a0a882d722a17cea6bebcce23464156c470f384 + React-jsinspectornetwork: b1dc2b219dd1c9bdbf13dccc8bfe313c39cf6cd7 + React-jsinspectortracing: c57b2e67ee3b56f98d2b507d3a29c1b5d44049e6 + React-jsitooling: 9fc594ea389d97893904f8ddafe1bec63313321b + React-jsitracing: ecbbc6d7f3de28197849da27f0e273e34a55626d + React-logger: 35ab012027cc552057f7ca5d031c6721f896e6bc + React-Mapbuffer: 75518c85e76e5117a6f7cac4e0bec4ba0a45723f + React-microtasksnativemodule: 9d2adb05be26b36bff9e3f24b0cc5bf0a0511c65 + React-mutationobservernativemodule: e450c29376a09a0f6dd23170096f3e46f1ba6d57 + react-native-blob-util: c29d5e7db12b72d656144276ee89bbb94d5c4c6d + react-native-image-picker: 09cea417bef3ee25008ad21d4426f9a1fc54ad84 + react-native-netinfo: a05f9b897e76ad24b53f615fed1cbb731932363d + react-native-quick-base64: e254a406ac7d7f20857c9fbcbfb57fbf91ae21be + react-native-safe-area-context: 6255354c51cdabe084b19ed7bd2cd9cd6b0f57e2 + react-native-video: 950a8bb07646654716ebf0dd78acf16f95fa3b6c + React-NativeModulesApple: 167e532fb9bae30f3c66636b8ee0092bab263667 + React-networking: e382e6f2f815e801a34a630e708a551c7feb2090 React-oscompat: db6675ddaef3bddd1b41533627f3d26ec710c05f - React-perflogger: 21ca12b8deb8411c6fa4a564e6e4e0800390b556 - React-performancecdpmetrics: 5043a827acb64f0931311eec1d894bd785e49622 - React-performancetimeline: 6d332fd2b2d17a43e49621bfe380cffe15321704 + React-perflogger: c34660c72849d23319f5e544c97e6c6ed687b186 + React-performancecdpmetrics: c852458c139c8c2a15284ddb7d4927d907b38731 + React-performancetimeline: cf6cd525e8d8a1c625985d54d9ee68296073f281 React-RCTActionSheet: 7d18777c531c516ab9f86342583057b15fb7fd7d - React-RCTAnimation: 14a2fdc1165d73dc96abd0935e89c2127a119fbd - React-RCTAppDelegate: 0b5a06df526fe79b2a5cbe90b889e28867e14574 - React-RCTBlob: f2ebb238c43a631f541377ec18ed60bb70665363 - React-RCTFabric: d1148be6b253d86ffcbac455a5c472acdf3cdc19 - React-RCTFBReactNativeSpec: 4fb3182f19d21880f3d09198a0ef718e155bf46a - React-RCTImage: 1d8c5b8786a0f52d0d2613b8ba58c02c1da67c1e - React-RCTLinking: 2560d59e85c82f260bff81fffc4ea30bc66a500b - React-RCTNetwork: d331a823911e70fd1de7eeddaa739f4c54dcacc4 - React-RCTRuntime: 7ebe23fb1fa548c7d59d2aa30e6ad8a76230c0a8 - React-RCTSettings: b7896598aa87af4b907628164240e87457e6f830 - React-RCTText: 1eb82d0798487adcf394a12d8fad825722d1a376 - React-RCTVibration: 0c76a15901bb1c8d47a8a02dc16f95f39ed3ffb5 + React-RCTAnimation: f2505067d2d83268f5619192f8f5ff14b2606c7f + React-RCTAppDelegate: 7bfa4d752f45a0b9195b8da0f188f8904806a86f + React-RCTBlob: 7b4d25eca2a6ecd83766d76790879774b73b4cd5 + React-RCTFabric: 2f4e25dd2a256cbba5d95d8ec2c03a876fddd8ec + React-RCTFBReactNativeSpec: 1f8c18b3344dee44d60d750958a0d0f94fcf4f3d + React-RCTImage: 9123b010921479b5bcd3771a4a4d4e788227a427 + React-RCTLinking: 358d42629d7012c5d75060fd3e25023b72ebae31 + React-RCTNetwork: 32f5274abd61e937bdcbfd85810ae784d3f0e38a + React-RCTRuntime: 606364977ba254a416e85ef7a9f6e08b89818a32 + React-RCTSettings: 86aa6e6a3a335d989e3fcd1bd98b9ea6331adfa9 + React-RCTText: 59376611225f3c6fdc75d57571d7c345bdd0be1e + React-RCTVibration: 3c7d17d3373c114220be9ac3b99df1646009b8fe React-rendererconsistency: 3238990615931ff327b5f1d98852cd6603dc5d10 - React-renderercss: 95ecdb01c38d79c99d91d8cfdbd7e86e53cd5ec0 - React-rendererdebug: 04ee94e461f37f2bc636e4e87cc58d9c7d29fd11 - React-RuntimeApple: 529b641fb6d9b191393b740e20e5ace72d65556f - React-RuntimeCore: ea394fde02dbdd63159603aa99df473f21a51d18 - React-runtimeexecutor: b50e7e6e028eb62aa561f2efb9d9a3cd79271929 - React-RuntimeHermes: 270988ef200429fc26d90fb0a858c51d9e5427ad - React-runtimescheduler: b2a156eb703fdcbdcac286db7073e15474e4fe0e - React-timing: 84d125637f03b6fa478830aeafcb41e3657054f8 - React-utils: 2490f15bfc04de9ae46d3feda964d85483fa76ae - React-viewtransitionnativemodule: 7b3f3f1b3bd0d6890733838cb3dac3b28acc848a - React-webperformancenativemodule: cf21b4a6de564da1275f866cf939d77241f4cd29 - ReactAppDependencyProvider: 082ef199e27011a4314f499b6b2e2ac29e141267 - ReactCodegen: 908cd0191a680b54042374076c28eaf3b5fdfd0e - ReactCommon: 00d8d68c7aa036745334d3a3844102ecd9bf099d + React-renderercss: a0142ecb1e580926514c05c70205e825230005d0 + React-rendererdebug: d475dc238a8f39c248d328605bc48268b2111625 + React-RuntimeApple: 2aab0db6b710869c4e995e0b9054edbe01c1f182 + React-RuntimeCore: 701d4a24f5a4a5e990b1cafea443e7bffb707de8 + React-runtimeexecutor: 9a0a090bf8183a45ddda95be72ba0d2d7bc5a6d4 + React-RuntimeHermes: c0a3a63d306daf45646862ce09afa00d8e654873 + React-runtimescheduler: 2af097dd7630e559e7f0a740d875857c4a02f90d + React-timing: 329b5e88f59491a5e893f6c549bd10883ef30e5a + React-utils: 25a0beadc5eacbffeb965c7024826b1233dbedc6 + React-viewtransitionnativemodule: 8f1d4895e081e0b2f0a98069c098eb40be032138 + React-webperformancenativemodule: 5bddf6c7eecda8a3ce48b82609008965cc9d94c2 + ReactAppDependencyProvider: 0e13d430eadac8a2ef18515a860d5c59df05b475 + ReactCodegen: b4e2c5f0d9415b8fe2faf163fca6f40c62f9666a + ReactCommon: 9002f006f571256348994183f7d4387aa7cf84e8 ReactNativeDependencies: 2bd6854ade79bf1b60586d1ab7813a389df1b6bf - RNCClipboard: 815e7ed527ba8c00ffa9fcdeb578510278beb0cb - RNCPushNotificationIOS: b9dcb4f28c85f77483c3f86d2fcb1e0f0ab396fa - RNDeviceInfo: d79872e11c8e9c4de0d65b0ee6e0cee719f37fea - RNGestureHandler: 25d3ab36590ef2d9ca578fc56436e5e3b72ad077 - RNNotifee: 4a6ee5c7deaf00e005050052d73ee6315dff7ec9 - RNPermissions: ec9ae245c85cbd3ba0b04eed47c5c992c23310db - RNReactNativeHapticFeedback: 48e6c9d9ad40906a260a207367c701d3baaa8d92 - RNReanimated: d7984411500b072f713444cc127d9c51af38e0b6 - RNScreens: fd17d67cc5fefe79ab4d9811c8b668312d9057b8 - RNShare: 489793682cecee51a0ece2772d9462a2949ffc9c - RNSVG: 10edb4a8a7e6bb61313c0a2311cc321644ff5e0a - RNWorklets: ca7e0a49681a74e09f09e389aefd9e453fa0079b - stream-chat-react-native: baf7e1c3fc101f8a1b95ad0c6693ddaf2bab384e - stream-io-noise-cancellation-react-native: 1dc825d5b6e69da05dcd7342498e0546a5ffdf49 - stream-io-video-filters-react-native: d9e5695d2fe09dc897236aecc8527ba45656482d - stream-react-native-webrtc: ab1633ad73f2eb1b8baf9f13858c78f2f62d1986 - stream-video-react-native: 5747603aad92ea28792951bc6cb84a549941a172 + RNCClipboard: 7a7d4557bfd3370b35c99dfecd92ae7b9fc4948a + RNCPushNotificationIOS: 85957a0534fd1309b4c028ccbe5b1baf9ab5cf57 + RNDeviceInfo: 4c852998208b60dc192ae3529e5867817719ad1e + RNGestureHandler: 7b07d9192bc65c6883fb37fdecc05dc6b61c814f + RNNotifee: 5e3b271e8ea7456a36eec994085543c9adca9168 + RNPermissions: 19510b9e4f2de1679a3213fc9be68eee4980769c + RNReactNativeHapticFeedback: e55b7b6240c89ca0865903cce8eac4cb2f9c1a6e + RNReanimated: fe41f7fdb22db30ada8954493cf9178757cb231a + RNScreens: 5b4b260e28d8263d97fddcb53e59649515797bcc + RNShare: 26c9524aee8cc3eedbab6d6b98cacce2f5247893 + RNSVG: 394cfd0518613b144c65ffe69045f827b78a7a7c + RNWorklets: 43af89f696342f91118a2f1f3a879056e2f47331 + stream-chat-react-native: e97f6d3ed0c2828b20610ffc0023ad7f9c90738d + stream-io-noise-cancellation-react-native: 2d3908635106b04f39569c7cc7504ec9d534f447 + stream-io-video-filters-react-native: 2f542b1b19ce0827c1723f2e372cf67fdf9d9adf + stream-react-native-webrtc: 8fe731cfda4b2e84d2ddfccd8145346b9244bb95 + stream-video-react-native: f8d181de2e7a347fc90c9d256ee416a4ffbc8dd6 StreamVideoNoiseCancellation: 41f5a712aba288f9636b64b17ebfbdff52c61490 - StreamWebRTC: 89743e7e21ec6e388462919246051e8ed6b3dfc1 - Teleport: 4cb9855422c04666fbeee9a52e5342466e27b009 - VisionCamera: 68d40255fa8866e815cbbb063730102d3da276fd + Teleport: c56b30b08bd20d10da1efb0f21c6bc50c269ee6a + VisionCamera: b51af005669c6642dd1b4fca434eda4c1778de87 Yoga: 542a30dafe5b0f5f1d9f185ea7b2a3811ac54801 PODFILE CHECKSUM: c29d9efec31064fe74fffdba980d3dc6a9a83ae9 -COCOAPODS: 1.16.2 +COCOAPODS: 1.15.2 diff --git a/sample-apps/react-native/dogfood/package.json b/sample-apps/react-native/dogfood/package.json index cc80f81a9f..4e12453574 100644 --- a/sample-apps/react-native/dogfood/package.json +++ b/sample-apps/react-native/dogfood/package.json @@ -23,7 +23,7 @@ "@react-navigation/native-stack": "^7.18.6", "@stream-io/noise-cancellation-react-native": "workspace:^", "@stream-io/react-native-callingx": "workspace:^", - "@stream-io/react-native-webrtc": "145.3.1", + "@stream-io/react-native-webrtc": "file:/Users/santhoshvaiyapuri/Documents/.stream-webrtc-local/stream-io-react-native-webrtc-0.0.0-local.1787231534.tgz", "@stream-io/video-filters-react-native": "workspace:^", "@stream-io/video-react-native-sdk": "workspace:^", "axios": "^1.19.0", @@ -37,6 +37,8 @@ "react-native-mmkv": "^4.3.2", "react-native-nitro-modules": "^0.36.5", "react-native-permissions": "^5.6.1", + "react-native-quick-base64": "^3.0.1", + "react-native-quick-crypto": "^1.1.7", "react-native-reanimated": "4.5.3", "react-native-safe-area-context": "~5.8.0", "react-native-screens": "4.26.2", diff --git a/sample-apps/react-native/dogfood/src/components/ActiveCall.tsx b/sample-apps/react-native/dogfood/src/components/ActiveCall.tsx index ada8375698..4d5db4a56f 100644 --- a/sample-apps/react-native/dogfood/src/components/ActiveCall.tsx +++ b/sample-apps/react-native/dogfood/src/components/ActiveCall.tsx @@ -18,6 +18,7 @@ import { View, } from 'react-native'; import { ParticipantsInfoListModal } from './ParticipantsInfoListModal'; +import { E2EEKeyNotification } from './E2EEKeyNotification'; import { BottomControls } from './CallControls/BottomControls'; import { useOrientation } from '../hooks/useOrientation'; import { Z_INDEX } from '../constants'; @@ -141,6 +142,7 @@ export const ActiveCall = ({ barStyle={themeMode === 'light' ? 'dark-content' : 'light-content'} /> {!isInPiPMode && } + {!isInPiPMode && } { + const { useE2eeEnabled } = useCallStateHooks(); + const e2eeEnabled = useE2eeEnabled(); + const { theme } = useTheme(); + const styles = useStyles(); + + if (!e2eeEnabled) return null; + + return ( + + + + ); +}; + +const useStyles = () => { + const { theme } = useTheme(); + return useMemo( + () => + StyleSheet.create({ + container: { + alignItems: 'center', + backgroundColor: theme.colors.buttonSecondary, + borderRadius: 8, + height: 36, + justifyContent: 'center', + width: 36, + }, + }), + [theme], + ); +}; diff --git a/sample-apps/react-native/dogfood/src/components/CallControls/TopControls/index.tsx b/sample-apps/react-native/dogfood/src/components/CallControls/TopControls/index.tsx index 2b03ebcb68..bf377df39e 100644 --- a/sample-apps/react-native/dogfood/src/components/CallControls/TopControls/index.tsx +++ b/sample-apps/react-native/dogfood/src/components/CallControls/TopControls/index.tsx @@ -8,6 +8,7 @@ import { } from '@stream-io/video-react-native-sdk'; import { CallStatusBadge } from './CallStatusBadge'; import { LayoutSwitcherButton } from './LayoutSwitcherButton'; +import { E2EEBadge } from './E2EEBadge'; export type TopControlsProps = { onHangupCallHandler?: () => void; @@ -47,6 +48,7 @@ export const TopControls = ({ + { centerElement: { flex: 1, alignItems: 'center', + flexDirection: 'row', + gap: 8, + justifyContent: 'center', }, rightElement: { flex: 1, diff --git a/sample-apps/react-native/dogfood/src/components/E2EEKeyInput.tsx b/sample-apps/react-native/dogfood/src/components/E2EEKeyInput.tsx new file mode 100644 index 0000000000..1273005b0a --- /dev/null +++ b/sample-apps/react-native/dogfood/src/components/E2EEKeyInput.tsx @@ -0,0 +1,74 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { useTheme } from '@stream-io/video-react-native-sdk'; +import { + useAppGlobalStoreSetState, + useAppGlobalStoreValue, +} from '../contexts/AppContext'; +import { appTheme } from '../theme'; +import { TextInput } from './TextInput'; + +/** + * Debug entry for the end-to-end encryption key. + * + * Keys are the app's business, not the SDK's, so the passphrase never leaves + * this app: it is stretched locally and installed as a shared key. Interop with + * the web and iOS demos relies on all three deriving the same bytes from the + * same passphrase. + */ +export const E2EEKeyInput = () => { + const setState = useAppGlobalStoreSetState(); + const stored = useAppGlobalStoreValue((store) => store.e2eeKeyInput) ?? ''; + const [draft, setDraft] = useState(stored); + const styles = useStyles(); + + // Persisted on every keystroke rather than on blur: tapping "Join Call" does + // not blur a focused input on iOS, and leaving the screen never fires onBlur, + // so a blur-only commit would let someone type a key and still join in the + // clear. + const onChangeText = useCallback( + (value: string) => { + setDraft(value); + setState({ e2eeKeyInput: value.trim() }); + }, + [setState], + ); + + return ( + + End-to-end encryption + + {draft.trim() ? 'Key set' : 'Off'} + + ); +}; + +const useStyles = () => { + const { theme } = useTheme(); + return useMemo( + () => + StyleSheet.create({ + container: { + marginTop: appTheme.spacing.lg, + }, + label: { + color: theme.colors.textPrimary, + fontSize: 14, + fontWeight: '500', + marginBottom: appTheme.spacing.sm, + }, + status: { + color: appTheme.colors.light_gray, + fontSize: 12, + marginTop: appTheme.spacing.sm, + }, + }), + [theme], + ); +}; diff --git a/sample-apps/react-native/dogfood/src/components/E2EEKeyNotification.tsx b/sample-apps/react-native/dogfood/src/components/E2EEKeyNotification.tsx new file mode 100644 index 0000000000..cf7b5ce75d --- /dev/null +++ b/sample-apps/react-native/dogfood/src/components/E2EEKeyNotification.tsx @@ -0,0 +1,149 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { useCall, useI18n, useTheme } from '@stream-io/video-react-native-sdk'; +import { useE2eeKeyStatus } from '../hooks/useE2eeKeyStatus'; +import { + useAppGlobalStoreSetState, + useAppGlobalStoreValue, +} from '../contexts/AppContext'; +import { updateE2EESharedKeys } from '../utils/e2ee'; +import { appTheme } from '../theme'; +import { TextInput } from './TextInput'; + +/** + * Surfaces a shared-key mismatch on an encrypted call. + * + * Without this a wrong meeting key looks like a broken call rather than a wrong + * key: media arrives, fails its authentication tag and is dropped, so tiles stay + * black and audio silent with nothing said about why. + * + * When the failure looks local, the banner doubles as the fix: the key can be + * re-entered here and is pushed straight to the native manager, so a mistyped key + * does not cost a rejoin. Dismissable, and re-armed once decryption recovers, so + * a later mismatch is surfaced again rather than nagging about this one. + */ +export const E2EEKeyNotification = () => { + const status = useE2eeKeyStatus(); + const call = useCall(); + const { t } = useI18n(); + const setState = useAppGlobalStoreSetState(); + const storedKey = useAppGlobalStoreValue((store) => store.e2eeKeyInput) ?? ''; + const [dismissed, setDismissed] = useState(false); + const [draftKey, setDraftKey] = useState(''); + const styles = useStyles(); + + // Re-arm once the call recovers, so a later mismatch is surfaced again. + useEffect(() => { + if (status.kind === 'ok') { + setDismissed(false); + setDraftKey(''); + } + }, [status.kind]); + + if (status.kind === 'ok' || dismissed) return null; + + const applyKey = () => { + const key = draftKey.trim(); + if (!key || !call) return; + // Persist as well as apply: the stored value is what the next call is + // created and encrypted with. + setState({ e2eeKeyInput: key }); + updateE2EESharedKeys(call, key); + setDraftKey(''); + }; + + return ( + + + + {status.kind === 'local-key-mismatch' + ? t( + "Nobody's audio or video can be decrypted. Your meeting key is most likely wrong.", + ) + : `${t('Cannot decrypt participants:')} ${status.names.join(', ')}`} + + setDismissed(true)} + hitSlop={12} + accessibilityLabel={t('Dismiss')} + > + ✕ + + + {status.kind === 'local-key-mismatch' && ( + + + + {t('Apply')} + + + )} + + ); +}; + +const useStyles = () => { + const { theme } = useTheme(); + return useMemo( + () => + StyleSheet.create({ + container: { + backgroundColor: theme.colors.sheetSecondary, + borderRadius: 8, + marginHorizontal: appTheme.spacing.md, + marginTop: appTheme.spacing.sm, + padding: appTheme.spacing.md, + }, + row: { + flexDirection: 'row', + alignItems: 'flex-start', + }, + message: { + color: theme.colors.textPrimary, + flex: 1, + fontSize: 13, + }, + dismiss: { + color: appTheme.colors.light_gray, + fontSize: 16, + marginLeft: appTheme.spacing.md, + }, + form: { + alignItems: 'center', + flexDirection: 'row', + marginTop: appTheme.spacing.sm, + }, + input: { + flex: 1, + marginVertical: 0, + }, + apply: { + backgroundColor: theme.colors.buttonPrimary, + borderRadius: 8, + marginLeft: appTheme.spacing.md, + paddingHorizontal: appTheme.spacing.lg, + paddingVertical: appTheme.spacing.sm, + }, + applyDisabled: { + backgroundColor: theme.colors.buttonDisabled, + }, + applyText: { + color: theme.colors.textPrimary, + fontWeight: '600', + }, + }), + [theme], + ); +}; diff --git a/sample-apps/react-native/dogfood/src/components/LobbyViewComponent.tsx b/sample-apps/react-native/dogfood/src/components/LobbyViewComponent.tsx index bf7848ff91..cc0400604b 100644 --- a/sample-apps/react-native/dogfood/src/components/LobbyViewComponent.tsx +++ b/sample-apps/react-native/dogfood/src/components/LobbyViewComponent.tsx @@ -2,13 +2,17 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack'; import { JoinCallButton, Lobby, + useCallStateHooks, useI18n, + useTheme, } from '@stream-io/video-react-native-sdk'; import React, { useCallback } from 'react'; import { Pressable, StyleSheet, View, Text } from 'react-native'; import { MeetingStackParamList } from '../../types'; import { appTheme } from '../theme'; import { useOrientation } from '../hooks/useOrientation'; +import { isCallEncrypted, isE2EEConfigured } from '../utils/e2ee'; +import { LockIcon } from './LockIcon'; type LobbyViewComponentType = NativeStackScreenProps< MeetingStackParamList, @@ -26,10 +30,28 @@ export const LobbyViewComponent = ({ }: LobbyViewComponentType) => { const { t } = useI18n(); const orientation = useOrientation(); + const { theme } = useTheme(); + const { useCallSettings } = useCallStateHooks(); + const settings = useCallSettings(); + // An `auto-on` call requires E2EE of every participant, so the backend rejects + // a join without it. Say so here rather than letting the join fail: the key is + // entered on the previous screen, and `useE2eeEnabled()` is still false at this + // point because the SFU has not been asked yet. + const needsEncryptionKey = isCallEncrypted(settings) && !isE2EEConfigured(); const JoinCallButtonComponent = useCallback(() => { return ( <> + {needsEncryptionKey && ( + + + + {t( + 'This call is end-to-end encrypted. Set a meeting key before joining.', + )} + + + )} {route.name !== 'MeetingScreen' && ( ); - }, [onJoinCallHandler, callId, navigation, route.name, t]); + }, [ + onJoinCallHandler, + callId, + navigation, + route.name, + t, + needsEncryptionKey, + theme, + ]); return ( @@ -65,6 +95,18 @@ const styles = StyleSheet.create({ anonymousButton: { marginTop: 8, }, + encryptionNotice: { + alignItems: 'center', + flexDirection: 'row', + gap: 8, + marginBottom: 12, + paddingHorizontal: 8, + }, + encryptionNoticeText: { + color: appTheme.colors.light_gray, + flex: 1, + fontSize: 13, + }, anonymousButtonText: { fontSize: 20, fontWeight: '500', diff --git a/sample-apps/react-native/dogfood/src/components/LockIcon.tsx b/sample-apps/react-native/dogfood/src/components/LockIcon.tsx new file mode 100644 index 0000000000..71d7c46a30 --- /dev/null +++ b/sample-apps/react-native/dogfood/src/components/LockIcon.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import Svg, { Path, Rect } from 'react-native-svg'; +import type { ColorValue } from 'react-native'; + +/** + * A small padlock glyph for the E2EE affordances (lobby notice + active-call + * badge). The SDK icon set has no plain lock, so this mirrors the web app's + * hand-rolled one rather than pulling in an asset. + */ +export const LockIcon = ({ + color, + size = 16, +}: { + color: ColorValue; + size?: number; +}) => ( + + + + +); diff --git a/sample-apps/react-native/dogfood/src/components/MeetingUI.tsx b/sample-apps/react-native/dogfood/src/components/MeetingUI.tsx index 2897473901..db9890e31e 100644 --- a/sample-apps/react-native/dogfood/src/components/MeetingUI.tsx +++ b/sample-apps/react-native/dogfood/src/components/MeetingUI.tsx @@ -13,6 +13,11 @@ import { useAppGlobalStoreSetState } from '../contexts/AppContext'; import { AuthenticationProgress } from './AuthenticatingProgress'; import { CallErrorComponent } from './CallErrorComponent'; import { LayoutProvider } from '../contexts/LayoutContext'; +import { + attachE2EEIfConfigured, + disposeE2EEManager, + getE2EESettingsOverride, +} from '../utils/e2ee'; type Props = NativeStackScreenProps< MeetingStackParamList, 'MeetingScreen' | 'GuestMeetingScreen' @@ -42,7 +47,9 @@ export const MeetingUI = ({ callId, navigation, route }: Props) => { } }; if (call?.state.callingState !== CallingState.LEFT) { - leaveCall(); + leaveCall().finally(() => disposeE2EEManager(call)); + } else { + disposeE2EEManager(call); } }; }, [call]); @@ -59,7 +66,17 @@ export const MeetingUI = ({ callId, navigation, route }: Props) => { if (!call) return; try { // call.updatePublishOptions({ preferredCodec: 'h264' }); - await call.join({ create: true }); + // Attach E2EE here rather than on mount: awaiting it immediately before + // the join leaves no window in which the join could win the race. + await attachE2EEIfConfigured(call); + // The override is repeated here because this join creates the call when + // the screen's getOrCreate has not landed yet, and a call created without + // it rejects the E2EE join the attached manager asks for. + const settings_override = getE2EESettingsOverride(); + await call.join({ + create: true, + ...(settings_override ? { data: { settings_override } } : {}), + }); appStoreSetState({ chatLabelNoted: false }); setShow('active-call'); } catch (error) { diff --git a/sample-apps/react-native/dogfood/src/contexts/AppContext.tsx b/sample-apps/react-native/dogfood/src/contexts/AppContext.tsx index 93d2a5a915..7dce97f02a 100644 --- a/sample-apps/react-native/dogfood/src/contexts/AppContext.tsx +++ b/sample-apps/react-native/dogfood/src/contexts/AppContext.tsx @@ -17,6 +17,8 @@ type AppGlobalStore = { localIpAddress: string; useLocalSfu?: boolean; devMode?: boolean; + /** Passphrase or hex keys for end-to-end encryption; empty means E2EE off. */ + e2eeKeyInput: string; }; export const { @@ -37,6 +39,7 @@ export const { useLocalSfu: false, localIpAddress: '127.0.0.1', devMode: false, + e2eeKeyInput: '', }, [ 'apiKey', @@ -48,5 +51,6 @@ export const { 'appMode', 'themeMode', 'devMode', + 'e2eeKeyInput', ], ); diff --git a/sample-apps/react-native/dogfood/src/hooks/useE2eeKeyStatus.ts b/sample-apps/react-native/dogfood/src/hooks/useE2eeKeyStatus.ts new file mode 100644 index 0000000000..28041ec83a --- /dev/null +++ b/sample-apps/react-native/dogfood/src/hooks/useE2eeKeyStatus.ts @@ -0,0 +1,108 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + EncryptionManager, + useCall, + useCallStateHooks, +} from '@stream-io/video-react-native-sdk'; + +/** + * What the local peer can conclude about key agreement from its own decryption + * failures. + * + * - `ok`: nothing is failing (or there is nothing to judge from yet). + * - `local-key-mismatch`: every publishing peer fails to decrypt. With a shared + * key that means *this* peer holds the wrong one - a peer with the right key + * would still decrypt the majority. + * - `peer-key-mismatch`: only some peers fail, so their keys differ from ours. + */ +export type E2EEKeyStatus = + | { kind: 'ok' } + | { kind: 'local-key-mismatch' } + | { kind: 'peer-key-mismatch'; names: string[] }; + +/** + * Detect a shared-key mismatch from the decryption signals. + * + * There is no direct "your key is wrong" event, and there cannot be: a wrong key + * still encrypts happily, so the local encoder never complains and nothing tells + * us that others cannot decrypt *us*. The only evidence is inbound, and it is + * per remote peer - so the verdict comes from the breadth of the failures rather + * than from any single event. + * + * `e2ee.decryption_stalled` is the trigger, not `e2ee.decryption_failed`: the + * latter fires once a second for any transient mismatch, including the brief + * window while a key change propagates, and would cry wolf. Stalled means the + * track has failed past the SDK's tolerance and is not recovering on its own. + * + * Blind spots worth knowing: alone in the call, or with every peer muted and + * camera-off, a wrong key is undetectable. And if two peers share the same wrong + * key they decrypt each other, so neither sees a full sweep of failures. + */ +export const useE2eeKeyStatus = (): E2EEKeyStatus => { + const call = useCall(); + const { useRemoteParticipants } = useCallStateHooks(); + const remoteParticipants = useRemoteParticipants(); + // Keyed per (userId, trackType) because failures are counted per track: a peer + // publishing audio and video reports them independently, and their video can + // recover while audio is still stalled. + const [stalledTracks, setStalledTracks] = useState>( + () => new Set(), + ); + + useEffect(() => { + // Only the built-in manager emits these events; a custom E2EEManager + // implementation satisfies the RTC contract without them. + const manager = call?.e2eeManager; + if (!(manager instanceof EncryptionManager)) return; + + const trackKey = (userId: string, trackType?: string) => + `${userId}/${trackType ?? 'unknown'}`; + + const unsubscribes = [ + manager.on('e2ee.decryption_stalled', ({ userId, trackType }) => { + setStalledTracks((prev) => { + const next = new Set(prev); + next.add(trackKey(userId, trackType)); + return next; + }); + }), + manager.on('e2ee.decryption_resumed', ({ userId, trackType }) => { + setStalledTracks((prev) => { + const key = trackKey(userId, trackType); + if (!prev.has(key)) return prev; + const next = new Set(prev); + next.delete(key); + return next; + }); + }), + ]; + + return () => unsubscribes.forEach((unsubscribe) => unsubscribe()); + }, [call]); + + return useMemo(() => { + if (stalledTracks.size === 0) return { kind: 'ok' }; + const stalledUserIds = new Set( + [...stalledTracks].map((key) => key.slice(0, key.lastIndexOf('/'))), + ); + // Judge only against peers that are actually sending something: a muted, + // camera-off peer produces no frames and so no evidence either way. Peers + // who have left keep stale entries in the set, which is harmless - they are + // simply not part of this comparison. + const publishing = remoteParticipants.filter( + (participant) => participant.publishedTracks.length > 0, + ); + const failing = publishing.filter((participant) => + stalledUserIds.has(participant.userId), + ); + if (failing.length === 0) return { kind: 'ok' }; + if (failing.length === publishing.length) + return { kind: 'local-key-mismatch' }; + return { + kind: 'peer-key-mismatch', + names: failing.map( + (participant) => participant.name || participant.userId, + ), + }; + }, [stalledTracks, remoteParticipants]); +}; diff --git a/sample-apps/react-native/dogfood/src/navigators/Call.tsx b/sample-apps/react-native/dogfood/src/navigators/Call.tsx index dca4679de5..eb53ec895a 100644 --- a/sample-apps/react-native/dogfood/src/navigators/Call.tsx +++ b/sample-apps/react-native/dogfood/src/navigators/Call.tsx @@ -15,6 +15,7 @@ import { NavigationHeader } from '../components/NavigationHeader'; import { useOrientation } from '../hooks/useOrientation'; import { ActiveCall } from '../components/ActiveCall'; import { LayoutProvider } from '../contexts/LayoutContext'; +import { attachE2EEIfConfigured, disposeE2EEManager } from '../utils/e2ee'; const CallStack = createNativeStackNavigator(); @@ -42,6 +43,7 @@ const Calls = () => { return ( + { ); }; +/** + * Attaches the E2EE manager while the call is still ringing. + * + * The accept button joins inside the SDK and `setE2EEManager` throws once the call + * has peer connections, so the attach has to happen before the tap. It cannot be + * moved into the accept handler either: `AcceptCallButton`'s pre-join `onPressHandler` + * is not reachable through `RingingCallContent`'s props, only the post-join + * `onAcceptCallHandler` is. + * + * Mounting here runs the attach on the render that first surfaces the ringing call. + * Derivation is `pbkdf2Sync` and the native manager is created synchronously, so it + * settles a microtask later - long before a finger can land on Accept. + * + * Calls accepted from the CallKit/Telecom UI never reach this component; they are + * covered by the push config's `onBeforeCallJoin` hook instead. + */ +const AttachE2EEWhileRinging = ({ call }: { call: StreamCallType }) => { + useEffect(() => { + attachE2EEIfConfigured(call).catch((error) => { + console.log('Failed to attach E2EE to ringing call:', error); + }); + }, [call]); + return null; +}; + const CallLeaveOnUnmount = ({ call }: { call: StreamCallType }) => { useEffect(() => { return () => { if (call && call.state.callingState !== CallingState.LEFT) { call.leave(); } + // No native detach exists, so a manager outlives its call unless released + // here. Rejected and timed-out calls reach this too, which is the point: + // they were attached while ringing but never joined. + disposeE2EEManager(call); }; }, [call]); return null; diff --git a/sample-apps/react-native/dogfood/src/screens/Call/JoinCallScreen.tsx b/sample-apps/react-native/dogfood/src/screens/Call/JoinCallScreen.tsx index a29e7bc47b..34a097c450 100644 --- a/sample-apps/react-native/dogfood/src/screens/Call/JoinCallScreen.tsx +++ b/sample-apps/react-native/dogfood/src/screens/Call/JoinCallScreen.tsx @@ -25,6 +25,10 @@ import { TextInput } from '../../components/TextInput'; import { KnownUsers } from '../../constants/KnownUsers'; import { randomId } from '../../modules/helpers/randomId'; import { useOrientation } from '../../hooks/useOrientation'; +import { + attachE2EEIfConfigured, + getE2EESettingsOverride, +} from '../../utils/e2ee'; const JoinCallScreen = () => { const [ringingUserIdsText, setRingingUserIdsText] = useState(''); @@ -58,6 +62,10 @@ const JoinCallScreen = () => { auto_cancel_timeout_ms: 30000, incoming_call_timeout_ms: 30000, }, + // Merged rather than assigned: the ring timeouts above are what make the + // callee's quit-state case work, and encryption mode is frozen at + // creation, so it has to be requested here or not at all. + ...getE2EESettingsOverride(), }, members: ringingUserIds.map((ringingUserId) => { return { @@ -66,6 +74,11 @@ const JoinCallScreen = () => { }), }, }); + // The caller's own join is triggered inside the client when the callee accepts, + // so attach now: this is the whole window, and it stays open until then. + if (call) { + await attachE2EEIfConfigured(call); + } } catch (error) { if (error instanceof Error) { Alert.alert('Error calling users', error.message); diff --git a/sample-apps/react-native/dogfood/src/screens/Meeting/GuestMeetingScreen.tsx b/sample-apps/react-native/dogfood/src/screens/Meeting/GuestMeetingScreen.tsx index 2f34390829..0481156590 100644 --- a/sample-apps/react-native/dogfood/src/screens/Meeting/GuestMeetingScreen.tsx +++ b/sample-apps/react-native/dogfood/src/screens/Meeting/GuestMeetingScreen.tsx @@ -11,6 +11,7 @@ import { MeetingUI } from '../../components/MeetingUI'; import { createToken } from '../../modules/helpers/createToken'; import { useAppGlobalStoreValue } from '../../contexts/AppContext'; import { useCustomTheme } from '../../theme'; +import { getE2EESettingsOverride } from '../../utils/e2ee'; type Props = NativeStackScreenProps< MeetingStackParamList, @@ -77,9 +78,14 @@ export const GuestMeetingScreen = (props: Props) => { }, [callId, callType, videoClient]); useEffect(() => { - call?.getOrCreate().catch((err) => { - console.error('Failed to get or create call', err); - }); + const settings_override = getE2EESettingsOverride(); + call + ?.getOrCreate( + settings_override ? { data: { settings_override } } : undefined, + ) + .catch((err) => { + console.error('Failed to get or create call', err); + }); }, [call]); if (!videoClient || !call) { diff --git a/sample-apps/react-native/dogfood/src/screens/Meeting/JoinMeetingScreen.tsx b/sample-apps/react-native/dogfood/src/screens/Meeting/JoinMeetingScreen.tsx index fe2e0dc8f0..7fc26e5ffa 100644 --- a/sample-apps/react-native/dogfood/src/screens/Meeting/JoinMeetingScreen.tsx +++ b/sample-apps/react-native/dogfood/src/screens/Meeting/JoinMeetingScreen.tsx @@ -18,6 +18,7 @@ import { MeetingStackParamList } from '../../../types'; import { appTheme } from '../../theme'; import { TextInput } from '../../components/TextInput'; import { Button } from '../../components/Button'; +import { E2EEKeyInput } from '../../components/E2EEKeyInput'; import { deeplinkCallId$ } from '../../hooks/useDeepLinkEffect'; import { useI18n, useTheme } from '@stream-io/video-react-native-sdk'; import { useOrientation } from '../../hooks/useOrientation'; @@ -123,6 +124,7 @@ const JoinMeetingScreen = (props: JoinMeetingScreenProps) => { title={t('Start a New Call')} buttonStyle={styles.startNewCallButton} /> + ); diff --git a/sample-apps/react-native/dogfood/src/screens/Meeting/MeetingScreen.tsx b/sample-apps/react-native/dogfood/src/screens/Meeting/MeetingScreen.tsx index 35aceaec0c..b28dbcbbb9 100644 --- a/sample-apps/react-native/dogfood/src/screens/Meeting/MeetingScreen.tsx +++ b/sample-apps/react-native/dogfood/src/screens/Meeting/MeetingScreen.tsx @@ -7,6 +7,7 @@ import { } from '@stream-io/video-react-native-sdk'; import { MeetingStackParamList } from '../../../types'; import { MeetingUI } from '../../components/MeetingUI'; +import { getE2EESettingsOverride } from '../../utils/e2ee'; type Props = NativeStackScreenProps; @@ -29,7 +30,12 @@ export const MeetingScreen = (props: Props) => { useEffect(() => { const getOrCreateCall = async () => { try { - await call?.getOrCreate(); + // A call's encryption setting is fixed at creation, and the backend + // rejects an E2EE join against a call that was not created for it. + const settings_override = getE2EESettingsOverride(); + await call?.getOrCreate( + settings_override ? { data: { settings_override } } : undefined, + ); } catch (error) { console.error('Failed to get or create call', error); } diff --git a/sample-apps/react-native/dogfood/src/utils/e2ee.ts b/sample-apps/react-native/dogfood/src/utils/e2ee.ts new file mode 100644 index 0000000000..834beb4e07 --- /dev/null +++ b/sample-apps/react-native/dogfood/src/utils/e2ee.ts @@ -0,0 +1,252 @@ +import { pbkdf2Sync } from 'react-native-quick-crypto'; +import { + Call, + EncryptionManager, + EncryptionSettingsRequestModeEnum, + EncryptionSettingsResponseModeEnum, + type CallSettingsResponse, + type EncryptionSettingsRequest, +} from '@stream-io/video-react-native-sdk'; +import { mmkvStorage } from '../contexts/createStoreContext'; + +/** + * Settings override that creates a call as end-to-end encrypted, to match the + * `e2ee: true` flag the SDK sends on join whenever a manager is attached. + * Without it the backend rejects the join. + * + * `auto-on` (E2EE required) rather than `available` (E2EE merely permitted): + * under `available` some participants could publish unencrypted, so a lock + * indicator would be claiming more than the call guarantees. + */ +const ENCRYPTION_OVERRIDE: EncryptionSettingsRequest = { + mode: EncryptionSettingsRequestModeEnum.AUTO_ON, +}; + +/** + * Whether the call these settings describe is end-to-end encrypted. + * + * Prefer the `useE2eeEnabled()` hook, which reads the SFU's join response and so + * reports whether E2EE is actually in effect. This settings-based check exists + * for the lobby, which runs before the call is joined - at that point the SFU has + * said nothing and the hook is still `false`, so the requested mode from the + * coordinator is the only thing to go on. + */ +export const isCallEncrypted = ( + settings: CallSettingsResponse | undefined, +): boolean => + settings?.encryption?.mode === EncryptionSettingsResponseModeEnum.AUTO_ON; + +/** The MMKV key the debug UI writes the passphrase to. */ +const E2EE_KEY_INPUT_STORE_KEY = 'e2eeKeyInput'; + +/** + * The single key index this app uses for its shared key. + * + * Everyone derives the key from the same passphrase, so everyone has to agree on + * the index too: a frame carries the index it was encrypted with, and a receiver + * that looked elsewhere would fail every decrypt. Re-keying reuses this index + * rather than bumping it - a bump would only be visible to peers told about it. + */ +const SHARED_KEY_INDEX = 0; + +/** + * Derivation parameters shared with the web and iOS demo apps. The salt and + * iteration count are part of the contract between participants: change either + * and peers on another build derive a different key from the same passphrase and + * nothing decrypts. + * + * Known answers, so a change here can be checked in seconds. `fingerprint` is + * what `requestKeyState()` reports, i.e. the first 8 bytes of SHA-256 over the + * installed key - so matching it proves the whole path, derivation through the + * native key store, not just this function: + * + * ``` + * passphrase key fingerprint + * secret 0471688454b3f5f7b815c11a525e8fac b4b4a78d820b0893 + * noun-rover-waitress d30b96af272a8791e237e83571639cf2 e78898acaa1c62cb + * ``` + */ +const PBKDF2_SALT = 'stream-e2ee'; +const PBKDF2_ITERATIONS = 100_000; +const AES_128_KEY_BYTES = 16; + +/** Read the raw text the user typed, straight from storage. */ +const getE2EEKeyInput = (): string | undefined => { + const stored = mmkvStorage.getString(E2EE_KEY_INPUT_STORE_KEY); + if (!stored) return undefined; + try { + // the store persists every value JSON-encoded + const parsed = JSON.parse(stored); + return typeof parsed === 'string' && parsed.trim() ? parsed : undefined; + } catch { + return undefined; + } +}; + +/** PBKDF2-HMAC-SHA256, matching the react-dogfood byte for byte. */ +const deriveKeyFromPassphrase = (passphrase: string): ArrayBuffer => + // slice() so the key sits in its own exact-length buffer rather than a view + // onto whatever the Buffer implementation allocated + new Uint8Array( + pbkdf2Sync( + passphrase, + PBKDF2_SALT, + PBKDF2_ITERATIONS, + AES_128_KEY_BYTES, + 'sha256', + ), + ).slice().buffer; + +/** + * Whether calls should be created and joined end-to-end encrypted. + * + * Device support is part of the answer, not a separate check: a call created + * with `auto-on` requires E2EE, so creating one on a device that cannot encrypt + * would produce a call this client is then rejected from joining. + */ +export const isE2EEConfigured = (): boolean => { + if (!getE2EEKeyInput()) return false; + if (!EncryptionManager.isSupported()) { + console.warn('E2EE key is set but E2EE is not supported on this device'); + return false; + } + return true; +}; + +/** + * The `settings_override` to create a call with, or `undefined` when E2EE is off. + * + * Spread into the create (or `join({ create: true })`) data. Encryption is frozen + * when the call is created, so this has to be set there rather than at join time. + * + * It answers to the same predicate as {@link attachE2EEIfConfigured} on purpose: + * creating an `auto-on` call this client then cannot encrypt for would produce a + * call it is rejected from joining. + */ +export const getE2EESettingsOverride = () => + isE2EEConfigured() ? { encryption: ENCRYPTION_OVERRIDE } : undefined; + +/** Events worth seeing in the log while debugging an interop failure. */ +const LOGGED_E2EE_EVENTS = [ + 'e2ee.missing_key', + 'e2ee.encryption_failed', + 'e2ee.unencrypted_frame', + 'e2ee.unsupported_version', + 'e2ee.decryption_failed', + 'e2ee.decryption_stalled', + 'e2ee.decryption_resumed', +] as const; + +/** + * Log the key fingerprints and every E2EE event, for cross-platform debugging. + * + * An interop failure is otherwise close to invisible from the publishing side: a + * wrong key still encrypts happily, so nothing local complains and the only + * evidence lives on the peers who cannot decrypt us. The fingerprint is the first + * thing to compare - the same passphrase must produce the same 16-hex + * fingerprint on every SDK - and it is safe to log, being a truncated SHA-256 of + * the key rather than the key. + */ +const logE2EEDiagnostics = (manager: EncryptionManager) => { + manager.on('e2ee.key_state', ({ sharedKeys, perUserKeys }) => { + console.log('[e2ee] key_state', { + shared: sharedKeys.map( + (key) => + `#${key.keyIndex} ${key.fingerprint}${key.isActive ? ' (active)' : ''}`, + ), + perUser: perUserKeys.map( + (key) => `${key.userId} #${key.keyIndex} ${key.fingerprint}`, + ), + }); + }); + LOGGED_E2EE_EVENTS.forEach((event) => { + manager.on(event, (payload: unknown) => { + console.log(`[e2ee] ${event}`, payload); + }); + }); + manager.requestKeyState(); +}; + +/** + * Replace the shared key on a call that is already joined. + * + * Re-using {@link SHARED_KEY_INDEX} replaces the key in place, so correcting a + * mistyped passphrase does not cost a rejoin: the native side clears its failure + * count for an index on the first frame that decrypts and reports + * `e2ee.decryption_resumed`. + */ +export const updateE2EESharedKeys = (call: Call, input: string): void => { + const manager = call.e2eeManager; + if (!(manager instanceof EncryptionManager)) return; + try { + manager.setSharedKey(SHARED_KEY_INDEX, deriveKeyFromPassphrase(input)); + manager.requestKeyState(); + } catch (error) { + console.error('Failed to apply the new E2EE key', error); + } +}; + +/** + * Release the encryption manager attached to a call, if it is one of ours. + * + * There is no native detach and closing the peer connections does not free the + * native manager, so unlike on web this cannot be left to garbage collection. + * `setE2EEManager` accepts any `E2EEManager`, hence the instance check. + */ +export const disposeE2EEManager = (call: Call | undefined) => { + const manager = call?.e2eeManager; + if (manager instanceof EncryptionManager) manager.dispose(); +}; + +/** + * Attach an encryption manager to a call, if the debug menu holds a key. + * + * Call it **awaited, immediately before `call.join()`**, the way the web app + * does: the join request carries the E2EE flag and the peer connections are + * built with the transforms in place, so `setE2EEManager` throws afterwards. + * Attaching from the join handler also means the client has long since connected + * a user, which is what `call.currentUserId` needs. + * + * A no-op when no key is configured, so it is safe to call unconditionally. + * + * Key derivation lives here rather than in the SDK by design: generating, + * deriving and distributing keys is the integrator's responsibility. + */ +export const attachE2EEIfConfigured = async (call: Call): Promise => { + if (!isE2EEConfigured()) { + // Say so out loud: silence here is ambiguous between "no key set" and "this + // build has no E2EE code at all", which is a stale bundle rather than a bug. + console.log('[e2ee] no usable key configured, joining unencrypted'); + return; + } + const input = getE2EEKeyInput()!; + + const userId = call.currentUserId; + if (!userId) { + // The manager labels the frames it encrypts with the local user, so there is + // nothing sane to attach before the client has connected one. + console.warn('Cannot enable E2EE before the user is connected'); + return; + } + + // Re-joining from the lobby attaches a second manager, and nothing else + // releases the native side of the first one. + disposeE2EEManager(call); + + let manager: EncryptionManager | undefined; + try { + manager = await EncryptionManager.create(userId); + manager.setSharedKey(SHARED_KEY_INDEX, deriveKeyFromPassphrase(input)); + console.log(`[e2ee] attaching manager for ${userId}`); + if (__DEV__) { + // The remaining interop tests - screen share, simulcast, reconnect - signal + // pass or fail through `decryption_stalled` / `decryption_resumed`, and a + // wrong key is otherwise invisible from the publishing side. + logE2EEDiagnostics(manager); + } + call.setE2EEManager(manager); + } catch (error) { + manager?.dispose(); + console.error('Failed to enable E2EE for the call', error); + } +}; diff --git a/sample-apps/react-native/dogfood/src/utils/setPushConfig.ts b/sample-apps/react-native/dogfood/src/utils/setPushConfig.ts index 4d91558df4..857fe00252 100644 --- a/sample-apps/react-native/dogfood/src/utils/setPushConfig.ts +++ b/sample-apps/react-native/dogfood/src/utils/setPushConfig.ts @@ -7,6 +7,7 @@ import { mmkvStorage } from '../contexts/createStoreContext'; import { createToken } from '../modules/helpers/createToken'; import { setNotificationListeners } from './setNotificationListeners'; import { registerNonRingingNotificationHandler } from './registerNonRingingNotifications'; +import { attachE2EEIfConfigured, disposeE2EEManager } from './e2ee'; export function setPushConfig() { StreamVideoRN.updateConfig({ @@ -34,6 +35,11 @@ export function setPushConfig() { }, shouldRejectCallWhenBusy: false, createStreamVideoClient, + // A call accepted from CallKit/Telecom is created and joined inside the SDK, so + // these two hooks are the only place app code can attach and release an E2EE + // manager on that path - the app may never even reach React, if it was killed. + onBeforeCallJoin: attachE2EEIfConfigured, + onAfterCallLeave: disposeE2EEManager, }); setNotificationListeners(); diff --git a/yarn.lock b/yarn.lock index 93f82eda82..7e34379bc5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1669,6 +1669,16 @@ __metadata: languageName: node linkType: hard +"@craftzdog/react-native-buffer@npm:^6.1.2": + version: 6.1.2 + resolution: "@craftzdog/react-native-buffer@npm:6.1.2" + dependencies: + ieee754: "npm:^1.2.1" + react-native-quick-base64: "npm:^3.0.0" + checksum: 10/c424beec79d6438ea305c07177f39ef737dc0e6fded31dd81f47b7b26f7a34acacd40d7cc1a0f3601c840cd695c8812fad2a73ae437e7641d1f6f367c68a7112 + languageName: node + linkType: hard + "@egjs/hammerjs@npm:^2.0.17": version: 2.0.17 resolution: "@egjs/hammerjs@npm:2.0.17" @@ -6942,6 +6952,30 @@ __metadata: languageName: unknown linkType: soft +"@stream-io/react-native-webrtc@file:/Users/santhoshvaiyapuri/Documents/.stream-webrtc-local/stream-io-react-native-webrtc-0.0.0-local.1787231534.tgz::locator=%40stream-io%2Fvideo-react-native-dogfood%40workspace%3Asample-apps%2Freact-native%2Fdogfood": + version: 0.0.0-local.1787231534 + resolution: "@stream-io/react-native-webrtc@file:/Users/santhoshvaiyapuri/Documents/.stream-webrtc-local/stream-io-react-native-webrtc-0.0.0-local.1787231534.tgz#/Users/santhoshvaiyapuri/Documents/.stream-webrtc-local/stream-io-react-native-webrtc-0.0.0-local.1787231534.tgz::hash=6261aa&locator=%40stream-io%2Fvideo-react-native-dogfood%40workspace%3Asample-apps%2Freact-native%2Fdogfood" + dependencies: + base64-js: "npm:^1.5.1" + debug: "npm:^4.4.3" + peerDependencies: + react-native: ">=0.73.0" + checksum: 10/cb8fcf4fc08875a040464ba302e0b0b4429eb7674fd20a620bc12ff51bc74abbcb9ad9816f7326a9608f6e244bc6d0b9067155df5c8d630679bc13744d3f6838 + languageName: node + linkType: hard + +"@stream-io/react-native-webrtc@file:/Users/santhoshvaiyapuri/Documents/.stream-webrtc-local/stream-io-react-native-webrtc-0.0.0-local.1787231534.tgz::locator=%40stream-io%2Fvideo-react-native-sdk%40workspace%3Apackages%2Freact-native-sdk": + version: 0.0.0-local.1787231534 + resolution: "@stream-io/react-native-webrtc@file:/Users/santhoshvaiyapuri/Documents/.stream-webrtc-local/stream-io-react-native-webrtc-0.0.0-local.1787231534.tgz#/Users/santhoshvaiyapuri/Documents/.stream-webrtc-local/stream-io-react-native-webrtc-0.0.0-local.1787231534.tgz::hash=6261aa&locator=%40stream-io%2Fvideo-react-native-sdk%40workspace%3Apackages%2Freact-native-sdk" + dependencies: + base64-js: "npm:^1.5.1" + debug: "npm:^4.4.3" + peerDependencies: + react-native: ">=0.73.0" + checksum: 10/cb8fcf4fc08875a040464ba302e0b0b4429eb7674fd20a620bc12ff51bc74abbcb9ad9816f7326a9608f6e244bc6d0b9067155df5c8d630679bc13744d3f6838 + languageName: node + linkType: hard + "@stream-io/react-native-webrtc@npm:145.3.1": version: 145.3.1 resolution: "@stream-io/react-native-webrtc@npm:145.3.1" @@ -7218,7 +7252,7 @@ __metadata: "@react-navigation/native-stack": "npm:^7.18.6" "@stream-io/noise-cancellation-react-native": "workspace:^" "@stream-io/react-native-callingx": "workspace:^" - "@stream-io/react-native-webrtc": "npm:145.3.1" + "@stream-io/react-native-webrtc": "file:/Users/santhoshvaiyapuri/Documents/.stream-webrtc-local/stream-io-react-native-webrtc-0.0.0-local.1787231534.tgz" "@stream-io/video-filters-react-native": "workspace:^" "@stream-io/video-react-native-sdk": "workspace:^" "@types/react": "npm:^19.2.18" @@ -7235,6 +7269,8 @@ __metadata: react-native-mmkv: "npm:^4.3.2" react-native-nitro-modules: "npm:^0.36.5" react-native-permissions: "npm:^5.6.1" + react-native-quick-base64: "npm:^3.0.1" + react-native-quick-crypto: "npm:^1.1.7" react-native-reanimated: "npm:4.5.3" react-native-safe-area-context: "npm:~5.8.0" react-native-screens: "npm:4.26.2" @@ -7310,7 +7346,7 @@ __metadata: "@react-native/metro-config": "npm:0.86.2" "@stream-io/noise-cancellation-react-native": "workspace:^" "@stream-io/react-native-callingx": "workspace:^" - "@stream-io/react-native-webrtc": "npm:145.3.1" + "@stream-io/react-native-webrtc": "file:/Users/santhoshvaiyapuri/Documents/.stream-webrtc-local/stream-io-react-native-webrtc-0.0.0-local.1787231534.tgz" "@stream-io/typescript-config": "workspace:^" "@stream-io/video-client": "workspace:*" "@stream-io/video-filters-react-native": "workspace:^" @@ -9384,6 +9420,16 @@ __metadata: languageName: node linkType: hard +"buffer@npm:^6.0.3": + version: 6.0.3 + resolution: "buffer@npm:6.0.3" + dependencies: + base64-js: "npm:^1.3.1" + ieee754: "npm:^1.2.1" + checksum: 10/b6bc68237ebf29bdacae48ce60e5e28fc53ae886301f2ad9496618efac49427ed79096750033e7eab1897a4f26ae374ace49106a5758f38fb70c78c9fda2c3b1 + languageName: node + linkType: hard + "bytes@npm:3.1.2, bytes@npm:^3.1.2, bytes@npm:~3.1.2": version: 3.1.2 resolution: "bytes@npm:3.1.2" @@ -11562,6 +11608,13 @@ __metadata: languageName: node linkType: hard +"events@npm:3.3.0, events@npm:^3.3.0": + version: 3.3.0 + resolution: "events@npm:3.3.0" + checksum: 10/a3d47e285e28d324d7180f1e493961a2bbb4cad6412090e4dec114f4db1f5b560c7696ee8e758f55e23913ede856e3689cd3aa9ae13c56b5d8314cd3b3ddd1be + languageName: node + linkType: hard + "execa@npm:^5.0.0": version: 5.1.1 resolution: "execa@npm:5.1.1" @@ -13480,6 +13533,16 @@ __metadata: languageName: node linkType: hard +"is-arguments@npm:^1.0.4": + version: 1.2.0 + resolution: "is-arguments@npm:1.2.0" + dependencies: + call-bound: "npm:^1.0.2" + has-tostringtag: "npm:^1.0.2" + checksum: 10/471a8ef631b8ee8829c43a8ab05c081700c0e25180c73d19f3bf819c1a8448c426a9e8e601f278973eca68966384b16ceb78b8c63af795b099cd199ea5afc457 + languageName: node + linkType: hard + "is-array-buffer@npm:^3.0.4, is-array-buffer@npm:^3.0.5": version: 3.0.5 resolution: "is-array-buffer@npm:3.0.5" @@ -13654,7 +13717,7 @@ __metadata: languageName: node linkType: hard -"is-generator-function@npm:^1.0.10": +"is-generator-function@npm:^1.0.10, is-generator-function@npm:^1.0.7": version: 1.1.2 resolution: "is-generator-function@npm:1.1.2" dependencies: @@ -13839,7 +13902,7 @@ __metadata: languageName: node linkType: hard -"is-typed-array@npm:^1.1.13, is-typed-array@npm:^1.1.14, is-typed-array@npm:^1.1.15": +"is-typed-array@npm:^1.1.13, is-typed-array@npm:^1.1.14, is-typed-array@npm:^1.1.15, is-typed-array@npm:^1.1.3": version: 1.1.15 resolution: "is-typed-array@npm:1.1.15" dependencies: @@ -17875,7 +17938,7 @@ __metadata: languageName: node linkType: hard -"process@npm:^0.11.1": +"process@npm:^0.11.1, process@npm:^0.11.10": version: 0.11.10 resolution: "process@npm:0.11.10" checksum: 10/dbaa7e8d1d5cf375c36963ff43116772a989ef2bb47c9bdee20f38fd8fc061119cf38140631cf90c781aca4d3f0f0d2c834711952b728953f04fd7d238f59f5b @@ -18390,6 +18453,39 @@ __metadata: languageName: node linkType: hard +"react-native-quick-base64@npm:^3.0.0, react-native-quick-base64@npm:^3.0.1": + version: 3.0.1 + resolution: "react-native-quick-base64@npm:3.0.1" + peerDependencies: + react: "*" + react-native: "*" + checksum: 10/9810adfd36f5a716f94049f6363e4d5a13614f0f1b9c26577aac1aefb8bd2076bd73f3495201807c2bd767dbae1d0bc878abafe084a11a9271b7f2fba47c1737 + languageName: node + linkType: hard + +"react-native-quick-crypto@npm:^1.1.7": + version: 1.1.7 + resolution: "react-native-quick-crypto@npm:1.1.7" + dependencies: + "@craftzdog/react-native-buffer": "npm:^6.1.2" + events: "npm:3.3.0" + readable-stream: "npm:4.7.0" + safe-buffer: "npm:^5.2.1" + string_decoder: "npm:^1.3.0" + util: "npm:0.12.5" + peerDependencies: + expo: ">=48.0.0" + react: "*" + react-native: "*" + react-native-nitro-modules: ">=0.31.2" + react-native-quick-base64: ">=3.0.0" + peerDependenciesMeta: + expo: + optional: true + checksum: 10/310c659e6964a2ff132751b265a527dafc90ea4113bff6a68e508d87ec763b89c778a79819cb8ed4853bb3b15d02530b6963a72a3e734af5882d9d5b46d3bf4c + languageName: node + linkType: hard + "react-native-reanimated@npm:4.5.3": version: 4.5.3 resolution: "react-native-reanimated@npm:4.5.3" @@ -18810,6 +18906,19 @@ __metadata: languageName: node linkType: hard +"readable-stream@npm:4.7.0": + version: 4.7.0 + resolution: "readable-stream@npm:4.7.0" + dependencies: + abort-controller: "npm:^3.0.0" + buffer: "npm:^6.0.3" + events: "npm:^3.3.0" + process: "npm:^0.11.10" + string_decoder: "npm:^1.3.0" + checksum: 10/bdf096c8ff59452ce5d08f13da9597f9fcfe400b4facfaa88e74ec057e5ad1fdfa140ffe28e5ed806cf4d2055f0b812806e962bca91dce31bc4cef08e53be3a4 + languageName: node + linkType: hard + "readdirp@npm:^5.0.0": version: 5.0.0 resolution: "readdirp@npm:5.0.0" @@ -19411,7 +19520,7 @@ __metadata: languageName: node linkType: hard -"safe-buffer@npm:5.2.1, safe-buffer@npm:>=5.1.0, safe-buffer@npm:^5.0.1, safe-buffer@npm:~5.2.0": +"safe-buffer@npm:5.2.1, safe-buffer@npm:>=5.1.0, safe-buffer@npm:^5.0.1, safe-buffer@npm:^5.2.1, safe-buffer@npm:~5.2.0": version: 5.2.1 resolution: "safe-buffer@npm:5.2.1" checksum: 10/32872cd0ff68a3ddade7a7617b8f4c2ae8764d8b7d884c651b74457967a9e0e886267d3ecc781220629c44a865167b61c375d2da6c720c840ecd73f45d5d9451 @@ -20472,7 +20581,7 @@ __metadata: languageName: node linkType: hard -"string_decoder@npm:1.3.0, string_decoder@npm:^1.1.1": +"string_decoder@npm:1.3.0, string_decoder@npm:^1.1.1, string_decoder@npm:^1.3.0": version: 1.3.0 resolution: "string_decoder@npm:1.3.0" dependencies: @@ -21532,6 +21641,19 @@ __metadata: languageName: node linkType: hard +"util@npm:0.12.5": + version: 0.12.5 + resolution: "util@npm:0.12.5" + dependencies: + inherits: "npm:^2.0.3" + is-arguments: "npm:^1.0.4" + is-generator-function: "npm:^1.0.7" + is-typed-array: "npm:^1.1.3" + which-typed-array: "npm:^1.1.2" + checksum: 10/61a10de7753353dd4d744c917f74cdd7d21b8b46379c1e48e1c4fd8e83f8190e6bd9978fc4e5102ab6a10ebda6019d1b36572fa4a325e175ec8b789a121f6147 + languageName: node + linkType: hard + "util@npm:^0.10.3": version: 0.10.4 resolution: "util@npm:0.10.4" @@ -21963,7 +22085,7 @@ __metadata: languageName: node linkType: hard -"which-typed-array@npm:^1.1.16, which-typed-array@npm:^1.1.19": +"which-typed-array@npm:^1.1.16, which-typed-array@npm:^1.1.19, which-typed-array@npm:^1.1.2": version: 1.1.22 resolution: "which-typed-array@npm:1.1.22" dependencies: From 65a1fb766461f95a3b70fe221e9ace0caad6c64b Mon Sep 17 00:00:00 2001 From: Santhosh Vaiyapuri <3846977+santhoshvai@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:25:31 +0200 Subject: [PATCH 02/18] fix: workaround for android non-telecom path MODE_IN_COMMUNICATION reset edge-case (#2360) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### 💡 Overview Fixes Android audio routing breaking mid-call on Android 11+. The OS resets `AudioManager.MODE_IN_COMMUNICATION` ~6s after it's set when the app has no active voice-comm playback or recording — dropping the call to the default media route and disabling AEC (https://issuetracker.google.com/issues/209493718). This PR adds a workaround that plays a silent, looping voice-communication `AudioTrack` for the duration of a communicator-role call, so the OS always sees active playback and holds the mode. Android-only; no-op below API 30 and for the `listener` role / Telecom-managed calls. ### 📝 Implementation notes - `CommunicationModeKeepAlive.kt`: plays a silent-`AudioTrack` to keep-alive; falls back to a periodic `MODE_IN_COMMUNICATION` re-assert watchdog if the track can't be built. 🎫 Ticket: https://linear.app/stream/issue/XYZ-123 📑 Docs: https://github.com/GetStream/docs-content/pull/1569 ## Summary by CodeRabbit * **New Features** * Added an option to disable the Android communication-mode audio workaround. * Added a getter to check the currently configured workaround preference. * Preference changes are retained and applied when joining the next call. * **Bug Fixes** * Improved Android audio routing stability during call start, stop, and teardown. * Added safeguards to prevent late audio operations after shutdown. * Improved handling when audio playback setup fails or the current activity is unavailable. * The setting has no effect on iOS or Telecom-managed calls. --- .../__tests__/StreamVideoRN.test.ts | 110 ++++++++ .../call-manager/CallManager.test.ts | 4 + .../reactnative/audio/AudioDeviceManager.kt | 168 ++++++++---- .../audio/CommunicationModeKeepAlive.kt | 247 ++++++++++++++++++ .../callmanager/StreamInCallManagerModule.kt | 21 +- .../modules/call-manager/native-module.d.ts | 14 + .../src/utils/StreamVideoRN/index.ts | 27 ++ 7 files changed, 528 insertions(+), 63 deletions(-) create mode 100644 packages/react-native-sdk/__tests__/StreamVideoRN.test.ts create mode 100644 packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/audio/CommunicationModeKeepAlive.kt diff --git a/packages/react-native-sdk/__tests__/StreamVideoRN.test.ts b/packages/react-native-sdk/__tests__/StreamVideoRN.test.ts new file mode 100644 index 0000000000..d852e18527 --- /dev/null +++ b/packages/react-native-sdk/__tests__/StreamVideoRN.test.ts @@ -0,0 +1,110 @@ +/** + * Tests for the app-level (process-wide) opt-out of the Android communication-mode keep-alive. + * It is set once at app start and lands on the native module directly; the native field is + * sticky for the process, so there is no join-time re-application. + */ + +const makeInCallManager = () => ({ + setDisableCommunicationModeWorkaround: jest.fn(), +}); + +/** Load StreamVideoRN with the given platform + mocked native module. */ +const loadStreamVideoRN = ({ + os, + inCallManager, +}: { + os: 'android' | 'ios'; + inCallManager: ReturnType | undefined; +}) => { + let StreamVideoRN!: typeof import('../src/utils/StreamVideoRN').StreamVideoRN; + jest.isolateModules(() => { + jest.doMock('react-native', () => ({ + Platform: { OS: os, select: (o: any) => o[os] }, + NativeModules: { + StreamInCallManager: inCallManager, + StreamVideoReactNative: {}, + }, + NativeEventEmitter: class { + addListener() { + return { remove: jest.fn() }; + } + }, + })); + // keep the push/callingx runtime out of this test + jest.doMock('../src/utils/push/setupIosVoipPushEvents', () => ({ + setupIosVoipPushEvents: jest.fn(), + })); + jest.doMock('../src/utils/push/setupAndroidPushEvents', () => ({ + setupAndroidPushEvents: jest.fn(), + })); + jest.doMock('../src/utils/push/setupCallingExpEvents', () => ({ + setupCallingExpEvents: jest.fn(), + })); + jest.doMock('../src/utils/push/libs/callingx', () => ({ + extractCallingExpOptions: jest.fn(), + getCallingxLib: jest.fn(), + getCallingxLibIfAvailable: jest.fn(), + })); + StreamVideoRN = require('../src/utils/StreamVideoRN').StreamVideoRN; + }); + return StreamVideoRN; +}; + +describe('StreamVideoRN.setDisableCommunicationModeWorkaround', () => { + afterEach(() => jest.resetModules()); + + it('forwards true to the native module on Android', () => { + const inCallManager = makeInCallManager(); + const StreamVideoRN = loadStreamVideoRN({ os: 'android', inCallManager }); + + StreamVideoRN.setDisableCommunicationModeWorkaround(true); + + expect( + inCallManager.setDisableCommunicationModeWorkaround, + ).toHaveBeenCalledWith(true); + }); + + it('forwards false to the native module on Android', () => { + const inCallManager = makeInCallManager(); + const StreamVideoRN = loadStreamVideoRN({ os: 'android', inCallManager }); + + StreamVideoRN.setDisableCommunicationModeWorkaround(false); + + expect( + inCallManager.setDisableCommunicationModeWorkaround, + ).toHaveBeenCalledWith(false); + }); + + it('is a no-op on iOS', () => { + const inCallManager = makeInCallManager(); + const StreamVideoRN = loadStreamVideoRN({ os: 'ios', inCallManager }); + + StreamVideoRN.setDisableCommunicationModeWorkaround(true); + + expect( + inCallManager.setDisableCommunicationModeWorkaround, + ).not.toHaveBeenCalled(); + }); + + it('survives a native module that predates the method (version skew)', () => { + const inCallManager = makeInCallManager(); + delete (inCallManager as Partial) + .setDisableCommunicationModeWorkaround; + const StreamVideoRN = loadStreamVideoRN({ os: 'android', inCallManager }); + + expect(() => + StreamVideoRN.setDisableCommunicationModeWorkaround(true), + ).not.toThrow(); + }); + + it('survives the native module being absent entirely', () => { + const StreamVideoRN = loadStreamVideoRN({ + os: 'android', + inCallManager: undefined, + }); + + expect(() => + StreamVideoRN.setDisableCommunicationModeWorkaround(true), + ).not.toThrow(); + }); +}); diff --git a/packages/react-native-sdk/__tests__/call-manager/CallManager.test.ts b/packages/react-native-sdk/__tests__/call-manager/CallManager.test.ts index e4d23cc439..43eb250c55 100644 --- a/packages/react-native-sdk/__tests__/call-manager/CallManager.test.ts +++ b/packages/react-native-sdk/__tests__/call-manager/CallManager.test.ts @@ -82,6 +82,10 @@ const loadCallManager = ({ const { registerSDKGlobals, } = require('../../src/utils/internal/registerSDKGlobals'); + // registerSDKGlobals() is a no-op once globalThis.streamRNVideoSDK is set, and that + // global outlives jest.resetModules(). Clear it so each test binds the internal call + // manager to its own mocked native module instead of the first test's. + delete (globalThis as { streamRNVideoSDK?: unknown }).streamRNVideoSDK; registerSDKGlobals(); internalCallManager = globalThis.streamRNVideoSDK!.callManager; }); diff --git a/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/audio/AudioDeviceManager.kt b/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/audio/AudioDeviceManager.kt index c4a8d67fa8..c9081f4c51 100644 --- a/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/audio/AudioDeviceManager.kt +++ b/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/audio/AudioDeviceManager.kt @@ -107,6 +107,30 @@ class AudioDeviceManager( */ var telecomManagedMode: Boolean = false + /** + * Opt-out for the Android 11+ communication-mode keep-alive. Sticky developer preference — + * intentionally NOT reset in [stop], unlike the per-call fields above. + */ + var disableCommunicationModeWorkaround: Boolean = false + + /** + * True once [stop]/[close] have torn routing down, until the next [start]. + * + * [updateAudioDeviceState] is enqueued from off-thread sources (Bluetooth receivers, + * audio-focus and device callbacks), so a task can still land after teardown. Re-routing + * then would re-assert MODE_IN_COMMUNICATION via AudioManagerUtil, undoing the + * MODE_NORMAL restore in [stop]. Audio-thread confined. + */ + private var routingStopped = false + + /** Keeps MODE_IN_COMMUNICATION owned for the whole Communicator call on Android 11+. */ + private val communicationModeKeepAlive: CommunicationModeKeepAlive = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + SilentPlaybackKeepAlive(mReactContext) + } else { + NoCommunicationModeKeepAlive + } + val bluetoothManager = BluetoothManager(mReactContext, this) private val proximityManager by lazy { ProximityManager(mReactContext, this) } @@ -130,53 +154,64 @@ class AudioDeviceManager( audioFocusUtil.setup(callAudioRole, mReactContext) } + /** Runs inline; callers must already be on the audio thread (see [runInAudioThread]). */ fun start(activity: Activity) { - runInAudioThread { - setup() - selectedAudioDeviceEndpoint = null - if (callAudioRole == CallAudioRole.Communicator) { - // Audio routing is manually controlled by the SDK in communication media mode - // and local microphone can be published - activity.volumeControlStream = AudioManager.STREAM_VOICE_CALL - if (!telecomManagedMode) { - // Telecom owns routing/focus; only run our own routing when not Telecom-managed. - bluetoothManager.start() - mAudioManager.registerAudioDeviceCallback(this, null) - updateAudioDeviceState() - } - proximityManager.start() - } else { - activity.volumeControlStream = AudioManager.USE_DEFAULT_STREAM_TYPE - } + routingStopped = false + setup() + selectedAudioDeviceEndpoint = null + if (callAudioRole == CallAudioRole.Communicator) { + // Audio routing is manually controlled by the SDK in communication media mode + // and local microphone can be published + activity.volumeControlStream = AudioManager.STREAM_VOICE_CALL if (!telecomManagedMode) { - audioFocusUtil.requestFocus(callAudioRole, mReactContext) + // Telecom owns routing/focus; only run our own routing when not Telecom-managed. + bluetoothManager.start() + mAudioManager.registerAudioDeviceCallback(this, null) + updateAudioDeviceState() } + proximityManager.start() + } else { + activity.volumeControlStream = AudioManager.USE_DEFAULT_STREAM_TYPE + } + if (!telecomManagedMode) { + audioFocusUtil.requestFocus(callAudioRole, mReactContext) + } + // Started last, after focus/routing, so the silent track uses the correct route. + if (callAudioRole == CallAudioRole.Communicator && + !telecomManagedMode && + !disableCommunicationModeWorkaround + ) { + communicationModeKeepAlive.start() } } - fun stop(activity: Activity) { - runInAudioThread { - if (callAudioRole == CallAudioRole.Communicator) { - if (!telecomManagedMode) { - // Only tear down what we set up ourselves; Telecom owns its own teardown. - if (Build.VERSION.SDK_INT >= 31) { - mAudioManager.clearCommunicationDevice() - } else { - mAudioManager.setSpeakerphoneOn(false) - } - bluetoothManager.stop() - } - callAudioRole = CallAudioRole.Communicator - enableStereo = false - defaultAudioDevice = AudioDeviceEndpoint.TYPE_SPEAKER - proximityManager.stop() - } - activity.volumeControlStream = AudioManager.USE_DEFAULT_STREAM_TYPE + /** Runs inline; callers must already be on the audio thread (see [runInAudioThread]). */ + fun stop(activity: Activity?) { + routingStopped = true + communicationModeKeepAlive.stop() + if (callAudioRole == CallAudioRole.Communicator) { if (!telecomManagedMode) { - audioFocusUtil.abandonFocus() + // Only tear down what we set up ourselves; Telecom owns its own teardown. + if (Build.VERSION.SDK_INT >= 31) { + mAudioManager.clearCommunicationDevice() + } else { + mAudioManager.setSpeakerphoneOn(false) + } + bluetoothManager.stop() + // Restore the mode set in setup(); it was previously left at + // MODE_IN_COMMUNICATION, holding the device in in-call routing. + mAudioManager.mode = AudioManager.MODE_NORMAL } - telecomManagedMode = false + callAudioRole = CallAudioRole.Communicator + enableStereo = false + defaultAudioDevice = AudioDeviceEndpoint.TYPE_SPEAKER + proximityManager.stop() } + activity?.volumeControlStream = AudioManager.USE_DEFAULT_STREAM_TYPE + if (!telecomManagedMode) { + audioFocusUtil.abandonFocus() + } + telecomManagedMode = false } fun setMicrophoneMute(enable: Boolean) { @@ -228,6 +263,9 @@ class AudioDeviceManager( } private fun switchDeviceEndpointType(@EndpointType deviceType: Int) { + // Sole sink for routing changes, and AudioManagerUtil re-asserts MODE_IN_COMMUNICATION + // here — so late work of any origin must not reach it after teardown. + if (routingStopped) return val newDevice = AudioManagerUtil.switchDeviceEndpointType( deviceType, mEndpointMaps, @@ -237,38 +275,49 @@ class AudioDeviceManager( this.selectedAudioDeviceEndpoint = newDevice } + /** Runs inline; callers must already be on the audio thread (see [runInAudioThread]). */ fun switchDeviceById( deviceId: String ) { Log.d(TAG, "switchDeviceById: deviceId = $deviceId") - runInAudioThread { - val btDevice = mEndpointMaps.bluetoothEndpoints[deviceId] - if (btDevice != null) { - if (Build.VERSION.SDK_INT >= 31) { - mAudioManager.setCommunicationDevice(btDevice.deviceInfo) - bluetoothManager.updateDevice() - this.selectedAudioDeviceEndpoint = btDevice - } else { - switchDeviceEndpointType( - AudioDeviceEndpoint.TYPE_BLUETOOTH - ) - } + // Guarded here too: the branch below sets the communication device directly rather + // than going through switchDeviceEndpointType(). + if (routingStopped) return + val btDevice = mEndpointMaps.bluetoothEndpoints[deviceId] + if (btDevice != null) { + if (Build.VERSION.SDK_INT >= 31) { + mAudioManager.setCommunicationDevice(btDevice.deviceInfo) + bluetoothManager.updateDevice() + this.selectedAudioDeviceEndpoint = btDevice } else { - val endpoint = nonBluetoothEndpointById(deviceId) - if (endpoint != null) { - switchDeviceEndpointType(endpoint.type) - } else { - Log.e(TAG, "switchDeviceById: no endpoint found for id $deviceId") - } + switchDeviceEndpointType( + AudioDeviceEndpoint.TYPE_BLUETOOTH + ) + } + } else { + val endpoint = nonBluetoothEndpointById(deviceId) + if (endpoint != null) { + switchDeviceEndpointType(endpoint.type) + } else { + Log.e(TAG, "switchDeviceById: no endpoint found for id $deviceId") } } } override fun close() { - mAudioManager.unregisterAudioDeviceCallback(this) - proximityManager.onDestroy() + // Unlike start()/stop(), this is called straight from the module, so it must be queued + // to land after any start/stop already pending on the audio thread. + runInAudioThread { + routingStopped = true + communicationModeKeepAlive.release() + mAudioManager.unregisterAudioDeviceCallback(this) + proximityManager.onDestroy() + } } + /** Short description of the keep-alive state, for the audio debug log. */ + fun communicationModeKeepAliveState(): String = communicationModeKeepAlive.describeState() + override fun onAudioDevicesAdded(addedDevices: Array?) { if (addedDevices != null) { runInAudioThread { @@ -423,6 +472,9 @@ class AudioDeviceManager( */ fun updateAudioDeviceState() { runInAudioThread { + // Bail early: on API < 31 bluetoothState survives stop(), so a late pass could + // still start SCO after the call ended. + if (routingStopped) return@runInAudioThread if (telecomManagedMode) { // Telecom is the single source of truth for routing/endpoints in this mode. return@runInAudioThread diff --git a/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/audio/CommunicationModeKeepAlive.kt b/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/audio/CommunicationModeKeepAlive.kt new file mode 100644 index 0000000000..431651f33f --- /dev/null +++ b/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/audio/CommunicationModeKeepAlive.kt @@ -0,0 +1,247 @@ +package com.streamvideo.reactnative.audio + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Context.AUDIO_SERVICE +import android.media.AudioAttributes +import android.media.AudioFormat +import android.media.AudioManager +import android.media.AudioTrack +import android.os.Build +import android.util.Log +import androidx.annotation.RequiresApi +import com.streamvideo.reactnative.callmanager.StreamInCallManagerModule +import java.nio.ByteBuffer +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.TimeUnit + +/** + * Holds the audio system in [AudioManager.MODE_IN_COMMUNICATION] for the duration + * of a communicator-role call. + * + * Why this is needed: from Android 11 (API 30) the platform demotes the + * audio mode back to `MODE_NORMAL` about six seconds after `setMode()` unless the + * call is actively playing or recording on the voice-communication path. + * During a call whose microphone is muted and that has no inbound audio yet, + * neither condition is met, so routing silently falls back to the media path and + * hardware echo cancellation is switched off. + * Details: https://issuetracker.google.com/issues/209493718 + * + * We satisfy the "actively playing" condition by continuously looping an inaudible + * audo track on the voice-communication stream, which keeps the platform counting us + * as an active player and therefore keeps the mode in place. + */ +interface CommunicationModeKeepAlive { + /** Begin holding the communication mode. Idempotent. */ + fun start() + + /** Stop holding the communication mode. Idempotent; the instance can be started again. */ + fun stop() + + /** Permanently release all resources. The instance must not be used afterwards. */ + fun release() + + /** Short human-readable state, surfaced in the audio-state debug log. */ + fun describeState(): String +} + +/** No-op variant used on platforms where the mode reset does not occur (Android API < 30). */ +object NoCommunicationModeKeepAlive : CommunicationModeKeepAlive { + override fun start() {} + override fun stop() {} + override fun release() {} + override fun describeState(): String = "disabled (android platform API < 30)" +} + +/** + * Default implementation backed by a silent, looping voice-communication + * [AudioTrack]. If the silent player cannot be created on a given device, it + * degrades to a lightweight poller that re-applies the mode whenever the platform + * has reset it. + * + * All lifecycle transitions and every access to [track] are serialized on [gate], + * so a play/pause can never overlap a release. The poller only ever touches the + * audio mode (never the track), so it runs lock-free. + * + * @suppress + */ +@RequiresApi(Build.VERSION_CODES.R) +internal class SilentPlaybackKeepAlive( + private val context: Context, +) : CommunicationModeKeepAlive { + + private val gate = Any() + + private var track: AudioTrack? = null + + /** Whether we currently intend the silent player to be running. */ + private var engaged = false + + /** Read by the poller (off-gate) to drop ticks that were queued before we stopped. */ + @Volatile + private var live = false + + private var modePoller: ScheduledExecutorService? = null + + override fun start(): Unit = synchronized(gate) { + live = true + if (engaged) return + engaged = true + + val player = track ?: createSilentTrack()?.also { track = it } + if (player != null && player.state == AudioTrack.STATE_INITIALIZED && play(player)) { + return + } + // No usable silent player on this device — fall back to reactive repair. + player?.release() + track = null + startModePoller() + } + + /** + * Starts playback, reporting whether the track actually entered the playing state. + * `play()` can throw or leave the track stopped even from STATE_INITIALIZED; treating + * that as success would strand the keep-alive with neither a track nor the poller. + */ + private fun play(player: AudioTrack): Boolean = + try { + player.play() + player.playState == AudioTrack.PLAYSTATE_PLAYING + } catch (e: IllegalStateException) { + Log.w(TAG, "Silent keep-alive track refused to start.", e) + false + } + + override fun stop() = synchronized(gate) { + live = false + if (engaged) { + engaged = false + track?.takeIf { it.state == AudioTrack.STATE_INITIALIZED }?.pause() + } + stopModePoller() + } + + override fun release() = synchronized(gate) { + live = false + engaged = false + stopModePoller() + track?.let { + if (it.state == AudioTrack.STATE_INITIALIZED) it.pause() + it.release() + } + track = null + } + + override fun describeState(): String = synchronized(gate) { + val playing = track?.playState == AudioTrack.PLAYSTATE_PLAYING + "enabled, engaged=$engaged, built=${track != null}, playing=$playing, " + + "modePoller=${modePoller != null}" + } + + @SuppressLint("Range") + private fun createSilentTrack(): AudioTrack? { + return try { + val audioManager = context.getSystemService(AUDIO_SERVICE) as AudioManager + val sampleRate = positiveOrDefault( + audioManager.getProperty(AudioManager.PROPERTY_OUTPUT_SAMPLE_RATE)?.toIntOrNull(), + DEFAULT_SAMPLE_RATE, + ) + // One buffer worth of frames is enough — we loop it forever. + val frameCount = positiveOrDefault( + audioManager.getProperty(AudioManager.PROPERTY_OUTPUT_FRAMES_PER_BUFFER) + ?.toIntOrNull(), + sampleRate / 100, // ~10 ms + ) + val bufferBytes = frameCount * BYTES_PER_FRAME + // A freshly allocated direct buffer is zero-filled, i.e. pure silence. + val silence = ByteBuffer.allocateDirect(bufferBytes) + + val player = AudioTrack.Builder() + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build(), + ) + .setAudioFormat( + AudioFormat.Builder() + .setEncoding(ENCODING) + .setSampleRate(sampleRate) + .setChannelMask(AudioFormat.CHANNEL_OUT_MONO) + .build(), + ) + .setBufferSizeInBytes(bufferBytes) + .setTransferMode(AudioTrack.MODE_STATIC) + .setSessionId(AudioManager.AUDIO_SESSION_ID_GENERATE) + .build() + + val written = player.write(silence, silence.remaining(), AudioTrack.WRITE_BLOCKING) + val loopResult = player.setLoopPoints(0, frameCount - 1, -1) + val ready = player.state == AudioTrack.STATE_INITIALIZED && + written >= 0 && + loopResult == AudioTrack.SUCCESS + if (ready) { + player + } else { + Log.w( + TAG, + "Silent keep-alive track not usable " + + "(state=${player.state}, written=$written, loop=$loopResult); discarding.", + ) + player.release() + null + } + } catch (e: Exception) { + Log.w(TAG, "Could not create silent keep-alive track.", e) + null + } + } + + private fun startModePoller() { + if (modePoller != null) return + modePoller = Executors.newSingleThreadScheduledExecutor().also { poller -> + poller.scheduleWithFixedDelay( + ::reapplyModeIfReset, + MODE_POLL_INTERVAL_MS, + MODE_POLL_INTERVAL_MS, + TimeUnit.MILLISECONDS, + ) + } + } + + private fun reapplyModeIfReset() { + // Read/write the mode on the shared audio thread so it can't race routing changes. + AudioDeviceManager.runInAudioThread { + if (!live) return@runInAudioThread + val audioManager = context.getSystemService(AUDIO_SERVICE) as AudioManager + if (audioManager.mode != AudioManager.MODE_IN_COMMUNICATION) { + Log.d(TAG, "Re-applying MODE_IN_COMMUNICATION after a platform reset.") + audioManager.mode = AudioManager.MODE_IN_COMMUNICATION + } + } + } + + private fun stopModePoller() { + modePoller?.shutdownNow() + modePoller = null + } + + private fun positiveOrDefault(value: Int?, default: Int): Int = + if (value != null && value > 0) value else default + + companion object { + private const val TAG = StreamInCallManagerModule.TAG + private const val DEFAULT_SAMPLE_RATE = 16000 + private const val ENCODING = AudioFormat.ENCODING_PCM_16BIT + + // We always emit mono 16-bit PCM, so a frame is exactly 2 bytes. + private const val BYTES_PER_FRAME = 2 + + // Fallback poll cadence. Deliberately shorter than AOSP's ~6s reset window: the + // interval bounds only the worst-case wrong-routing gap (<= interval), while the + // real setMode/HAL transition rate stays governed by the OS re-arm, so polling + // faster only costs cheap reads and shortens the recovery latency. + private const val MODE_POLL_INTERVAL_MS = 2000L + } +} diff --git a/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/callmanager/StreamInCallManagerModule.kt b/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/callmanager/StreamInCallManagerModule.kt index 0c9c669e2a..d4a11af62c 100644 --- a/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/callmanager/StreamInCallManagerModule.kt +++ b/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/callmanager/StreamInCallManagerModule.kt @@ -75,6 +75,18 @@ class StreamInCallManagerModule(reactContext: ReactApplicationContext) : } } + @ReactMethod + fun setDisableCommunicationModeWorkaround(disabled: Boolean) { + AudioDeviceManager.runInAudioThread { + if (audioManagerActivated) { + Log.e(TAG, "setDisableCommunicationModeWorkaround(): AudioManager is already activated and so it cannot be changed") + return@runInAudioThread + } + Log.d(TAG, "setDisableCommunicationModeWorkaround(): $disabled") + mAudioDeviceManager.disableCommunicationModeWorkaround = disabled + } + } + @ReactMethod fun setDefaultAudioDeviceEndpointType(endpointDeviceTypeName: String) { AudioDeviceManager.runInAudioThread { @@ -129,10 +141,8 @@ class StreamInCallManagerModule(reactContext: ReactApplicationContext) : AudioDeviceManager.runInAudioThread { if (audioManagerActivated) { Log.d(TAG, "stop() mAudioDeviceManager") - reactApplicationContext.currentActivity?.let { - mAudioDeviceManager.stop(it) - audioManagerActivated = false - } + mAudioDeviceManager.stop(reactApplicationContext.currentActivity) + audioManagerActivated = false setMicrophoneMute(false) setKeepScreenOn(false) } @@ -185,7 +195,8 @@ class StreamInCallManagerModule(reactContext: ReactApplicationContext) : @ReactMethod(isBlockingSynchronousMethod = true) fun getAudioStateLog(): String { - return WebRtcAudioUtils.getAudioStateLog(reactApplicationContext) + return WebRtcAudioUtils.getAudioStateLog(reactApplicationContext) + + "Communication mode keep-alive: ${mAudioDeviceManager.communicationModeKeepAliveState()}\n" } @Suppress("unused") diff --git a/packages/react-native-sdk/src/modules/call-manager/native-module.d.ts b/packages/react-native-sdk/src/modules/call-manager/native-module.d.ts index 4881e7ff58..029639710a 100644 --- a/packages/react-native-sdk/src/modules/call-manager/native-module.d.ts +++ b/packages/react-native-sdk/src/modules/call-manager/native-module.d.ts @@ -32,6 +32,20 @@ export interface CallManager extends NativeModule { */ setTelecomManagedMode: (enabled: boolean) => void; + /** + * Opt out of the Android 11+ communication-mode keep-alive workaround. + * + * On Android 11+ (API 30) the OS resets `MODE_IN_COMMUNICATION` ~6s after it is + * set when there is no active voice-communication playback/recording, which + * breaks audio routing and AEC. By default the SDK plays a silent + * voice-communication track for the duration of a communicator-role call to + * prevent this. Pass `true` to disable it. Sticky for the process lifetime. + * + * No-op on iOS and on Android below API 30. See + * https://issuetracker.google.com/issues/209493718 + */ + setDisableCommunicationModeWorkaround: (disabled: boolean) => void; + /** * Choose an audio device endpoint by its stable id. * @param deviceId - The id of the audio device to choose (`AudioDevice.id`). diff --git a/packages/react-native-sdk/src/utils/StreamVideoRN/index.ts b/packages/react-native-sdk/src/utils/StreamVideoRN/index.ts index a6189ed534..12f3766946 100644 --- a/packages/react-native-sdk/src/utils/StreamVideoRN/index.ts +++ b/packages/react-native-sdk/src/utils/StreamVideoRN/index.ts @@ -158,6 +158,33 @@ export class StreamVideoRN { pushLogoutCallbacks.current = []; } + /** + * Android only. Opt out of the Android 11+ communication-mode keep-alive. + * + * By default the SDK plays a silent voice-communication track for the duration of a + * communicator-role call, so Android does not reset `MODE_IN_COMMUNICATION` ~6s after it is + * set (which breaks audio routing and echo cancellation). + * See {@link https://issuetracker.google.com/issues/209493718} + * + * Call this at app start, alongside {@link setPushConfig} — the native audio manager + * rejects the change once it has been activated for a call. No-op on iOS and on + * Android below API 30. + */ + static setDisableCommunicationModeWorkaround(disabled: boolean) { + if (Platform.OS !== 'android') { + return; + } + try { + NativeModules.StreamInCallManager?.setDisableCommunicationModeWorkaround( + disabled, + ); + } catch (error) { + videoLoggerSystem + .getLogger('StreamVideoRN') + .warn('setDisableCommunicationModeWorkaround failed', error); + } + } + /** * Play native busy tone for call rejection */ From 8cedb195e918f0f5c9b4fb445f942d08ed8f7063 Mon Sep 17 00:00:00 2001 From: GitHub Actions Bot <> Date: Fri, 4 Sep 2026 15:01:20 +0000 Subject: [PATCH 03/18] chore(@stream-io/video-client): release version 1.59.1 --- packages/client/CHANGELOG.md | 7 +++++++ packages/client/package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/client/CHANGELOG.md b/packages/client/CHANGELOG.md index c6578345e9..518f327df5 100644 --- a/packages/client/CHANGELOG.md +++ b/packages/client/CHANGELOG.md @@ -2,6 +2,13 @@ This file was generated using [@jscutlery/semver](https://github.com/jscutlery/semver). +## [1.59.1](https://github.com/GetStream/stream-video-js/compare/@stream-io/video-client-1.59.0...@stream-io/video-client-1.59.1) (2026-09-04) + +### Bug Fixes + +- **client:** isolate coordinator event listener errors from the dispatch loop ([#2404](https://github.com/GetStream/stream-video-js/issues/2404)) ([4ab49de](https://github.com/GetStream/stream-video-js/commit/4ab49decc4bbc70b0fc35d6b92f2bd18922a105c)), closes [GetStream/stream-chat-js#1850](https://github.com/GetStream/stream-chat-js/issues/1850) +- **client:** keep passive participants out of the spotlight ([#2405](https://github.com/GetStream/stream-video-js/issues/2405)) ([0713650](https://github.com/GetStream/stream-video-js/commit/0713650fa90caa8a82ce0bee75acf4dbaaad33e9)) + ## [1.59.0](https://github.com/GetStream/stream-video-js/compare/@stream-io/video-client-1.58.0...@stream-io/video-client-1.59.0) (2026-08-28) ### Features diff --git a/packages/client/package.json b/packages/client/package.json index 6bf8dc0c91..18856c77de 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@stream-io/video-client", - "version": "1.59.0", + "version": "1.59.1", "main": "dist/index.cjs.js", "module": "dist/index.es.js", "browser": "dist/index.browser.es.js", From a15ee7c750e28536f0e90429f016f3ce1e0e1bf5 Mon Sep 17 00:00:00 2001 From: GitHub Actions Bot <> Date: Fri, 4 Sep 2026 15:01:53 +0000 Subject: [PATCH 04/18] chore(@stream-io/video-react-bindings): release version 1.20.2 --- packages/react-bindings/CHANGELOG.md | 6 ++++++ packages/react-bindings/package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/react-bindings/CHANGELOG.md b/packages/react-bindings/CHANGELOG.md index a547bf1238..dcd8497dee 100644 --- a/packages/react-bindings/CHANGELOG.md +++ b/packages/react-bindings/CHANGELOG.md @@ -2,6 +2,12 @@ This file was generated using [@jscutlery/semver](https://github.com/jscutlery/semver). +## [1.20.2](https://github.com/GetStream/stream-video-js/compare/@stream-io/video-react-bindings-1.20.1...@stream-io/video-react-bindings-1.20.2) (2026-09-04) + +### Dependency Updates + +- `@stream-io/video-client` updated to version `1.59.1` + ## [1.20.1](https://github.com/GetStream/stream-video-js/compare/@stream-io/video-react-bindings-1.20.0...@stream-io/video-react-bindings-1.20.1) (2026-08-28) ### Dependency Updates diff --git a/packages/react-bindings/package.json b/packages/react-bindings/package.json index 0c07639b84..7ac63238fa 100644 --- a/packages/react-bindings/package.json +++ b/packages/react-bindings/package.json @@ -1,6 +1,6 @@ { "name": "@stream-io/video-react-bindings", - "version": "1.20.1", + "version": "1.20.2", "main": "./dist/index.cjs.js", "module": "./dist/esm/index.js", "types": "./dist/index.d.ts", From 20bdad5ebc351b539782b436695699002b2b1ad7 Mon Sep 17 00:00:00 2001 From: GitHub Actions Bot <> Date: Fri, 4 Sep 2026 15:02:13 +0000 Subject: [PATCH 05/18] chore(@stream-io/react-native-callingx): release version 0.11.3 --- packages/react-native-callingx/CHANGELOG.md | 6 ++++++ packages/react-native-callingx/package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/react-native-callingx/CHANGELOG.md b/packages/react-native-callingx/CHANGELOG.md index adc8422221..efce73721d 100644 --- a/packages/react-native-callingx/CHANGELOG.md +++ b/packages/react-native-callingx/CHANGELOG.md @@ -2,6 +2,12 @@ This file was generated using [@jscutlery/semver](https://github.com/jscutlery/semver). +## [0.11.3](https://github.com/GetStream/stream-video-js/compare/@stream-io/react-native-callingx-0.11.2...@stream-io/react-native-callingx-0.11.3) (2026-09-04) + +### Bug Fixes + +- **callingx:** don't tear down concurrent calls on stop service ([#2394](https://github.com/GetStream/stream-video-js/issues/2394)) ([d6e9deb](https://github.com/GetStream/stream-video-js/commit/d6e9debbe98ae91cc349f010eb92f76b35a3f932)) + ## [0.11.0](https://github.com/GetStream/stream-video-js/compare/@stream-io/react-native-callingx-0.10.0...@stream-io/react-native-callingx-0.11.0) (2026-08-28) ### Features diff --git a/packages/react-native-callingx/package.json b/packages/react-native-callingx/package.json index 26c57e6dca..298271a2fb 100644 --- a/packages/react-native-callingx/package.json +++ b/packages/react-native-callingx/package.json @@ -1,6 +1,6 @@ { "name": "@stream-io/react-native-callingx", - "version": "0.11.0", + "version": "0.11.3", "description": "CallKit and Telecom API capabilities for React Native", "main": "./dist/module/index.js", "module": "./dist/module/index.js", From 84f7c5ed2948e499754df0c4393cb03b19d38faa Mon Sep 17 00:00:00 2001 From: GitHub Actions Bot <> Date: Fri, 4 Sep 2026 15:02:31 +0000 Subject: [PATCH 06/18] chore(@stream-io/video-filters-web): release version 0.10.0 --- packages/video-filters-web/CHANGELOG.md | 6 ++++++ packages/video-filters-web/package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/video-filters-web/CHANGELOG.md b/packages/video-filters-web/CHANGELOG.md index 2848862d7c..9967c1590a 100644 --- a/packages/video-filters-web/CHANGELOG.md +++ b/packages/video-filters-web/CHANGELOG.md @@ -2,6 +2,12 @@ This file was generated using [@jscutlery/semver](https://github.com/jscutlery/semver). +## [0.10.0](https://github.com/GetStream/stream-video-js/compare/@stream-io/video-filters-web-0.9.1...@stream-io/video-filters-web-0.10.0) (2026-09-04) + +### Features + +- **video-filters-web:** apply background filter changes without re-registering the filter ([#2403](https://github.com/GetStream/stream-video-js/issues/2403)) ([4fbb30a](https://github.com/GetStream/stream-video-js/commit/4fbb30a640f87a61d400179b24f844b04f2f3bd5)) + ## [0.8.7](https://github.com/GetStream/stream-video-js/compare/@stream-io/video-filters-web-0.8.6...@stream-io/video-filters-web-0.8.7) (2026-08-28) ### Performance Improvements diff --git a/packages/video-filters-web/package.json b/packages/video-filters-web/package.json index c400e252e2..1632adc5a3 100644 --- a/packages/video-filters-web/package.json +++ b/packages/video-filters-web/package.json @@ -1,6 +1,6 @@ { "name": "@stream-io/video-filters-web", - "version": "0.8.7", + "version": "0.10.0", "main": "./dist/index.cjs.js", "module": "./dist/esm/index.js", "types": "./dist/index.d.ts", From 86bf41ef0a5a5c4ad663847ce8eea5e59a0589cd Mon Sep 17 00:00:00 2001 From: GitHub Actions Bot <> Date: Fri, 4 Sep 2026 15:02:54 +0000 Subject: [PATCH 07/18] chore(@stream-io/video-react-native-sdk): release version 1.45.1 --- packages/react-native-sdk/CHANGELOG.md | 15 +++++++++++++++ packages/react-native-sdk/package.json | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/react-native-sdk/CHANGELOG.md b/packages/react-native-sdk/CHANGELOG.md index c37dd444af..c0ae9d05a9 100644 --- a/packages/react-native-sdk/CHANGELOG.md +++ b/packages/react-native-sdk/CHANGELOG.md @@ -2,6 +2,21 @@ This file was generated using [@jscutlery/semver](https://github.com/jscutlery/semver). +## [1.45.1](https://github.com/GetStream/stream-video-js/compare/@stream-io/video-react-native-sdk-1.45.0...@stream-io/video-react-native-sdk-1.45.1) (2026-09-04) + +### Dependency Updates + +- `@stream-io/react-native-callingx` updated to version `0.11.3` +- `@stream-io/video-client` updated to version `1.59.1` +- `@stream-io/video-react-bindings` updated to version `1.20.2` + +### Bug Fixes + +- **callingx:** don't tear down concurrent calls on stop service ([#2394](https://github.com/GetStream/stream-video-js/issues/2394)) ([d6e9deb](https://github.com/GetStream/stream-video-js/commit/d6e9debbe98ae91cc349f010eb92f76b35a3f932)) +- **rn:** auto-leave previous callingx calls on new join ([#2402](https://github.com/GetStream/stream-video-js/issues/2402)) ([5a3dfdd](https://github.com/GetStream/stream-video-js/commit/5a3dfddd8ddbfacf6e8ea109656be82da91a3436)) +- **rn:** wrong expo plugin Android call-alive foreground service detection ([#2398](https://github.com/GetStream/stream-video-js/issues/2398)) ([eac10b5](https://github.com/GetStream/stream-video-js/commit/eac10b5a390f6949b25535d2b4c4cf9bc3d5f065)) +- workaround for android non-telecom path MODE_IN_COMMUNICATION reset edge-case ([#2360](https://github.com/GetStream/stream-video-js/issues/2360)) ([65a1fb7](https://github.com/GetStream/stream-video-js/commit/65a1fb766461f95a3b70fe221e9ace0caad6c64b)) + ## [1.45.0](https://github.com/GetStream/stream-video-js/compare/@stream-io/video-react-native-sdk-1.44.0...@stream-io/video-react-native-sdk-1.45.0) (2026-08-28) ### Dependency Updates diff --git a/packages/react-native-sdk/package.json b/packages/react-native-sdk/package.json index 6b83e61fe5..f2f3a1bf1f 100644 --- a/packages/react-native-sdk/package.json +++ b/packages/react-native-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@stream-io/video-react-native-sdk", - "version": "1.45.0", + "version": "1.45.1", "description": "Stream Video SDK for React Native", "author": "https://getstream.io", "homepage": "https://getstream.io/video/docs/react-native/", From bfb77a28eb85d2cfde39a6fb7b820c1d09b41537 Mon Sep 17 00:00:00 2001 From: GitHub Actions Bot <> Date: Fri, 4 Sep 2026 15:03:22 +0000 Subject: [PATCH 08/18] chore(@stream-io/video-react-sdk): release version 1.43.0 --- packages/react-sdk/CHANGELOG.md | 12 ++++++++++++ packages/react-sdk/package.json | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/react-sdk/CHANGELOG.md b/packages/react-sdk/CHANGELOG.md index 322c1aeb6b..14a55a1bbf 100644 --- a/packages/react-sdk/CHANGELOG.md +++ b/packages/react-sdk/CHANGELOG.md @@ -2,6 +2,18 @@ This file was generated using [@jscutlery/semver](https://github.com/jscutlery/semver). +## [1.43.0](https://github.com/GetStream/stream-video-js/compare/@stream-io/video-react-sdk-1.42.0...@stream-io/video-react-sdk-1.43.0) (2026-09-04) + +### Dependency Updates + +- `@stream-io/video-client` updated to version `1.59.1` +- `@stream-io/video-filters-web` updated to version `0.10.0` +- `@stream-io/video-react-bindings` updated to version `1.20.2` + +### Features + +- **video-filters-web:** apply background filter changes without re-registering the filter ([#2403](https://github.com/GetStream/stream-video-js/issues/2403)) ([4fbb30a](https://github.com/GetStream/stream-video-js/commit/4fbb30a640f87a61d400179b24f844b04f2f3bd5)) + ## [1.42.0](https://github.com/GetStream/stream-video-js/compare/@stream-io/video-react-sdk-1.41.0...@stream-io/video-react-sdk-1.42.0) (2026-08-28) ### Dependency Updates diff --git a/packages/react-sdk/package.json b/packages/react-sdk/package.json index 1ae37c7913..68aec5f149 100644 --- a/packages/react-sdk/package.json +++ b/packages/react-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@stream-io/video-react-sdk", - "version": "1.42.0", + "version": "1.43.0", "main": "./dist/index.cjs.js", "module": "./dist/esm/index.js", "types": "./dist/index.d.ts", From 92d85006a64b755f01590ada700a24b17e884d43 Mon Sep 17 00:00:00 2001 From: GitHub Actions Bot <> Date: Fri, 4 Sep 2026 15:03:59 +0000 Subject: [PATCH 09/18] chore(@stream-io/video-react-native-dogfood): release version 4.46.1 --- sample-apps/react-native/dogfood/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sample-apps/react-native/dogfood/package.json b/sample-apps/react-native/dogfood/package.json index c1bde37884..ee020a55ba 100644 --- a/sample-apps/react-native/dogfood/package.json +++ b/sample-apps/react-native/dogfood/package.json @@ -1,6 +1,6 @@ { "name": "@stream-io/video-react-native-dogfood", - "version": "4.45.0", + "version": "4.46.1", "private": true, "scripts": { "android": "react-native run-android", From 6d7ae7f15f33dafab5a017597462cba29547187f Mon Sep 17 00:00:00 2001 From: Gabriel Donadel Dall'Agnol Date: Mon, 7 Sep 2026 07:50:10 -0300 Subject: [PATCH 10/18] fix(android): skip explicit Kotlin plugin when AGP registers the kotlin extension (#2417) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Android Gradle Plugin 9 ships built-in Kotlin support and enables it by default, so AGP registers the `kotlin` extension itself. When a library *also* applies `kotlin-android` explicitly, the two collide and configuration fails before anything compiles. AGP words it two ways, both the same problem: ``` > Failed to apply plugin 'kotlin-android'. > Cannot add extension with name 'kotlin', as there is an extension already registered with that name. ``` ``` > The 'kotlin-android' plugin is no longer required for Kotlin support since AGP 9.0. ``` The apply is unconditional in all 4 modules below, so on an AGP 9 project this cannot be built at all. There is no consumer-side workaround short of patching the file — setting `android.builtInKotlin=false` project-wide just to build one dependency is not a reasonable ask, and that escape hatch is removed in AGP 10. ## Change Apply the plugin only when nothing has registered the `kotlin` extension yet: ```groovy if (project.extensions.findByName('kotlin') == null) { apply plugin: 'kotlin-android' } ``` Files changed: - `packages/noise-cancellation-react-native/android/build.gradle` - `packages/react-native-callingx/android/build.gradle` - `packages/react-native-sdk/android/build.gradle` - `packages/video-filters-react-native/android/build.gradle` This tests the condition that actually fails, so there is no AGP version table to keep in sync, and it covers AGP 10 — where the `android.builtInKotlin` opt-out is removed — without a special case. | AGP | `android.builtInKotlin` | `kotlin` extension | explicit apply | |---|---|---|---| | 8.x | unset or `false` | absent | yes (unchanged) | | 9.x | unset or `true` | registered by AGP | no | | 9.x | `false` | absent | yes | | 10+ | n/a (removed) | registered by AGP | no | The guard sits after `apply plugin: 'com.android.library'` in every file it touches, so AGP has already registered its extensions by the time it runs. I checked that ordering per file rather than assuming it. ## What I verified, and what I did not - **Verified end to end** on a real Expo SDK 58 / React Native 0.87 project with AGP 9.2.1 and Gradle 9.4.1: `:app:assembleDebug` succeeds both with `-Pandroid.newDsl=true -Pandroid.builtInKotlin=true` and with both flags off. - Confirmed both branches actually execute rather than one path always winning: with the flags off, `compileDebugKotlin` runs from the explicitly applied plugin; with them on the build completes without it. - Every changed file passes a Groovy `Phases.CONVERSION` syntax check. - **Not run:** this repo's own CI or example app. ## Where this came from A sweep of 1000 popular React Native libraries against the AGP 9 defaults. 281 failed with the new DSL enabled, and **269 of those failed on exactly this collision** — by far the most common blocker. Affects `@stream-io/react-native-callingx`, `@stream-io/video-react-native-sdk` here. The same guard shape was accepted in [RevenueCat/react-native-purchases#1934](https://github.com/RevenueCat/react-native-purchases/pull/1934), at that maintainer's suggestion. ## Summary by CodeRabbit * **Bug Fixes** * Resolved Android build configuration failures when using Android Gradle Plugin 9. * Improved compatibility across the affected React Native packages by avoiding duplicate Kotlin configuration. --- .../noise-cancellation-react-native/android/build.gradle | 8 +++++++- packages/react-native-callingx/android/build.gradle | 9 ++++++++- packages/react-native-sdk/android/build.gradle | 8 +++++++- packages/video-filters-react-native/android/build.gradle | 8 +++++++- 4 files changed, 29 insertions(+), 4 deletions(-) diff --git a/packages/noise-cancellation-react-native/android/build.gradle b/packages/noise-cancellation-react-native/android/build.gradle index 136c63dd85..7e74acd527 100644 --- a/packages/noise-cancellation-react-native/android/build.gradle +++ b/packages/noise-cancellation-react-native/android/build.gradle @@ -16,7 +16,13 @@ buildscript { apply plugin: "com.android.library" -apply plugin: "kotlin-android" +// AGP 9 ships built-in Kotlin support and registers the `kotlin` extension +// itself. Applying the Kotlin plugin on top of it fails configuration with +// "Cannot add extension with name 'kotlin'". Only apply it when nothing has +// registered that extension yet. +if (project.extensions.findByName('kotlin') == null) { + apply plugin: "kotlin-android" +} def getExtOrIntegerDefault(name) { diff --git a/packages/react-native-callingx/android/build.gradle b/packages/react-native-callingx/android/build.gradle index 41ca28b451..017e9b07c4 100644 --- a/packages/react-native-callingx/android/build.gradle +++ b/packages/react-native-callingx/android/build.gradle @@ -17,7 +17,14 @@ buildscript { apply plugin: "com.android.library" -apply plugin: "kotlin-android" +// AGP 9 ships built-in Kotlin support and registers the `kotlin` extension +// itself. Applying the Kotlin plugin on top of it fails configuration with +// "Cannot add extension with name 'kotlin'". Only apply it when nothing has +// registered that extension yet. +if (project.extensions.findByName('kotlin') == null) { + apply plugin: "kotlin-android" +} + apply plugin: "kotlin-parcelize" apply plugin: "com.facebook.react" diff --git a/packages/react-native-sdk/android/build.gradle b/packages/react-native-sdk/android/build.gradle index e196746e39..0897d2f60f 100644 --- a/packages/react-native-sdk/android/build.gradle +++ b/packages/react-native-sdk/android/build.gradle @@ -19,7 +19,13 @@ def isNewArchitectureEnabled() { } apply plugin: "com.android.library" -apply plugin: "kotlin-android" +// AGP 9 ships built-in Kotlin support and registers the `kotlin` extension +// itself. Applying the Kotlin plugin on top of it fails configuration with +// "Cannot add extension with name 'kotlin'". Only apply it when nothing has +// registered that extension yet. +if (project.extensions.findByName('kotlin') == null) { + apply plugin: "kotlin-android" +} if (isNewArchitectureEnabled()) { apply plugin: "com.facebook.react" diff --git a/packages/video-filters-react-native/android/build.gradle b/packages/video-filters-react-native/android/build.gradle index 6911e58425..394872ac37 100644 --- a/packages/video-filters-react-native/android/build.gradle +++ b/packages/video-filters-react-native/android/build.gradle @@ -19,7 +19,13 @@ def isNewArchitectureEnabled() { } apply plugin: "com.android.library" -apply plugin: "kotlin-android" +// AGP 9 ships built-in Kotlin support and registers the `kotlin` extension +// itself. Applying the Kotlin plugin on top of it fails configuration with +// "Cannot add extension with name 'kotlin'". Only apply it when nothing has +// registered that extension yet. +if (project.extensions.findByName('kotlin') == null) { + apply plugin: "kotlin-android" +} if (isNewArchitectureEnabled()) { apply plugin: "com.facebook.react" From 2f5bb0f1389c4d6d25becef0972af4de8431d04d Mon Sep 17 00:00:00 2001 From: Santhosh Vaiyapuri Date: Mon, 7 Sep 2026 16:11:53 +0200 Subject: [PATCH 11/18] use alpha for testing --- packages/react-native-sdk/package.json | 4 +-- sample-apps/react-native/dogfood/package.json | 2 +- yarn.lock | 34 ++++++------------- 3 files changed, 14 insertions(+), 26 deletions(-) diff --git a/packages/react-native-sdk/package.json b/packages/react-native-sdk/package.json index bf5cdb8c97..1f2d41ff17 100644 --- a/packages/react-native-sdk/package.json +++ b/packages/react-native-sdk/package.json @@ -65,7 +65,7 @@ "@react-native-firebase/messaging": ">=17.5.0", "@stream-io/noise-cancellation-react-native": ">=0.1.0", "@stream-io/react-native-callingx": ">=0.1.0", - "@stream-io/react-native-webrtc": "^145.3.1", + "@stream-io/react-native-webrtc": "^145.3.1 || >=145.4.0-alpha.1", "@stream-io/video-filters-react-native": ">=0.1.0", "expo": ">=47.0.0", "expo-notifications": "*", @@ -119,7 +119,7 @@ "@react-native/metro-config": "0.86.2", "@stream-io/noise-cancellation-react-native": "workspace:^", "@stream-io/react-native-callingx": "workspace:^", - "@stream-io/react-native-webrtc": "file:/Users/santhoshvaiyapuri/Documents/.stream-webrtc-local/stream-io-react-native-webrtc-0.0.0-local.1787231534.tgz", + "@stream-io/react-native-webrtc": "145.4.0-alpha.1", "@stream-io/typescript-config": "workspace:^", "@stream-io/video-filters-react-native": "workspace:^", "@testing-library/jest-native": "^5.4.3", diff --git a/sample-apps/react-native/dogfood/package.json b/sample-apps/react-native/dogfood/package.json index 4e12453574..1d73b851f2 100644 --- a/sample-apps/react-native/dogfood/package.json +++ b/sample-apps/react-native/dogfood/package.json @@ -23,7 +23,7 @@ "@react-navigation/native-stack": "^7.18.6", "@stream-io/noise-cancellation-react-native": "workspace:^", "@stream-io/react-native-callingx": "workspace:^", - "@stream-io/react-native-webrtc": "file:/Users/santhoshvaiyapuri/Documents/.stream-webrtc-local/stream-io-react-native-webrtc-0.0.0-local.1787231534.tgz", + "@stream-io/react-native-webrtc": "145.4.0-alpha.1", "@stream-io/video-filters-react-native": "workspace:^", "@stream-io/video-react-native-sdk": "workspace:^", "axios": "^1.19.0", diff --git a/yarn.lock b/yarn.lock index 7e34379bc5..90c8830b10 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6952,39 +6952,27 @@ __metadata: languageName: unknown linkType: soft -"@stream-io/react-native-webrtc@file:/Users/santhoshvaiyapuri/Documents/.stream-webrtc-local/stream-io-react-native-webrtc-0.0.0-local.1787231534.tgz::locator=%40stream-io%2Fvideo-react-native-dogfood%40workspace%3Asample-apps%2Freact-native%2Fdogfood": - version: 0.0.0-local.1787231534 - resolution: "@stream-io/react-native-webrtc@file:/Users/santhoshvaiyapuri/Documents/.stream-webrtc-local/stream-io-react-native-webrtc-0.0.0-local.1787231534.tgz#/Users/santhoshvaiyapuri/Documents/.stream-webrtc-local/stream-io-react-native-webrtc-0.0.0-local.1787231534.tgz::hash=6261aa&locator=%40stream-io%2Fvideo-react-native-dogfood%40workspace%3Asample-apps%2Freact-native%2Fdogfood" - dependencies: - base64-js: "npm:^1.5.1" - debug: "npm:^4.4.3" - peerDependencies: - react-native: ">=0.73.0" - checksum: 10/cb8fcf4fc08875a040464ba302e0b0b4429eb7674fd20a620bc12ff51bc74abbcb9ad9816f7326a9608f6e244bc6d0b9067155df5c8d630679bc13744d3f6838 - languageName: node - linkType: hard - -"@stream-io/react-native-webrtc@file:/Users/santhoshvaiyapuri/Documents/.stream-webrtc-local/stream-io-react-native-webrtc-0.0.0-local.1787231534.tgz::locator=%40stream-io%2Fvideo-react-native-sdk%40workspace%3Apackages%2Freact-native-sdk": - version: 0.0.0-local.1787231534 - resolution: "@stream-io/react-native-webrtc@file:/Users/santhoshvaiyapuri/Documents/.stream-webrtc-local/stream-io-react-native-webrtc-0.0.0-local.1787231534.tgz#/Users/santhoshvaiyapuri/Documents/.stream-webrtc-local/stream-io-react-native-webrtc-0.0.0-local.1787231534.tgz::hash=6261aa&locator=%40stream-io%2Fvideo-react-native-sdk%40workspace%3Apackages%2Freact-native-sdk" +"@stream-io/react-native-webrtc@npm:145.3.1": + version: 145.3.1 + resolution: "@stream-io/react-native-webrtc@npm:145.3.1" dependencies: base64-js: "npm:^1.5.1" debug: "npm:^4.4.3" peerDependencies: react-native: ">=0.73.0" - checksum: 10/cb8fcf4fc08875a040464ba302e0b0b4429eb7674fd20a620bc12ff51bc74abbcb9ad9816f7326a9608f6e244bc6d0b9067155df5c8d630679bc13744d3f6838 + checksum: 10/be8ad331e1e7943ac674c5f8804f717dd81869221ab4a109980f4f19744ca43479a8b783aa3790d4f042a581e7d807a9fd2ca775646a5b7983865d4db49da7fc languageName: node linkType: hard -"@stream-io/react-native-webrtc@npm:145.3.1": - version: 145.3.1 - resolution: "@stream-io/react-native-webrtc@npm:145.3.1" +"@stream-io/react-native-webrtc@npm:145.4.0-alpha.1": + version: 145.4.0-alpha.1 + resolution: "@stream-io/react-native-webrtc@npm:145.4.0-alpha.1" dependencies: base64-js: "npm:^1.5.1" debug: "npm:^4.4.3" peerDependencies: react-native: ">=0.73.0" - checksum: 10/be8ad331e1e7943ac674c5f8804f717dd81869221ab4a109980f4f19744ca43479a8b783aa3790d4f042a581e7d807a9fd2ca775646a5b7983865d4db49da7fc + checksum: 10/0bb59a5e2e5f3b804b2e41a1ce6f006a3ade457ef71a07c9dbdaf575e9fcfa76534276ba12c4e39d18ce61c43d2c3a97dd558c56a75380400bd5ed3a5e14fd96 languageName: node linkType: hard @@ -7252,7 +7240,7 @@ __metadata: "@react-navigation/native-stack": "npm:^7.18.6" "@stream-io/noise-cancellation-react-native": "workspace:^" "@stream-io/react-native-callingx": "workspace:^" - "@stream-io/react-native-webrtc": "file:/Users/santhoshvaiyapuri/Documents/.stream-webrtc-local/stream-io-react-native-webrtc-0.0.0-local.1787231534.tgz" + "@stream-io/react-native-webrtc": "npm:145.4.0-alpha.1" "@stream-io/video-filters-react-native": "workspace:^" "@stream-io/video-react-native-sdk": "workspace:^" "@types/react": "npm:^19.2.18" @@ -7346,7 +7334,7 @@ __metadata: "@react-native/metro-config": "npm:0.86.2" "@stream-io/noise-cancellation-react-native": "workspace:^" "@stream-io/react-native-callingx": "workspace:^" - "@stream-io/react-native-webrtc": "file:/Users/santhoshvaiyapuri/Documents/.stream-webrtc-local/stream-io-react-native-webrtc-0.0.0-local.1787231534.tgz" + "@stream-io/react-native-webrtc": "npm:145.4.0-alpha.1" "@stream-io/typescript-config": "workspace:^" "@stream-io/video-client": "workspace:*" "@stream-io/video-filters-react-native": "workspace:^" @@ -7381,7 +7369,7 @@ __metadata: "@react-native-firebase/messaging": ">=17.5.0" "@stream-io/noise-cancellation-react-native": ">=0.1.0" "@stream-io/react-native-callingx": ">=0.1.0" - "@stream-io/react-native-webrtc": ^145.3.1 + "@stream-io/react-native-webrtc": ^145.3.1 || >=145.4.0-alpha.1 "@stream-io/video-filters-react-native": ">=0.1.0" expo: ">=47.0.0" expo-notifications: "*" From d3bd1475e82a08217cdff1909548653e276093a8 Mon Sep 17 00:00:00 2001 From: Santhosh Vaiyapuri Date: Tue, 8 Sep 2026 22:33:29 +0200 Subject: [PATCH 12/18] review fixes --- packages/client/src/Call.ts | 140 ++--- .../__tests__/Call.ringingLifecycle.test.ts | 107 ++-- packages/client/src/types.ts | 31 +- .../io/getstream/rn/callingx/CallService.kt | 1 - .../callingx/cancelledJoinCleanup.test.ts | 140 +++++ .../callingx/joinCallingxCall.test.ts | 134 ++++- .../dogfood/meetingUiLifecycle.test.tsx | 509 ++++++++++++++++++ .../__tests__/push/ringingJoin.test.ts | 282 ++++++---- .../push/ringingJoinIntegration.test.ts | 273 +++++++--- .../modules/encryption/EncryptionManager.ts | 6 +- .../src/utils/StreamVideoRN/index.ts | 4 +- .../src/utils/StreamVideoRN/types.ts | 23 +- .../src/utils/internal/callingx/callingx.ts | 16 + .../src/utils/internal/registerSDKGlobals.ts | 4 +- .../utils/internal/ringingCallLifecycle.ts | 204 ++----- .../src/components/CallErrorComponent.tsx | 15 +- .../dogfood/src/components/MeetingUI.tsx | 131 ++++- .../react-native/dogfood/src/utils/e2ee.ts | 4 - 18 files changed, 1516 insertions(+), 508 deletions(-) create mode 100644 packages/react-native-sdk/__tests__/callingx/cancelledJoinCleanup.test.ts create mode 100644 packages/react-native-sdk/__tests__/dogfood/meetingUiLifecycle.test.tsx diff --git a/packages/client/src/Call.ts b/packages/client/src/Call.ts index 885ab7053c..4cbe6c3745 100644 --- a/packages/client/src/Call.ts +++ b/packages/client/src/Call.ts @@ -796,9 +796,8 @@ export class Call { await this.dynascaleManager?.dispose(); this.state.setCallingState(CallingState.LEFT); - // Ringing only, and driven by the call ending rather than by any view's - // lifetime. `ringingSubject` is cleared further down, so this reads true - // here for a call that was ringing. + // `ringingSubject` is cleared further down, so this still reads true for + // a call that was ringing. if (this.ringing) { globalThis.streamRNVideoSDK?.ringingCallLifecycle?.onLeave(this); } @@ -1110,48 +1109,13 @@ 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. */ - /** - * Ringing calls are joined by the SDK rather than by app code, so React Native - * takes the whole operation: it runs the app's pre-join hook, applies its - * duplicate/busy policy, and releases what it installed if the join fails. - * Everything else - web, and every ordinary call - goes straight to the - * coalesced join below. - */ - join = ( - options: JoinCallData & { - maxJoinRetries?: number; - joinResponseTimeout?: number; - rpcRequestTimeout?: number; - allowOwnTracksLoopback?: boolean; - } = {}, - ): Promise => { - const ringingLifecycle = globalThis.streamRNVideoSDK?.ringingCallLifecycle; - // `options.ring` counts: a reused instance is `LEFT` and not yet ringing, and - // it is this join that makes it a ringing call again. - const isRingingJoin = this.ringing || options.ring === true; - if (!isRingingJoin || !ringingLifecycle) { - return this.coreJoin(options); - } - // Recorded before the hook runs, not inside the join below: a `leave()` that - // lands while the app is still preparing would otherwise see a call that is - // not ringing yet and never tell the owner its lifecycle ended. - if (options.ring) { - this.ringingSubject.next(true); - } - // Captured before the hook runs, so a `leave()` that lands while the app is - // still preparing stops this join instead of resuming into media setup. - const generationBeforeSetup = this.leaveGeneration; - return ringingLifecycle.runJoin(this, () => { - if (this.leaveGeneration !== generationBeforeSetup) { - throw new Error('Call was left while the pre-join setup was running'); - } - return this.coreJoin(options); - }); - }; - - private coreJoin = singleFlight( + join = singleFlight( async ({ maxJoinRetries = 3, joinResponseTimeout, @@ -1166,6 +1130,8 @@ export class Call { } = {}): Promise => { const callingState = this.state.callingState; + // Ahead of the failure boundary below on purpose: a duplicate join on a + // live call is refused without tearing that call down. if ([CallingState.JOINED, CallingState.JOINING].includes(callingState)) { throw new Error(`Illegal State: call.join() shall be called only once`); } @@ -1178,13 +1144,17 @@ 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; + // `doJoin` captures `leaveGeneration` itself, so its own staleness checks + // compare the new value with itself; only a generation captured out here + // sees a `leave()` that landed during an earlier await or a retry backoff. const generationAtJoin = this.leaveGeneration; - /** - * Must live here: `doJoin` captures `leaveGeneration` itself, so from then - * on its own staleness checks compare the new value with itself and cannot - * see a `leave()` that landed during an earlier await or a retry backoff. - * Reuses the existing signal rather than adding another. - */ const assertNotSuperseded = () => { if (this.leaveGeneration !== generationAtJoin) { throw new Error('Call was left while the join was in progress'); @@ -1192,42 +1162,46 @@ export class Call { }; 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, - () => this.leaveGeneration !== generationAtJoin, - ); - assertNotSuperseded(); - } + try { + if (ringingLifecycle) { + await ringingLifecycle.beforeJoin(this); + assertNotSuperseded(); + } - await this.setup(); - assertNotSuperseded(); + 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, + () => this.leaveGeneration !== generationAtJoin, + ); + assertNotSuperseded(); + } - 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(); + assertNotSuperseded(); - 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, 'first-attempt', async () => { for (let attempt = 0; attempt < maxJoinRetries; attempt++) { - // A leave during the backoff below cancels the whole join, not - // just the attempt that failed. + // A leave during the backoff below cancels the whole join. assertNotSuperseded(); try { this.logger.trace(`Joining call (${attempt})`, this.cid); @@ -1276,6 +1250,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; } }, @@ -2540,10 +2517,11 @@ export class Call { * Must be called before {@link join} so the RTCPeerConnection can be * configured for E2EE. * - * The manager is kept across {@link leave} so a rejoin of this same instance - * stays encrypted: do not dispose it while this call may be joined again. - * A disposed manager throws from `encrypt`/`decrypt` rather than silently - * publishing nothing, so re-attach a fresh one instead of reusing it. + * The application owns the manager's lifetime - the SDK never disposes it, and + * closing the peer connections does not release it. Discard the manager + * together with this call instance once its flow has ended, and create a fresh + * pair for a later flow: a disposed manager throws from `encrypt`/`decrypt` + * rather than silently publishing nothing. * * @param e2ee - Any `E2EEManager`. Use `EncryptionManager.create()` for the * built-in AES-GCM scheme, or pass your own implementation. diff --git a/packages/client/src/__tests__/Call.ringingLifecycle.test.ts b/packages/client/src/__tests__/Call.ringingLifecycle.test.ts index 0ad8cb2472..619bdeebf8 100644 --- a/packages/client/src/__tests__/Call.ringingLifecycle.test.ts +++ b/packages/client/src/__tests__/Call.ringingLifecycle.test.ts @@ -13,10 +13,10 @@ import { generateUUIDv4 } from '../coordinator/connection/utils'; import { StreamVideoWriteableStateStore } from '../store'; /** - * The core's whole part in the React Native ringing lifecycle: hand a ringing - * join to the RN owner, tell it when the call ends, and stop a join that a - * `leave()` overtook. Setup, duplicate-trigger policy and release ordering are - * the RN runner's, and are tested there. + * 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) => { @@ -32,19 +32,18 @@ const createCall = (ringing: boolean) => { }; const install = (overrides: Record = {}) => { - const runJoin = vi.fn((_call: Call, proceed: () => Promise) => - proceed(), - ); + 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: { runJoin, onLeave }, + 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 { runJoin, onLeave, callingX }; + return { beforeJoin, onJoinFailed, onLeave, callingX }; }; describe('ringing call lifecycle integration', () => { @@ -57,36 +56,65 @@ describe('ringing call lifecycle integration', () => { vi.restoreAllMocks(); }); - it('routes a ringing join through the React Native owner', async () => { - const { runJoin } = install(); + 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(runJoin).toHaveBeenCalledTimes(1); + expect(beforeJoin).toHaveBeenCalledTimes(1); + expect(beforeJoin).toHaveBeenCalledBefore(callingX.joinCall); expect(doJoin).toHaveBeenCalledTimes(1); }); it('leaves ordinary calls to join themselves', async () => { - const { runJoin } = install(); + const { beforeJoin, onJoinFailed } = install(); const call = createCall(false); vi.spyOn(call as any, 'doJoin').mockResolvedValue(undefined); await call.join(); - expect(runJoin).not.toHaveBeenCalled(); + expect(beforeJoin).not.toHaveBeenCalled(); + expect(onJoinFailed).not.toHaveBeenCalled(); }); - it('does not join when the owner refuses', async () => { - const { runJoin } = install(); - runJoin.mockRejectedValue(new Error('no key')); + 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 () => { @@ -160,51 +188,36 @@ describe('ringing call lifecycle integration', () => { } }); - it('registers natively again on an ordinary rejoin of a reused instance', async () => { - const { callingX } = install(); - callingX.joinCall.mockResolvedValue(undefined); - const call = createCall(true); - vi.spyOn(call as any, 'doJoin').mockResolvedValue(undefined); - - await call.join(); - await call.leave({ reject: false }); - callingX.joinCall.mockClear(); - await call.join({ ring: true }); - - // the instance is still LEFT here - `setup()` only resets it afterwards - expect(callingX.joinCall).toHaveBeenCalledTimes(1); - }); - - it('F1: routes a reused LEFT instance through the owner when join says ring', async () => { - const { runJoin } = install(); - const call = createCall(false); // not ringing yet + 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 }); - // `options.ring` is what makes this a ringing call; reading `call.ringing` - // alone would skip setup entirely - expect(runJoin).toHaveBeenCalledTimes(1); + // reading `call.ringing` before applying `options.ring` would skip + // preparation entirely on this path + expect(beforeJoin).toHaveBeenCalledTimes(1); }); - it('F2: a leave awaiting its response still stops the delayed join', async () => { + it('stops a join that a leave overtook during preparation', async () => { let releaseHook: () => void = () => {}; - const { runJoin } = install(); - runJoin.mockImplementation( - async (_c: Call, proceed: () => Promise) => { - await new Promise((resolve) => (releaseHook = resolve)); - return proceed(); - }, + const { beforeJoin } = 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(runJoin).toHaveBeenCalled()); + await vi.waitFor(() => expect(beforeJoin).toHaveBeenCalled()); await call.leave({ reject: false }); releaseHook(); - await expect(joining).rejects.toThrow(/left while the pre-join setup/i); + await expect(joining).rejects.toThrow( + /left while the join was in progress/i, + ); expect(doJoin).not.toHaveBeenCalled(); expect(call.state.callingState).toBe(CallingState.LEFT); }); diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts index 6b07bdbdf0..c85bc6f247 100644 --- a/packages/client/src/types.ts +++ b/packages/client/src/types.ts @@ -459,8 +459,8 @@ type StreamRNVideoSDKEndCallReason = type StreamRNVideoSDKCallingX = { /** - * @param isCancelled - polled after any wait inside the bridge. Registration - * is skipped when it returns true. Supplied by the join attempt itself, + * @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. */ @@ -479,24 +479,25 @@ type StreamRNVideoSDKCallingX = { }; /** - * React Native's owner for a ringing call's join. + * 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 - the accept - * button joins internally, and an outgoing call joins itself once the callee - * answers - so there is no point at which the app holds the call and can still - * set it up. React Native therefore takes the whole operation: it runs the - * app's pre-join hook, decides what a duplicate or retried trigger does, and - * releases whatever it installed when the join fails. + * 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 one ringing join. `proceed` performs the actual join and must not be - * called before the app's setup hook has resolved. - * - * Rejects when setup fails or is refused, which fails the join closed rather - * than connecting with nothing installed. + * 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. */ - runJoin: (call: Call, proceed: () => Promise) => Promise; + onJoinFailed: (call: Call) => Promise; /** The call has ended; release whatever the pre-join hook installed. */ onLeave: (call: Call) => void; 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 6dfbc98bd0..4b19f80ac2 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 @@ -574,7 +574,6 @@ class CallService : Service(), CallRepository.Listener { CallRegistrationStore.removeTrackedCall(callInfo.callId) registeringCallIds.remove(callInfo.callId) } catch (e: Exception) { - // we dont remove the call from store Log.e(TAG, "[service] registerCall: Error registering call: ${e.message}") sendBroadcastEvent(CallingxModuleImpl.CALL_REGISTRATION_FAILED_ACTION) { 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..cc7ca9d872 --- /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().catch((e: Error) => e); + await tick(); + await call.leave(); + release(); + + await expect(joining).resolves.toThrow( + 'Call was left while the join was in progress', + ); + 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 index cf0d3b87f3..9021c4406f 100644 --- a/packages/react-native-sdk/__tests__/callingx/joinCallingxCall.test.ts +++ b/packages/react-native-sdk/__tests__/callingx/joinCallingxCall.test.ts @@ -8,14 +8,21 @@ import { CallingState } from '@stream-io/video-client'; * lifecycle's, and whichever cleanup runs next ends the wrong call. */ +const mockTracked = new Set(); const mockCallingxModule = { isSetup: true, isOngoingCallsEnabled: false, - isCallTracked: jest.fn(() => true), - displayIncomingCall: jest.fn().mockResolvedValue(undefined), + 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().mockResolvedValue(undefined), - startCall: 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', () => ({ @@ -39,7 +46,11 @@ const makeCall = (overrides: Partial = {}) => }) as any; describe('joinCallingxCall', () => { - beforeEach(() => jest.clearAllMocks()); + beforeEach(() => { + jest.clearAllMocks(); + mockTracked.clear(); + mockCallingxModule.isOngoingCallsEnabled = false; + }); it('registers an incoming call that is still wanted', async () => { const { @@ -81,5 +92,118 @@ describe('joinCallingxCall', () => { expect(other.leave).toHaveBeenCalled(); expect(mockCallingxModule.displayIncomingCall).not.toHaveBeenCalled(); expect(mockCallingxModule.answerIncomingCall).not.toHaveBeenCalled(); + // nothing was registered, so there is nothing to end either + expect(mockCallingxModule.endCallWithReason).not.toHaveBeenCalled(); + }); + + it('ends a registration that completed after its join was cancelled', async () => { + const { + joinCallingxCall, + } = require('../../src/utils/internal/callingx/callingx'); + const call = makeCall(); + let cancelled = false; + mockCallingxModule.displayIncomingCall.mockImplementationOnce( + async (cid: string) => { + // the user hangs up while the OS is still bringing the call up + cancelled = true; + mockTracked.add(cid); + }, + ); + + await joinCallingxCall(call, [], () => cancelled); + + expect(mockCallingxModule.endCallWithReason).toHaveBeenCalledWith( + 'default:target', + 'canceled', + ); + expect(mockTracked.has('default:target')).toBe(false); + }); + + it('leaves the currently active call before registering the accepted one', async () => { + const { + joinCallingxCall, + } = require('../../src/utils/internal/callingx/callingx'); + const accepted = makeCall(); + const active = makeCall({ cid: 'default:active' }); + + await joinCallingxCall(accepted, [active], () => false); + + // the SDK's standing behaviour: one active call, so A goes before B arrives + expect(active.leave).toHaveBeenCalledWith({ reason: 'cancel' }); + expect(mockCallingxModule.displayIncomingCall).toHaveBeenCalled(); + expect(mockTracked.has('default:target')).toBe(true); + // declining B never reaches this bridge at all, so A is untouched by it + }); + + it('ends an outgoing registration that outlived its join', async () => { + const { + joinCallingxCall, + } = require('../../src/utils/internal/callingx/callingx'); + const call = makeCall({ cid: 'default:outgoing', isCreatedByMe: true }); + let cancelled = false; + mockCallingxModule.startCall.mockImplementationOnce(async (cid: string) => { + cancelled = true; + mockTracked.add(cid); + }); + + await joinCallingxCall(call, [], () => cancelled); + + expect(mockCallingxModule.endCallWithReason).toHaveBeenCalledWith( + 'default:outgoing', + 'canceled', + ); + expect(mockTracked.has('default:outgoing')).toBe(false); + }); + + it('ends a partial registration whose answer rejected after cancellation', async () => { + const { + joinCallingxCall, + } = require('../../src/utils/internal/callingx/callingx'); + const call = makeCall(); + let cancelled = false; + mockCallingxModule.displayIncomingCall.mockImplementationOnce( + async (cid: string) => { + mockTracked.add(cid); + }, + ); + mockCallingxModule.answerIncomingCall.mockImplementationOnce(async () => { + cancelled = true; + throw new Error('answer failed'); + }); + + await joinCallingxCall(call, [], () => cancelled); + + // the call was displayed, so something is tracked even though answer threw + expect(mockCallingxModule.endCallWithReason).toHaveBeenCalledWith( + 'default:target', + 'canceled', + ); + expect(mockTracked.has('default:target')).toBe(false); + }); + + it('ends an ordinary ongoing registration that outlived its join', async () => { + mockCallingxModule.isOngoingCallsEnabled = true; + const { + joinCallingxCall, + } = require('../../src/utils/internal/callingx/callingx'); + const call = makeCall({ + cid: 'default:ongoing', + ringing: false, + state: { callingState: CallingState.IDLE, members: [] }, + }); + let cancelled = false; + mockCallingxModule.startCall.mockImplementationOnce(async (cid: string) => { + cancelled = true; + mockTracked.add(cid); + }); + + await joinCallingxCall(call, [], () => cancelled); + + expect(mockCallingxModule.startCall).toHaveBeenCalled(); + expect(mockCallingxModule.endCallWithReason).toHaveBeenCalledWith( + 'default:ongoing', + 'canceled', + ); + expect(mockTracked.has('default:ongoing')).toBe(false); }); }); diff --git a/packages/react-native-sdk/__tests__/dogfood/meetingUiLifecycle.test.tsx b/packages/react-native-sdk/__tests__/dogfood/meetingUiLifecycle.test.tsx new file mode 100644 index 0000000000..8643ff239a --- /dev/null +++ b/packages/react-native-sdk/__tests__/dogfood/meetingUiLifecycle.test.tsx @@ -0,0 +1,509 @@ +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 { 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(), +}); + +const renderMeeting = (nav = navigation()) => { + const view = render( + , + ); + 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( + , + ); +}; + +/** + * 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__/push/ringingJoin.test.ts b/packages/react-native-sdk/__tests__/push/ringingJoin.test.ts index 4e0284f723..a05287237d 100644 --- a/packages/react-native-sdk/__tests__/push/ringingJoin.test.ts +++ b/packages/react-native-sdk/__tests__/push/ringingJoin.test.ts @@ -1,27 +1,27 @@ import { BehaviorSubject } from 'rxjs'; import { CallingState } from '@stream-io/video-client'; import { + beforeJoin, + onJoinFailed, onLeave, - RingingJoinBusyError, - runJoin, setRingingCallLifecycleHooks, } from '../../src/utils/internal/ringingCallLifecycle'; /** - * React Native owns a ringing call's join, because the SDK performs it rather - * than the app: the accept button joins internally and an outgoing call joins - * itself once the callee answers. These cover that ownership - the hook runs - * before the join, a failure never joins, duplicates do not double-register, and - * only one attempt is ever in flight for a call. + * 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 = (cid = 'default:ringing-test') => +const createFakeCall = (state = CallingState.RINGING) => ({ - cid, + cid: 'default:ringing-test', ringing: true, + leave: jest.fn().mockResolvedValue(undefined), state: { - callingState: CallingState.RINGING, - callingState$: new BehaviorSubject(CallingState.RINGING), + callingState: state, + callingState$: new BehaviorSubject(state), }, }) as any; @@ -35,8 +35,12 @@ afterEach(() => { jest.restoreAllMocks(); }); -describe('runJoin', () => { - it('awaits the hook before the join proceeds', async () => { +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 () => { @@ -44,117 +48,93 @@ describe('runJoin', () => { }), }); - await runJoin(createFakeCall(), async () => { - order.push('join'); - }); - - expect(order).toEqual(['hook', 'join']); + const pending = beforeJoin(createFakeCall()); + expect(order).toEqual(['hook']); + await pending; }); - it('joins normally when no hooks are registered', async () => { - const proceed = jest.fn().mockResolvedValue(undefined); - await runJoin(createFakeCall(), proceed); - expect(proceed).toHaveBeenCalledTimes(1); - }); - - it('does not join when the hook rejects, and releases what it installed', async () => { - const onAfterCallLeave = jest.fn(); + it('rejects when the hook rejects', async () => { setRingingCallLifecycleHooks({ onBeforeCallJoin: jest.fn().mockRejectedValue(new Error('no key')), - onAfterCallLeave, }); - const proceed = jest.fn(); - const call = createFakeCall(); - - await expect(runJoin(call, proceed)).rejects.toThrow('no key'); - await flush(); - expect(proceed).not.toHaveBeenCalled(); - expect(onAfterCallLeave).toHaveBeenCalledWith(call); + await expect(beforeJoin(createFakeCall())).rejects.toThrow('no key'); }); - it('does not join when the hook throws synchronously', async () => { + it('rejects when the hook throws synchronously', async () => { setRingingCallLifecycleHooks({ onBeforeCallJoin: jest.fn(() => { throw new Error('sync boom'); }), }); - const proceed = jest.fn(); - await expect(runJoin(createFakeCall(), proceed)).rejects.toThrow( - 'sync boom', - ); - expect(proceed).not.toHaveBeenCalled(); + await expect(beforeJoin(createFakeCall())).rejects.toThrow('sync boom'); }); - it('coalesces a duplicate trigger onto the live attempt', async () => { - let finishHook: () => void = () => {}; - const onBeforeCallJoin = jest.fn( - () => new Promise((resolve) => (finishHook = resolve)), - ); - setRingingCallLifecycleHooks({ onBeforeCallJoin }); - const proceed = jest.fn().mockResolvedValue(undefined); - const call = createFakeCall(); + it('gives up on a hook that outruns its deadline', async () => { + jest.useFakeTimers(); + setRingingCallLifecycleHooks({ + onBeforeCallJoin: jest.fn(() => new Promise(() => {})), + }); - // a push acceptance racing an in-app tap - const first = runJoin(call, proceed); - const second = runJoin(call, proceed); - finishHook(); - await Promise.all([first, second]); + const pending = beforeJoin(createFakeCall()).catch((e: Error) => e.message); + jest.advanceTimersByTime(5_000); - expect(onBeforeCallJoin).toHaveBeenCalledTimes(1); - expect(proceed).toHaveBeenCalledTimes(1); + await expect(pending).resolves.toContain('did not settle within 5000ms'); }); +}); - it('refuses a retry while a cancelled attempt is still settling', async () => { - let finishHook: () => void = () => {}; +describe('release pairing', () => { + it('releases on leave what the hook installed', async () => { + const onAfterCallLeave = jest.fn(); setRingingCallLifecycleHooks({ - onBeforeCallJoin: jest.fn( - () => new Promise((resolve) => (finishHook = resolve)), - ), - onAfterCallLeave: jest.fn(), + onBeforeCallJoin: jest.fn().mockResolvedValue(undefined), + onAfterCallLeave, }); const call = createFakeCall(); - const cancelled = runJoin(call, jest.fn()); + await beforeJoin(call); onLeave(call); + await flush(); - await expect(runJoin(call, jest.fn())).rejects.toBeInstanceOf( - RingingJoinBusyError, - ); - - finishHook(); - await cancelled.catch(() => {}); + expect(onAfterCallLeave).toHaveBeenCalledWith(call); }); - it('allows a retry once the cancelled attempt has settled', async () => { - let finishHook: () => void = () => {}; - const onBeforeCallJoin = jest - .fn() - .mockImplementationOnce( - () => new Promise((resolve) => (finishHook = resolve)), - ) - .mockResolvedValue(undefined); + it('releases even when the hook rejected, because it may have installed something', async () => { + const onAfterCallLeave = jest.fn(); setRingingCallLifecycleHooks({ - onBeforeCallJoin, - onAfterCallLeave: jest.fn(), + onBeforeCallJoin: jest.fn().mockRejectedValue(new Error('half done')), + onAfterCallLeave, }); const call = createFakeCall(); - const cancelled = runJoin(call, jest.fn()); + await beforeJoin(call).catch(() => {}); onLeave(call); - finishHook(); - await cancelled.catch(() => {}); await flush(); - const proceed = jest.fn().mockResolvedValue(undefined); - await runJoin(call, proceed); + 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); - expect(onBeforeCallJoin).toHaveBeenCalledTimes(2); - expect(proceed).toHaveBeenCalledTimes(1); + await beforeJoin(call); + await onJoinFailed(call); + await flush(); + expect(onAfterCallLeave).toHaveBeenCalledTimes(1); + + onLeave(call); + await flush(); + expect(onAfterCallLeave).toHaveBeenCalledTimes(1); }); - it('releases a hook that completes after its deadline', async () => { + it('waits for a timed-out hook to settle before releasing', async () => { jest.useFakeTimers(); let finishHook: () => void = () => {}; const onAfterCallLeave = jest.fn(); @@ -164,64 +144,132 @@ describe('runJoin', () => { ), onAfterCallLeave, }); - const call = createFakeCall(); + const call = createFakeCall(CallingState.LEFT); - const timedOut = runJoin(call, jest.fn()).catch(() => 'timed-out'); + const timedOut = beforeJoin(call).catch(() => 'timed-out'); jest.advanceTimersByTime(5_000); await expect(timedOut).resolves.toBe('timed-out'); - // the hook is still running and may yet install something + // 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('keeps retries unavailable while an unsettled hook is still running', async () => { - jest.useFakeTimers(); + it('releases nothing for a call whose setup never ran', () => { + const onAfterCallLeave = jest.fn(); setRingingCallLifecycleHooks({ - // documented limitation: a hook that never settles cannot be cancelled, so - // this lifecycle stays busy rather than allowing an overlapping takeover - onBeforeCallJoin: jest.fn(() => new Promise(() => {})), - onAfterCallLeave: jest.fn(), + onBeforeCallJoin: jest.fn().mockResolvedValue(undefined), + onAfterCallLeave, }); - const call = createFakeCall(); - const stuck = runJoin(call, jest.fn()).catch(() => 'timed-out'); - jest.advanceTimersByTime(5_000); - await stuck; - onLeave(call); + onLeave(createFakeCall()); - await expect(runJoin(call, jest.fn())).rejects.toBeInstanceOf( - RingingJoinBusyError, - ); + expect(onAfterCallLeave).not.toHaveBeenCalled(); }); -}); -describe('onLeave', () => { - it('releases a call that already finished joining', async () => { - const onAfterCallLeave = jest.fn(); + 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: jest.fn().mockResolvedValue(undefined), - onAfterCallLeave, + onBeforeCallJoin, + onAfterCallLeave: (call) => { + released.push(call.cid); + }, }); - const call = createFakeCall(); - - await runJoin(call, jest.fn().mockResolvedValue(undefined)); + 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(); - onLeave(call); - expect(onAfterCallLeave).toHaveBeenCalledWith(call); + expect(released).toEqual(['default:a']); }); - it('swallows a synchronous throw from the release hook', () => { + 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')); - expect(() => onLeave(createFakeCall())).not.toThrow(); + 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 index 71023d1202..861f807b1b 100644 --- a/packages/react-native-sdk/__tests__/push/ringingJoinIntegration.test.ts +++ b/packages/react-native-sdk/__tests__/push/ringingJoinIntegration.test.ts @@ -1,44 +1,71 @@ -import { CallingState, StreamVideoClient } from '@stream-io/video-client'; -import { endCallingxCall } from '../../src/utils/internal/callingx/callingx'; +import { Call, CallingState, StreamVideoClient } from '@stream-io/video-client'; import { + beforeJoin, + onJoinFailed, onLeave, - RingingJoinBusyError, - runJoin, setRingingCallLifecycleHooks, } from '../../src/utils/internal/ringingCallLifecycle'; /** - * Drives a real `Call` against the real runner through the globals bridge. + * 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. * - * The unit suites either stub the owner inside core or call the runner with a - * fake call; between them they miss the dispatch, the timing, and what happens - * to a call that already joined. These cover that seam. + * One `Call` is one call flow here, as the public contract says: nothing below + * rejoins an instance it has left. */ -jest.mock('../../src/utils/internal/callingx/callingx', () => ({ - ...jest.requireActual('../../src/utils/internal/callingx/callingx'), - endCallingxCall: jest.fn().mockResolvedValue(undefined), -})); - const endCall = jest.fn().mockResolvedValue(undefined); const joinCall = jest.fn().mockResolvedValue(undefined); -const createCall = () => { - const client = new StreamVideoClient({ apiKey: 'abc' }); - const call = client.call( - 'test', - `int-${Math.random().toString(36).slice(2)}`, - ); +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 as any).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(call as any, 'doJoin').mockResolvedValue(undefined); + 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: { runJoin, onLeave }, + ringingCallLifecycle: { beforeJoin, onJoinFailed, onLeave }, callingX: { joinCall, endCall }, callManager: { setup: jest.fn(), start: jest.fn(), stop: jest.fn() }, }; @@ -47,102 +74,218 @@ beforeEach(() => { afterEach(() => { setRingingCallLifecycleHooks({}); (globalThis as any).streamRNVideoSDK = undefined; + // the timeout test installs fake timers, which also fake `setImmediate` + jest.useRealTimers(); }); -describe('ringing join, core to runner', () => { - it('F1: a reused instance joined with ring:true still runs setup', async () => { - const onBeforeCallJoin = jest.fn().mockResolvedValue(undefined); - setRingingCallLifecycleHooks({ onBeforeCallJoin }); - const call = createCall(); +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({ ring: true }); + await call.join(joinOptions(kind)); - expect(onBeforeCallJoin).toHaveBeenCalledWith(call); - }); + expect(order).toEqual(['setup', 'native', 'call-setup']); + }, + ); - it('F1: a rejecting hook prevents the join', async () => { - setRingingCallLifecycleHooks({ - onBeforeCallJoin: jest.fn().mockRejectedValue(new Error('no key')), - }); - const call = createCall(); + 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]); - await expect(call.join({ ring: true })).rejects.toThrow('no key'); - expect(call.state.callingState).not.toBe(CallingState.JOINED); + expect(onBeforeCallJoin).toHaveBeenCalledTimes(1); + expect(joinCall).toHaveBeenCalledTimes(1); + expect((call as any).doJoin).toHaveBeenCalledTimes(1); }); - it('F3/F5: a duplicate after success reruns no setup and releases nothing', async () => { + 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(); + const call = createCall('incoming'); - await call.join({ ring: true }); - await call.join({ ring: true }).catch(() => {}); + 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); }); +}); - it('F5: declining a call that never joined releases nothing', async () => { - const onAfterCallLeave = jest.fn(); +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().mockResolvedValue(undefined), - onAfterCallLeave, + onBeforeCallJoin: jest.fn().mockRejectedValue(new Error('no key')), }); + const call = createCall('incoming'); - await createCall().leave({ reject: true }); + await call.join().catch(() => {}); - expect(onAfterCallLeave).not.toHaveBeenCalled(); + // core never registered it - the push path reported the accept already + expect(endCall).toHaveBeenCalledWith(call, 'error'); }); - it('F5: a failed setup followed by leave releases exactly once', async () => { + 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().mockRejectedValue(new Error('no key')), + onBeforeCallJoin: jest.fn( + () => new Promise((resolve) => (finishHook = resolve)), + ), onAfterCallLeave, }); - const call = createCall(); + const call = createCall('incoming'); - await call.join({ ring: true }).catch(() => {}); - await call.leave(); + 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('F4: a rejecting hook ends the already reported native call', async () => { + 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(); + const call = createCall('incoming'); - await call.join({ ring: true }).catch(() => {}); + await call.join().catch(() => {}); + await flush(); - // core never registered it - the push path reported the accept already - expect(endCallingxCall).toHaveBeenCalled(); + expect(onAfterCallLeave).toHaveBeenCalledTimes(1); }); +}); - it('refuses a retry while a cancelled attempt is still settling', async () => { +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: jest.fn(), + onAfterCallLeave, }); - const call = createCall(); + const call = createCall('incoming'); - const cancelled = call.join({ ring: true }).catch(() => 'cancelled'); - await new Promise((r) => setImmediate(r)); + const joining = call.join().catch((e: Error) => e.message); + await tick(); await call.leave({ reject: false }); - await expect(call.join({ ring: true })).rejects.toBeInstanceOf( - RingingJoinBusyError, + finishHook(); + await expect(joining).resolves.toContain( + 'Call was left while the join was in progress', ); + await flush(); - finishHook(); - await cancelled; + 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/src/modules/encryption/EncryptionManager.ts b/packages/react-native-sdk/src/modules/encryption/EncryptionManager.ts index 77e5f55179..6cd1161be5 100644 --- a/packages/react-native-sdk/src/modules/encryption/EncryptionManager.ts +++ b/packages/react-native-sdk/src/modules/encryption/EncryptionManager.ts @@ -290,9 +290,9 @@ export class EncryptionManager implements E2EEManager { * **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 once the call object - * is done with, and attach a fresh manager rather than reusing this one when - * re-joining (see `Call.setE2EEManager`). + * 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. * diff --git a/packages/react-native-sdk/src/utils/StreamVideoRN/index.ts b/packages/react-native-sdk/src/utils/StreamVideoRN/index.ts index 82d6362470..fa568d31bb 100644 --- a/packages/react-native-sdk/src/utils/StreamVideoRN/index.ts +++ b/packages/react-native-sdk/src/utils/StreamVideoRN/index.ts @@ -156,9 +156,9 @@ export class StreamVideoRN { * onAfterCallLeave: (call) => disposeE2EEManager(call), * }); */ - static setRingingCallLifecycleHooks(hooks: RingingCallLifecycleHooks) { + 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 6ff3635dc8..a7e2f42c03 100644 --- a/packages/react-native-sdk/src/utils/StreamVideoRN/types.ts +++ b/packages/react-native-sdk/src/utils/StreamVideoRN/types.ts @@ -45,6 +45,8 @@ export type RingingCallLifecycleHooks = { * 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 @@ -58,11 +60,9 @@ export type RingingCallLifecycleHooks = { */ onBeforeCallJoin?: (call: Call) => Promise; /** - * Called once, when a ringing call is finished with: normally when it leaves, and - * also when a join fails after {@link onBeforeCallJoin} has already run - - * otherwise whatever that hook installed would never be released. A call that - * rings and is then rejected, cancelled or missed never joins, so it was never - * set up and there is nothing here to release. + * 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 @@ -70,9 +70,16 @@ export type RingingCallLifecycleHooks = { * 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. - * Registered on its own it has no such pairing to honour and is called for every - * ringing call that ends. + * 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. 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 7bf401f24c..cedeaece26 100644 --- a/packages/react-native-sdk/src/utils/internal/callingx/callingx.ts +++ b/packages/react-native-sdk/src/utils/internal/callingx/callingx.ts @@ -166,6 +166,9 @@ export async function joinCallingxCall( 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); @@ -175,6 +178,19 @@ export async function joinCallingxCall( `startCallingxCall: Error starting call in callingx: ${call.cid} isIncoming: ${isIncomingCall} isOutgoing: ${isOutcomingCall}`, error, ); + } finally { + // The registration above can outlive the join that asked for it: `leave()` + // may land while native is still bringing the call up, and by the time the + // caller aborts, the call exists natively with nobody to end it. Ringing + // calls have a lifecycle owner that would clean up; ordinary ones do not. + // Nothing was registered when the check above already skipped it, and + // `endCallingxCall` no-ops for an untracked cid, so this is safe either way. + if (isCancelled?.()) { + logger.debug( + `joinCallingxCall: ending ${call.cid}: join was cancelled while registering`, + ); + await endCallingxCall(call, 'canceled'); + } } } diff --git a/packages/react-native-sdk/src/utils/internal/registerSDKGlobals.ts b/packages/react-native-sdk/src/utils/internal/registerSDKGlobals.ts index 7fe6e0da4a..ede2b75a32 100644 --- a/packages/react-native-sdk/src/utils/internal/registerSDKGlobals.ts +++ b/packages/react-native-sdk/src/utils/internal/registerSDKGlobals.ts @@ -16,7 +16,7 @@ import { unwireAudioEngineSubscription, } from './callingx/callingx'; import { registerCallMediaEngine } from './registerMediaEngine'; -import { onLeave, runJoin } from './ringingCallLifecycle'; +import { beforeJoin, onJoinFailed, onLeave } from './ringingCallLifecycle'; import { callManager as publicCallManager } from '../../modules/call-manager'; const StreamInCallManagerNativeModule = NativeModules.StreamInCallManager; @@ -92,7 +92,7 @@ const streamRNVideoSDKGlobals: StreamRNVideoSDKGlobals = { wireAudioEngineSubscription: wireAudioEngineSubscription, unwireAudioEngineSubscription: unwireAudioEngineSubscription, }, - ringingCallLifecycle: { runJoin, onLeave }, + 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 index 8599a180f9..860cec4328 100644 --- a/packages/react-native-sdk/src/utils/internal/ringingCallLifecycle.ts +++ b/packages/react-native-sdk/src/utils/internal/ringingCallLifecycle.ts @@ -1,6 +1,9 @@ -import { videoLoggerSystem, type Call } from '@stream-io/video-client'; +import { + CallingState, + videoLoggerSystem, + type Call, +} from '@stream-io/video-client'; import type { RingingCallLifecycleHooks } from '../StreamVideoRN/types'; -import { endCallingxCall } from './callingx/callingx'; const logger = videoLoggerSystem.getLogger('ringingCallLifecycle'); @@ -14,19 +17,6 @@ const logger = videoLoggerSystem.getLogger('ringingCallLifecycle'); */ const ON_BEFORE_CALL_JOIN_TIMEOUT_MS = 5_000; -/** - * Thrown when a ringing call is asked to join again while its previous attempt - * is still settling. Callers may retry once that attempt finishes. - */ -export class RingingJoinBusyError extends Error { - constructor(cid: string) { - super( - `A previous ringing join for ${cid} is still settling; retry once it finishes.`, - ); - this.name = 'RingingJoinBusyError'; - } -} - /** * The registered hooks. * @@ -42,32 +32,15 @@ export const setRingingCallLifecycleHooks = ( hooks = next; }; -export const getRingingCallLifecycleHooks = () => hooks; - /** - * The one join a ringing call may have in progress. - * - * Only one exists per call at a time, which is what keeps the rest of this file - * small: with no overlapping attempt there is never a second owner competing for - * the same manager or the same native cid, so cleanup needs no ownership test. + * How to release what a call's setup hook installed. * - * `hookSettled` is tracked apart from `operation` because the timeout can end - * the caller's wait while the app's hook is still running. The hook cannot be - * cancelled, so this entry outlives the join until it settles - and until then - * a retry is refused rather than being allowed to race it. + * 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. */ -type RingingJoin = { - operation: Promise; - hookSettled: boolean; - operationDone: boolean; - /** No further join may start until this one settles and is cleaned up. */ - closed: boolean; - /** Setup ran, so a release is owed once the lifecycle ends. */ - setupRan: boolean; - releaseOwed: boolean; -}; - -const joins = new WeakMap(); +const cleanups = new WeakMap void>(); /** Invokes the release hook, swallowing whatever it throws. */ const fireRelease = (call: Call): void => { @@ -82,34 +55,40 @@ const fireRelease = (call: Call): void => { } }; +/** 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 hook, bounded by the deadline. + * Runs the app's pre-join setup, bounded by the deadline. * - * Resolves the *hook's* own promise separately from the bounded wait, so a late - * completion still has an owner: whatever it installs is released by - * {@link settle} rather than stranded. + * Rejecting fails the join closed - joining without whatever this installs would + * publish unencrypted media on a call the user believes is private. */ -const runHook = (call: Call, entry: RingingJoin): Promise => { +export const beforeJoin = (call: Call): Promise => { const hook = hooks?.onBeforeCallJoin; - if (!hook) { - entry.hookSettled = true; - return Promise.resolve(); - } + 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 (e) { - started = Promise.reject(e); + } catch (error) { + started = Promise.reject(error); } - const settled = started - .catch(() => {}) - .then(() => { - entry.hookSettled = true; - settle(call, entry); - }); + // 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) => { @@ -124,111 +103,46 @@ const runHook = (call: Call, entry: RingingJoin): Promise => { ); }); - return Promise.race([started, expiry]).finally(() => { - clearTimeout(timeout); - void settled; - }); + return Promise.race([started, expiry]).finally(() => clearTimeout(timeout)); }; /** - * Retires the join once both the operation and the app's hook have finished, - * releasing anything the hook installed if the join did not succeed. + * The join failed and will not be retried on this call. * - * Both conditions matter: retiring early would let a retry start while the old - * hook is still working, which is the overlap this design exists to avoid. + * 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. */ -const settle = (call: Call, entry: RingingJoin): void => { - if (!entry.hookSettled || !entry.operationDone) return; - // A join that succeeded keeps its record: it owns the live call's setup until - // something actually ends the call, and a duplicate trigger must find it here - // rather than starting a second setup that would dispose the live manager. - if (!entry.releaseOwed) return; - if (joins.get(call) === entry) joins.delete(call); - entry.releaseOwed = false; - if (entry.setupRan) fireRelease(call); -}; - -/** - * Runs one ringing join: the app's setup hook, then the join itself. - * - * A second trigger for the same live attempt joins it rather than starting - * another, so a double tap or a push racing an in-app accept cannot produce two - * setups or two native registrations. A trigger arriving after cancellation is - * refused with {@link RingingJoinBusyError} until the previous attempt settles; - * it does not queue, and it does not run alongside. - */ -export const runJoin = async ( - call: Call, - proceed: () => Promise, -): Promise => { - const existing = joins.get(call); - if (existing) { - // Closed means the previous attempt is cancelled, or failed with its hook - // still running. Either way it may still be holding the manager or the - // native registration, so a retry is refused rather than run alongside. - if (existing.closed) throw new RingingJoinBusyError(call.cid); - // Otherwise this is a duplicate trigger for a live or already-successful - // join: hand back the same operation. Never a second hook, and never a - // release that would dispose the manager the call is still using. - return existing.operation; - } - - const entry: RingingJoin = { - operation: undefined as unknown as Promise, - hookSettled: false, - operationDone: false, - closed: false, - setupRan: false, - releaseOwed: false, - }; - joins.set(call, entry); - - entry.operation = (async () => { +export const onJoinFailed = async (call: Call): Promise => { + if (call.state.callingState !== CallingState.LEFT) { try { - if (hooks?.onBeforeCallJoin) entry.setupRan = true; - await runHook(call, entry); - await proceed(); + // 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) { - // Fail closed. The native side may already be showing this call as - // answered - the push path reports the accept before the join - so end it - // rather than leaving it on screen with nothing behind it. - await endCallingxCall(call, 'error').catch(() => {}); - if (entry.hookSettled) { - // Nothing else can still be installed: release now and let a retry start. - entry.releaseOwed = true; - } else { - // The hook is still running and may yet install something. Hold the - // lifecycle closed until it settles, then release. - entry.closed = true; - entry.releaseOwed = true; - } - throw error; - } finally { - entry.operationDone = true; - settle(call, entry); + logger.warn(`failed to leave after a failed join: ${call.cid}`, error); } - })(); - - return entry.operation; + } + // 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. * - * Marks any attempt still in flight as cancelled, so a retry is refused until it - * finishes rather than racing it, and releases what the hook installed once it - * has settled. + * 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 => { - const entry = joins.get(call); - if (!entry) { - // No join ever ran for this call - a ringing call declined without being - // accepted, say. With a setup hook configured there is nothing paired to - // release; a release-only registration is still called, as documented. - if (!hooks?.onBeforeCallJoin) fireRelease(call); + // 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; } - entry.closed = true; - entry.releaseOwed = true; - settle(call, entry); + requestRelease(call); }; diff --git a/sample-apps/react-native/dogfood/src/components/CallErrorComponent.tsx b/sample-apps/react-native/dogfood/src/components/CallErrorComponent.tsx index 788141be12..1b50d49d21 100644 --- a/sample-apps/react-native/dogfood/src/components/CallErrorComponent.tsx +++ b/sample-apps/react-native/dogfood/src/components/CallErrorComponent.tsx @@ -8,7 +8,8 @@ type Props = { title: string; message: string; returnToHomeHandler: () => void; - backToLobbyHandler: () => void; + /** Omit when the call this error belongs to must not be joined again. */ + backToLobbyHandler?: () => void; }; export const CallErrorComponent = ({ @@ -23,11 +24,13 @@ export const CallErrorComponent = ({ {title} {message}