@stream-io/video-client 1.57.0 → 1.59.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/e2ee-worker.browser.es.js +1342 -0
  3. package/dist/e2ee-worker.browser.es.js.map +1 -0
  4. package/dist/index.browser.es.js +1141 -117
  5. package/dist/index.browser.es.js.map +1 -1
  6. package/dist/index.cjs.js +1196 -115
  7. package/dist/index.cjs.js.map +1 -1
  8. package/dist/index.d.ts +5 -0
  9. package/dist/index.es.js +1174 -117
  10. package/dist/index.es.js.map +1 -1
  11. package/dist/src/Call.d.ts +66 -9
  12. package/dist/src/StreamVideoClient.d.ts +9 -1
  13. package/dist/src/coordinator/connection/connection.d.ts +1 -1
  14. package/dist/src/devices/CameraManager.d.ts +8 -1
  15. package/dist/src/devices/DeviceManager.d.ts +2 -1
  16. package/dist/src/devices/MicrophoneManager.d.ts +8 -1
  17. package/dist/src/devices/SpeakerManager.d.ts +2 -1
  18. package/dist/src/events/participant.d.ts +10 -3
  19. package/dist/src/gen/coordinator/index.d.ts +1386 -240
  20. package/dist/src/gen/video/sfu/models/models.d.ts +8 -0
  21. package/dist/src/helpers/MediaStreamRecorder.d.ts +26 -0
  22. package/dist/src/helpers/TypedEventEmitter.d.ts +23 -0
  23. package/dist/src/helpers/concurrency.d.ts +8 -0
  24. package/dist/src/helpers/ensureExhausted.d.ts +1 -1
  25. package/dist/src/helpers/loopback.d.ts +55 -0
  26. package/dist/src/permissions/PermissionsContext.d.ts +2 -2
  27. package/dist/src/reporting/ClientEventReporter.d.ts +0 -2
  28. package/dist/src/rtc/BasePeerConnection.d.ts +3 -1
  29. package/dist/src/rtc/Subscriber.d.ts +5 -0
  30. package/dist/src/rtc/e2ee/E2EEManager.d.ts +12 -0
  31. package/dist/src/rtc/e2ee/EncryptionManager.d.ts +148 -0
  32. package/dist/src/rtc/e2ee/e2ee-worker.d.ts +25 -0
  33. package/dist/src/rtc/e2ee/events.d.ts +163 -0
  34. package/dist/src/rtc/e2ee/transformSupport.d.ts +21 -0
  35. package/dist/src/rtc/helpers/rtcConfiguration.d.ts +2 -2
  36. package/dist/src/rtc/index.d.ts +1 -0
  37. package/dist/src/rtc/mediaEngine.d.ts +23 -0
  38. package/dist/src/rtc/types.d.ts +2 -0
  39. package/dist/src/store/CallState.d.ts +21 -0
  40. package/dist/src/types.d.ts +15 -6
  41. package/index.ts +5 -0
  42. package/package.json +1 -1
  43. package/src/Call.ts +311 -155
  44. package/src/StreamVideoClient.ts +35 -8
  45. package/src/__tests__/Call.lifecycle.test.ts +33 -2
  46. package/src/__tests__/Call.publishing.test.ts +42 -1
  47. package/src/__tests__/Call.test.ts +15 -0
  48. package/src/__tests__/StreamVideoClient.api.test.ts +33 -0
  49. package/src/__tests__/StreamVideoClient.rewatch.test.ts +213 -0
  50. package/src/__tests__/StreamVideoClient.ringing.test.ts +11 -4
  51. package/src/__tests__/StreamVideoClient.test.ts +14 -12
  52. package/src/coordinator/connection/connection.ts +6 -3
  53. package/src/devices/AudioDeviceManager.ts +6 -0
  54. package/src/devices/CameraManager.ts +67 -8
  55. package/src/devices/DeviceManager.ts +24 -4
  56. package/src/devices/MicrophoneManager.ts +66 -8
  57. package/src/devices/ScreenShareManager.ts +3 -0
  58. package/src/devices/SpeakerManager.ts +42 -12
  59. package/src/devices/__tests__/CameraManager.test.ts +27 -1
  60. package/src/devices/__tests__/DeviceManager.test.ts +49 -1
  61. package/src/devices/__tests__/MicrophoneManager.test.ts +23 -1
  62. package/src/devices/__tests__/MicrophoneManagerRN.test.ts +1 -20
  63. package/src/devices/__tests__/SpeakerManager.test.ts +138 -42
  64. package/src/devices/__tests__/mocks.ts +36 -3
  65. package/src/events/__tests__/participant.test.ts +134 -1
  66. package/src/events/call.ts +6 -3
  67. package/src/events/callEventHandlers.ts +9 -3
  68. package/src/events/participant.ts +42 -7
  69. package/src/gen/coordinator/index.ts +1402 -236
  70. package/src/gen/video/sfu/models/models.ts +15 -0
  71. package/src/helpers/MediaStreamRecorder.ts +104 -0
  72. package/src/helpers/TypedEventEmitter.ts +71 -0
  73. package/src/helpers/__tests__/MediaStreamRecorder.test.ts +324 -0
  74. package/src/helpers/__tests__/TypedEventEmitter.test.ts +133 -0
  75. package/src/helpers/__tests__/concurrency.test.ts +74 -0
  76. package/src/helpers/__tests__/loopback.test.ts +367 -0
  77. package/src/helpers/concurrency.ts +24 -0
  78. package/src/helpers/ensureExhausted.ts +4 -2
  79. package/src/helpers/loopback.ts +200 -0
  80. package/src/permissions/PermissionsContext.ts +10 -5
  81. package/src/reporting/ClientEventReporter.ts +15 -28
  82. package/src/reporting/__tests__/ClientEventReporter.test.ts +3 -7
  83. package/src/rtc/BasePeerConnection.ts +12 -1
  84. package/src/rtc/Publisher.ts +8 -0
  85. package/src/rtc/Subscriber.ts +13 -0
  86. package/src/rtc/__tests__/Publisher.test.ts +101 -0
  87. package/src/rtc/__tests__/Subscriber.test.ts +190 -0
  88. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +31 -0
  89. package/src/rtc/e2ee/E2EEManager.ts +12 -0
  90. package/src/rtc/e2ee/EncryptionManager.ts +336 -0
  91. package/src/rtc/e2ee/SPEC.md +586 -0
  92. package/src/rtc/e2ee/__tests__/E2EEManager.contract.test.ts +39 -0
  93. package/src/rtc/e2ee/__tests__/EncryptionManager.test.ts +490 -0
  94. package/src/rtc/e2ee/__tests__/codec.test.ts +247 -0
  95. package/src/rtc/e2ee/__tests__/conformance.test.ts +184 -0
  96. package/src/rtc/e2ee/__tests__/failureTracker.test.ts +45 -0
  97. package/src/rtc/e2ee/__tests__/frameCounter.test.ts +90 -0
  98. package/src/rtc/e2ee/__tests__/keyStore.test.ts +221 -0
  99. package/src/rtc/e2ee/__tests__/notifications.test.ts +166 -0
  100. package/src/rtc/e2ee/__tests__/perf.test.ts +139 -0
  101. package/src/rtc/e2ee/__tests__/queue.test.ts +61 -0
  102. package/src/rtc/e2ee/__tests__/replayWindow.test.ts +133 -0
  103. package/src/rtc/e2ee/__tests__/trailer.test.ts +143 -0
  104. package/src/rtc/e2ee/__tests__/transform-pipeline.test.ts +839 -0
  105. package/src/rtc/e2ee/__tests__/transformSupport.test.ts +65 -0
  106. package/src/rtc/e2ee/e2ee-worker/codec.ts +220 -0
  107. package/src/rtc/e2ee/e2ee-worker/constants.ts +47 -0
  108. package/src/rtc/e2ee/e2ee-worker/decode.ts +159 -0
  109. package/src/rtc/e2ee/e2ee-worker/e2ee-worker-impl.ts +137 -0
  110. package/src/rtc/e2ee/e2ee-worker/encode.ts +147 -0
  111. package/src/rtc/e2ee/e2ee-worker/failureTracker.ts +26 -0
  112. package/src/rtc/e2ee/e2ee-worker/frameCounter.ts +37 -0
  113. package/src/rtc/e2ee/e2ee-worker/keyStore.ts +211 -0
  114. package/src/rtc/e2ee/e2ee-worker/notifications.ts +190 -0
  115. package/src/rtc/e2ee/e2ee-worker/perf.ts +129 -0
  116. package/src/rtc/e2ee/e2ee-worker/queue.ts +12 -0
  117. package/src/rtc/e2ee/e2ee-worker/replayWindow.ts +131 -0
  118. package/src/rtc/e2ee/e2ee-worker/trailer.ts +155 -0
  119. package/src/rtc/e2ee/e2ee-worker/tsconfig.json +8 -0
  120. package/src/rtc/e2ee/e2ee-worker/types.ts +29 -0
  121. package/src/rtc/e2ee/e2ee-worker.ts +27 -0
  122. package/src/rtc/e2ee/events.ts +179 -0
  123. package/src/rtc/e2ee/transformSupport.ts +35 -0
  124. package/src/rtc/helpers/rtcConfiguration.ts +4 -2
  125. package/src/rtc/index.ts +1 -0
  126. package/src/rtc/mediaEngine.ts +44 -0
  127. package/src/rtc/types.ts +2 -0
  128. package/src/store/CallState.ts +33 -1
  129. package/src/store/__tests__/CallState.test.ts +82 -1
  130. package/src/types.ts +23 -5
@@ -6,8 +6,10 @@ import {
6
6
  emitDeviceIds,
7
7
  LocalStorageMock,
8
8
  mockAudioDevices,
9
+ mockAudioOutputDevices,
9
10
  mockBrowserPermission,
10
11
  mockDeviceIds$,
12
+ mockDevicesWithoutAudioPermission,
11
13
  } from './mocks';
12
14
  import { of } from 'rxjs';
13
15
  import { SpeakerManager } from '../SpeakerManager';
@@ -122,15 +124,36 @@ describe('SpeakerManager.test', () => {
122
124
  });
123
125
 
124
126
  it('should disable device if selected device is disconnected', () => {
125
- emitDeviceIds(mockAudioDevices);
126
- const deviceId = mockAudioDevices[1].deviceId;
127
+ const deviceId = mockAudioOutputDevices[0].deviceId;
128
+ emitDeviceIds([...mockAudioDevices, ...mockAudioOutputDevices]);
127
129
  manager.select(deviceId);
128
130
 
129
- emitDeviceIds(mockAudioDevices.slice(2));
131
+ emitDeviceIds([...mockAudioDevices, ...mockAudioOutputDevices.slice(1)]);
130
132
 
131
133
  expect(manager.state.selectedDevice).toBe('');
132
134
  });
133
135
 
136
+ it('should keep the selection when the device list never exposed audio ids', () => {
137
+ const deviceId = mockAudioOutputDevices[0].deviceId;
138
+ emitDeviceIds(mockDevicesWithoutAudioPermission);
139
+
140
+ manager.select(deviceId);
141
+ expect(manager.state.selectedDevice).toBe(deviceId);
142
+
143
+ emitDeviceIds(mockDevicesWithoutAudioPermission);
144
+ expect(manager.state.selectedDevice).toBe(deviceId);
145
+ });
146
+
147
+ it('should keep the selection when the device appears in a later enumeration', () => {
148
+ const deviceId = mockAudioOutputDevices[0].deviceId;
149
+ emitDeviceIds(mockDevicesWithoutAudioPermission);
150
+ manager.select(deviceId);
151
+
152
+ emitDeviceIds([...mockAudioDevices, ...mockAudioOutputDevices]);
153
+
154
+ expect(manager.state.selectedDevice).toBe(deviceId);
155
+ });
156
+
134
157
  it('persists speaker selection when permission is granted', async () => {
135
158
  const streamClient = new StreamClient('abc123');
136
159
  const persistedManager = new SpeakerManager(
@@ -159,16 +182,9 @@ describe('SpeakerManager.test', () => {
159
182
  });
160
183
 
161
184
  describe('apply (web)', () => {
162
- it('does nothing when persistence is disabled', () => {
163
- const selectSpy = vi.spyOn(manager, 'select');
164
- // @ts-expect-error - partial data
165
- manager.apply({});
166
- expect(selectSpy).not.toHaveBeenCalled();
167
- });
168
-
169
- it('selects the persisted speaker device', () => {
185
+ const createPersistedManager = () => {
170
186
  const streamClient = new StreamClient('abc123');
171
- const persistedManager = new SpeakerManager(
187
+ return new SpeakerManager(
172
188
  new Call({
173
189
  id: '',
174
190
  type: '',
@@ -178,56 +194,136 @@ describe('SpeakerManager.test', () => {
178
194
  }),
179
195
  { enabled: true, storageKey },
180
196
  );
197
+ };
181
198
 
199
+ const persist = (selectedDeviceId: string, selectedDeviceLabel: string) => {
182
200
  localStorageMock.setItem(
183
201
  storageKey,
184
202
  JSON.stringify({
185
- speaker: [
186
- {
187
- selectedDeviceId: 'speaker-1',
188
- selectedDeviceLabel: 'Speaker 1',
189
- },
190
- ],
203
+ speaker: [{ selectedDeviceId, selectedDeviceLabel }],
191
204
  }),
192
205
  );
206
+ };
207
+
208
+ it('does nothing when persistence is disabled', async () => {
209
+ const selectSpy = vi.spyOn(manager, 'select');
210
+ // @ts-expect-error - partial data
211
+ await manager.apply({});
212
+ expect(selectSpy).not.toHaveBeenCalled();
213
+ });
214
+
215
+ it('selects the persisted speaker device', async () => {
216
+ const persistedManager = createPersistedManager();
217
+ vi.spyOn(persistedManager, 'listDevices').mockReturnValue(
218
+ of([
219
+ {
220
+ deviceId: 'speaker-1',
221
+ kind: 'audiooutput',
222
+ label: 'Speaker 1',
223
+ groupId: 'speaker-group',
224
+ } as MediaDeviceInfo,
225
+ ]),
226
+ );
227
+ persist('speaker-1', 'Speaker 1');
193
228
 
194
229
  const selectSpy = vi.spyOn(persistedManager, 'select');
195
230
  // @ts-expect-error - partial data
196
- persistedManager.apply({});
231
+ await persistedManager.apply({});
197
232
 
198
233
  expect(selectSpy).toHaveBeenCalledWith('speaker-1');
199
234
  expect(persistedManager.state.selectedDevice).toBe('speaker-1');
200
235
  });
201
236
 
202
- it('selects system default when persisted device is default', () => {
203
- const streamClient = new StreamClient('abc123');
204
- const persistedManager = new SpeakerManager(
205
- new Call({
206
- id: '',
207
- type: '',
208
- streamClient,
209
- clientEventReporter: new ClientEventReporter({ streamClient }),
210
- clientStore: new StreamVideoWriteableStateStore(),
211
- }),
212
- { enabled: true, storageKey },
237
+ it('does not select a missing persisted speaker device', async () => {
238
+ const persistedManager = createPersistedManager();
239
+ vi.spyOn(persistedManager, 'listDevices').mockReturnValue(
240
+ of(mockAudioOutputDevices),
213
241
  );
214
- persistedManager.select('previous-device');
242
+ persist('speaker-1', 'Speaker 1');
215
243
 
216
- localStorageMock.setItem(
217
- storageKey,
218
- JSON.stringify({
219
- speaker: [
220
- {
221
- selectedDeviceId: defaultDeviceId,
222
- selectedDeviceLabel: '',
223
- },
224
- ],
225
- }),
244
+ const selectSpy = vi.spyOn(persistedManager, 'select');
245
+ // @ts-expect-error - partial data
246
+ await persistedManager.apply({});
247
+
248
+ expect(selectSpy).not.toHaveBeenCalled();
249
+ expect(persistedManager.state.selectedDevice).toBe('');
250
+ });
251
+
252
+ it('does not restore a speaker preference when audio permission is not granted', async () => {
253
+ const persistedManager = createPersistedManager();
254
+ vi.spyOn(mockBrowserPermission, 'asStateObservable').mockReturnValue(
255
+ of('prompt'),
256
+ );
257
+ const listDevicesSpy = vi.spyOn(persistedManager, 'listDevices');
258
+ persist('speaker-1', 'Speaker 1');
259
+
260
+ const selectSpy = vi.spyOn(persistedManager, 'select');
261
+ // @ts-expect-error - partial data
262
+ await persistedManager.apply({});
263
+
264
+ expect(listDevicesSpy).not.toHaveBeenCalled();
265
+ expect(selectSpy).not.toHaveBeenCalled();
266
+ expect(persistedManager.state.selectedDevice).toBe('');
267
+ });
268
+
269
+ it('does not restore a speaker preference from a non-output device with the same label', async () => {
270
+ const persistedManager = createPersistedManager();
271
+ const videoDevice = {
272
+ deviceId: 'video-device-id',
273
+ kind: 'videoinput',
274
+ label: 'RODECaster Video (19f7:006b)',
275
+ groupId: 'video-group',
276
+ } as MediaDeviceInfo;
277
+ const outputDevice = {
278
+ deviceId: 'speaker-device-id',
279
+ kind: 'audiooutput',
280
+ label: 'RODECaster Video (19f7:006b)',
281
+ groupId: 'speaker-group',
282
+ } as MediaDeviceInfo;
283
+ vi.spyOn(persistedManager, 'listDevices').mockReturnValue(
284
+ of([outputDevice]),
226
285
  );
286
+ persist('missing-speaker-device-id', outputDevice.label);
287
+
288
+ const selectSpy = vi.spyOn(persistedManager, 'select');
289
+ // @ts-expect-error - partial data
290
+ await persistedManager.apply({});
291
+
292
+ expect(selectSpy).toHaveBeenCalledWith(outputDevice.deviceId);
293
+ expect(selectSpy).not.toHaveBeenCalledWith(videoDevice.deviceId);
294
+ expect(persistedManager.state.selectedDevice).toBe(outputDevice.deviceId);
295
+ });
296
+
297
+ it('does not restore a speaker preference from an empty label placeholder', async () => {
298
+ const persistedManager = createPersistedManager();
299
+ vi.spyOn(persistedManager, 'listDevices').mockReturnValue(
300
+ of([
301
+ {
302
+ deviceId: '',
303
+ kind: 'audiooutput',
304
+ label: '',
305
+ groupId: '',
306
+ } as MediaDeviceInfo,
307
+ ]),
308
+ );
309
+ persist('missing-speaker-device-id', '');
310
+
311
+ const selectSpy = vi.spyOn(persistedManager, 'select');
312
+ // @ts-expect-error - partial data
313
+ await persistedManager.apply({});
314
+
315
+ expect(selectSpy).not.toHaveBeenCalled();
316
+ expect(persistedManager.state.selectedDevice).toBe('');
317
+ });
318
+
319
+ it('selects system default when persisted device is default', async () => {
320
+ const persistedManager = createPersistedManager();
321
+ persistedManager.select('previous-device');
322
+ persist(defaultDeviceId, '');
227
323
 
228
324
  const selectSpy = vi.spyOn(persistedManager, 'select');
229
325
  // @ts-expect-error - partial data
230
- persistedManager.apply({});
326
+ await persistedManager.apply({});
231
327
 
232
328
  expect(selectSpy).toHaveBeenCalledWith('');
233
329
  expect(persistedManager.state.selectedDevice).toBe('');
@@ -6,7 +6,7 @@ import {
6
6
  OwnCapability,
7
7
  } from '../../gen/coordinator';
8
8
  import { Call } from '../../Call';
9
- import { of, Subject } from 'rxjs';
9
+ import { of, ReplaySubject } from 'rxjs';
10
10
  import { BrowserPermission } from '../BrowserPermission';
11
11
  import { Tracer } from '../../stats';
12
12
 
@@ -71,6 +71,35 @@ export const mockAudioDevices = [
71
71
  },
72
72
  ] as MediaDeviceInfo[];
73
73
 
74
+ export const mockAudioOutputDevices = [
75
+ {
76
+ deviceId:
77
+ 'f1602a62d9d363c0711de07182751b646b87cfc0edd9e89d2849289e49304158',
78
+ kind: 'audiooutput',
79
+ label: 'RODECaster Video (19f7:006b)',
80
+ groupId: '27f43ef815ab224429532d6e8ebbeb00d7a895e0cbb0c2e4fcbe5b370f234259',
81
+ },
82
+ {
83
+ deviceId:
84
+ 'd542885e6a47d7a38837aee0323bd2c50e825971690f47295cb338e1f1061283',
85
+ kind: 'audiooutput',
86
+ label: 'MacBook Pro Speakers (Built-in)',
87
+ groupId: 'a8708545cb3969c0b7e3712d2ffdbc381992aeb5577b06cd71ba7a94ee20cf3e',
88
+ },
89
+ ] as MediaDeviceInfo[];
90
+
91
+ export const mockDevicesWithoutAudioPermission = [
92
+ { deviceId: '', kind: 'audioinput', label: '', groupId: '' },
93
+ {
94
+ deviceId:
95
+ 'f10561a0bbd3effbd25645e7f06fc2e261255afeb05f6bc4fdaba3ed7bd9c2b7',
96
+ kind: 'videoinput',
97
+ label: 'RODECaster Video (19f7:006b)',
98
+ groupId: '27f43ef815ab224429532d6e8ebbeb00d7a895e0cbb0c2e4fcbe5b370f234259',
99
+ },
100
+ { deviceId: '', kind: 'audiooutput', label: '', groupId: '' },
101
+ ] as MediaDeviceInfo[];
102
+
74
103
  export const mockCall = (): Partial<Call> => {
75
104
  const callState = new CallState();
76
105
  callState.setCallingState(CallingState.JOINED);
@@ -97,6 +126,7 @@ export const mockCall = (): Partial<Call> => {
97
126
  return {
98
127
  cid: 'default:test-call',
99
128
  state: callState,
129
+ hasMediaEngine: true,
100
130
  publish: vi.fn(),
101
131
  stopPublish: vi.fn(),
102
132
  streamClient: fromPartial({
@@ -106,6 +136,9 @@ export const mockCall = (): Partial<Call> => {
106
136
  notifyNoiseCancellationStopped: vi.fn().mockResolvedValue(undefined),
107
137
  notifyTrackMuteState: vi.fn().mockResolvedValue(undefined),
108
138
  refreshPublishedTrack: vi.fn().mockResolvedValue(undefined),
139
+ ensureMediaFactory: vi.fn().mockResolvedValue({
140
+ dispose: vi.fn().mockResolvedValue(undefined),
141
+ }),
109
142
  tracer: new Tracer('tests'),
110
143
  };
111
144
  };
@@ -252,9 +285,9 @@ export const createLocalStorageMock = (): LocalStorageMock => {
252
285
  };
253
286
  };
254
287
 
255
- let deviceIds: Subject<MediaDeviceInfo[]>;
288
+ let deviceIds: ReplaySubject<MediaDeviceInfo[]>;
256
289
  export const mockDeviceIds$ = () => {
257
- deviceIds = new Subject();
290
+ deviceIds = new ReplaySubject(1);
258
291
  return deviceIds;
259
292
  };
260
293
 
@@ -1,5 +1,5 @@
1
1
  import '../../rtc/__tests__/mocks/webrtc.mocks';
2
- import { describe, expect, it } from 'vitest';
2
+ import { describe, expect, it, vi } from 'vitest';
3
3
  import { CallState } from '../../store';
4
4
  import { VisibilityState } from '../../types';
5
5
  import { TrackType } from '../../gen/video/sfu/models/models';
@@ -192,6 +192,139 @@ describe('Participant events', () => {
192
192
  expect(p?.screenShareStream).toBe(mediaStream);
193
193
  expect(state.takeOrphanedTracks('track-lookup-prefix')).toHaveLength(0);
194
194
  });
195
+
196
+ it('participantJoined should attach E2EE decryptor to orphaned track receiver', () => {
197
+ const state = new CallState();
198
+ const mediaStream = new MediaStream();
199
+ const receiver = {} as RTCRtpReceiver;
200
+ const e2ee = { decrypt: vi.fn() };
201
+ state.registerOrphanedTrack({
202
+ trackLookupPrefix: 'track-lookup-prefix',
203
+ trackType: TrackType.VIDEO,
204
+ track: mediaStream,
205
+ id: mediaStream.id,
206
+ receiver,
207
+ });
208
+ // @ts-expect-error partial mock
209
+ const onParticipantJoined = watchParticipantJoined(state, () => e2ee);
210
+ onParticipantJoined({
211
+ // @ts-expect-error incomplete data
212
+ participant: {
213
+ userId: 'user-id',
214
+ sessionId: 'session-id',
215
+ trackLookupPrefix: 'track-lookup-prefix',
216
+ },
217
+ });
218
+
219
+ expect(e2ee.decrypt).toHaveBeenCalledWith(receiver, 'user-id', 'VIDEO');
220
+ });
221
+
222
+ it('trackPublished should attach E2EE decryptor to orphaned track receiver', () => {
223
+ const state = new CallState();
224
+ const mediaStream = new MediaStream();
225
+ const receiver = {} as RTCRtpReceiver;
226
+ const e2ee = { decrypt: vi.fn() };
227
+ state.registerOrphanedTrack({
228
+ trackLookupPrefix: 'track-lookup-prefix',
229
+ trackType: TrackType.AUDIO,
230
+ track: mediaStream,
231
+ id: mediaStream.id,
232
+ receiver,
233
+ });
234
+ // @ts-expect-error partial mock
235
+ const onTrackPublished = watchTrackPublished(state, () => e2ee);
236
+ onTrackPublished({
237
+ // @ts-expect-error incomplete data
238
+ participant: {
239
+ userId: 'user-id',
240
+ sessionId: 'session-id',
241
+ trackLookupPrefix: 'track-lookup-prefix',
242
+ },
243
+ });
244
+
245
+ expect(e2ee.decrypt).toHaveBeenCalledWith(receiver, 'user-id', 'AUDIO');
246
+ });
247
+
248
+ it('trackUnpublished should attach E2EE decryptor to orphaned track receiver', () => {
249
+ const state = new CallState();
250
+ const mediaStream = new MediaStream();
251
+ const receiver = {} as RTCRtpReceiver;
252
+ const e2ee = { decrypt: vi.fn() };
253
+ state.registerOrphanedTrack({
254
+ trackLookupPrefix: 'track-lookup-prefix',
255
+ trackType: TrackType.SCREEN_SHARE,
256
+ track: mediaStream,
257
+ id: mediaStream.id,
258
+ receiver,
259
+ });
260
+ // @ts-expect-error partial mock
261
+ const onTrackUnPublished = watchTrackUnpublished(state, () => e2ee);
262
+ onTrackUnPublished({
263
+ // @ts-expect-error incomplete data
264
+ participant: {
265
+ userId: 'user-id',
266
+ sessionId: 'session-id',
267
+ trackLookupPrefix: 'track-lookup-prefix',
268
+ },
269
+ });
270
+
271
+ expect(e2ee.decrypt).toHaveBeenCalledWith(
272
+ receiver,
273
+ 'user-id',
274
+ 'SCREEN_SHARE',
275
+ );
276
+ });
277
+
278
+ it('should not call decrypt when no E2EE manager is provided', () => {
279
+ const state = new CallState();
280
+ const mediaStream = new MediaStream();
281
+ const receiver = {} as RTCRtpReceiver;
282
+ state.registerOrphanedTrack({
283
+ trackLookupPrefix: 'track-lookup-prefix',
284
+ trackType: TrackType.VIDEO,
285
+ track: mediaStream,
286
+ id: mediaStream.id,
287
+ receiver,
288
+ });
289
+ const onParticipantJoined = watchParticipantJoined(state);
290
+ onParticipantJoined({
291
+ // @ts-expect-error incomplete data
292
+ participant: {
293
+ userId: 'user-id',
294
+ sessionId: 'session-id',
295
+ trackLookupPrefix: 'track-lookup-prefix',
296
+ },
297
+ });
298
+
299
+ const p = state.findParticipantBySessionId('session-id');
300
+ expect(p?.videoStream).toBe(mediaStream);
301
+ });
302
+
303
+ it('should not call decrypt when orphaned track has no receiver', () => {
304
+ const state = new CallState();
305
+ const mediaStream = new MediaStream();
306
+ const e2ee = { decrypt: vi.fn() };
307
+ state.registerOrphanedTrack({
308
+ trackLookupPrefix: 'track-lookup-prefix',
309
+ trackType: TrackType.VIDEO,
310
+ track: mediaStream,
311
+ id: mediaStream.id,
312
+ });
313
+ // @ts-expect-error partial mock
314
+ const onParticipantJoined = watchParticipantJoined(state, () => e2ee);
315
+ onParticipantJoined({
316
+ // @ts-expect-error incomplete data
317
+ participant: {
318
+ userId: 'user-id',
319
+ sessionId: 'session-id',
320
+ trackLookupPrefix: 'track-lookup-prefix',
321
+ },
322
+ });
323
+
324
+ expect(e2ee.decrypt).not.toHaveBeenCalled();
325
+ const p = state.findParticipantBySessionId('session-id');
326
+ expect(p?.videoStream).toBe(mediaStream);
327
+ });
195
328
  });
196
329
 
197
330
  describe('trackPublished', () => {
@@ -81,7 +81,6 @@ export const watchCallRejected = (call: Call) => {
81
81
  */
82
82
  export const watchCallEnded = (call: Call) => {
83
83
  return function onCallEnded() {
84
- globalThis.streamRNVideoSDK?.callingX?.endCall(call, 'remote');
85
84
  const { callingState } = call.state;
86
85
  if (
87
86
  callingState !== CallingState.IDLE &&
@@ -92,9 +91,13 @@ export const watchCallEnded = (call: Call) => {
92
91
  reason: 'call.ended event received',
93
92
  });
94
93
  call
95
- .leave({ message: 'call.ended event received', reject: false })
94
+ .leave({
95
+ message: 'call.ended event received',
96
+ reject: false,
97
+ reason: 'ended',
98
+ })
96
99
  .catch((err) => {
97
- call.logger.error('Failed to leave call after call.ended ', err);
100
+ call.logger.error('Failed to leave call after call.ended', err);
98
101
  });
99
102
  }
100
103
  };
@@ -40,6 +40,12 @@ type RingCallEvents = Extract<
40
40
  */
41
41
  export const registerEventHandlers = (call: Call, dispatcher: Dispatcher) => {
42
42
  const state = call.state;
43
+ // Read lazily on each event: setE2EEManager can run after setup() (an app that
44
+ // inspects call settings via get()/getOrCreate() before deciding to encrypt has
45
+ // already triggered it), so a value captured here would be a stale undefined
46
+ // for the whole call and orphaned tracks would never get a decryptor.
47
+ const e2ee = () => call.e2eeManager;
48
+
43
49
  const eventHandlers = [
44
50
  call.on('call.ended', watchCallEnded(call)),
45
51
  watchSfuCallEnded(call),
@@ -49,12 +55,12 @@ export const registerEventHandlers = (call: Call, dispatcher: Dispatcher) => {
49
55
  watchConnectionQualityChanged(dispatcher, state),
50
56
  watchParticipantCountChanged(dispatcher, state),
51
57
 
52
- call.on('participantJoined', watchParticipantJoined(state)),
58
+ call.on('participantJoined', watchParticipantJoined(state, e2ee)),
53
59
  call.on('participantLeft', watchParticipantLeft(state)),
54
60
  call.on('participantUpdated', watchParticipantUpdated(state)),
55
61
 
56
- call.on('trackPublished', watchTrackPublished(state)),
57
- call.on('trackUnpublished', watchTrackUnpublished(state)),
62
+ call.on('trackPublished', watchTrackPublished(state, e2ee)),
63
+ call.on('trackUnpublished', watchTrackUnpublished(state, e2ee)),
58
64
 
59
65
  watchAudioLevelChanged(dispatcher, state),
60
66
  watchDominantSpeakerChanged(dispatcher, state),
@@ -5,7 +5,7 @@ import type {
5
5
  TrackPublished,
6
6
  TrackUnpublished,
7
7
  } from '../gen/video/sfu/event/events';
8
- import type { Participant } from '../gen/video/sfu/models/models';
8
+ import { type Participant, TrackType } from '../gen/video/sfu/models/models';
9
9
  import {
10
10
  StreamVideoParticipant,
11
11
  StreamVideoParticipantPatch,
@@ -14,11 +14,21 @@ import {
14
14
  import { CallState } from '../store';
15
15
  import { trackTypeToParticipantStreamKey } from '../rtc';
16
16
  import { pushToIfMissing } from '../helpers/array';
17
+ import type { E2EEManager } from '../rtc/e2ee/E2EEManager';
18
+
19
+ /**
20
+ * Reads the call's current encryption manager. Deliberately not the manager
21
+ * itself: `Call.setE2EEManager` may be called after these handlers are wired up.
22
+ */
23
+ type GetE2EEManager = () => E2EEManager | undefined;
17
24
 
18
25
  /**
19
26
  * An event responder which handles the `participantJoined` event.
20
27
  */
21
- export const watchParticipantJoined = (state: CallState) => {
28
+ export const watchParticipantJoined = (
29
+ state: CallState,
30
+ e2ee: GetE2EEManager | undefined = undefined,
31
+ ) => {
22
32
  return function onParticipantJoined(e: ParticipantJoined) {
23
33
  const { participant } = e;
24
34
  if (!participant) return;
@@ -30,7 +40,7 @@ export const watchParticipantJoined = (state: CallState) => {
30
40
  // The SFU would send participant info as part of the `join`
31
41
  // response and then follow up with a `participantJoined` event for
32
42
  // already announced participants.
33
- const orphanedTracks = reconcileOrphanedTracks(state, participant);
43
+ const orphanedTracks = reconcileOrphanedTracks(state, participant, e2ee);
34
44
  state.updateOrAddParticipant(
35
45
  participant.sessionId,
36
46
  Object.assign<
@@ -77,7 +87,10 @@ export const watchParticipantUpdated = (state: CallState) => {
77
87
  * An event responder which handles the `trackPublished` event.
78
88
  * The SFU will send this event when a participant publishes a track.
79
89
  */
80
- export const watchTrackPublished = (state: CallState) => {
90
+ export const watchTrackPublished = (
91
+ state: CallState,
92
+ e2ee: GetE2EEManager | undefined = undefined,
93
+ ) => {
81
94
  return function onTrackPublished(e: TrackPublished) {
82
95
  const { type, sessionId } = e;
83
96
  // An optimization for large calls.
@@ -85,7 +98,11 @@ export const watchTrackPublished = (state: CallState) => {
85
98
  // events, and instead, it would only provide the participant's information
86
99
  // once they start publishing a track.
87
100
  if (e.participant) {
88
- const orphanedTracks = reconcileOrphanedTracks(state, e.participant);
101
+ const orphanedTracks = reconcileOrphanedTracks(
102
+ state,
103
+ e.participant,
104
+ e2ee,
105
+ );
89
106
  const participant = Object.assign(e.participant, orphanedTracks);
90
107
  state.updateOrAddParticipant(sessionId, participant);
91
108
  } else {
@@ -100,12 +117,19 @@ export const watchTrackPublished = (state: CallState) => {
100
117
  * An event responder which handles the `trackUnpublished` event.
101
118
  * The SFU will send this event when a participant unpublishes a track.
102
119
  */
103
- export const watchTrackUnpublished = (state: CallState) => {
120
+ export const watchTrackUnpublished = (
121
+ state: CallState,
122
+ e2ee: GetE2EEManager | undefined = undefined,
123
+ ) => {
104
124
  return function onTrackUnpublished(e: TrackUnpublished) {
105
125
  const { type, sessionId } = e;
106
126
  // An optimization for large calls. See `watchTrackPublished`.
107
127
  if (e.participant) {
108
- const orphanedTracks = reconcileOrphanedTracks(state, e.participant);
128
+ const orphanedTracks = reconcileOrphanedTracks(
129
+ state,
130
+ e.participant,
131
+ e2ee,
132
+ );
109
133
  const participant = Object.assign(e.participant, orphanedTracks);
110
134
  state.updateOrAddParticipant(sessionId, participant, (p) => ({
111
135
  pausedTracks: p.pausedTracks?.filter((t) => t !== type),
@@ -124,18 +148,29 @@ export const watchTrackUnpublished = (state: CallState) => {
124
148
  *
125
149
  * @param state the call state.
126
150
  * @param participant the participant.
151
+ * @param e2ee accessor for the call's encryption manager, if any.
127
152
  */
128
153
  const reconcileOrphanedTracks = (
129
154
  state: CallState,
130
155
  participant: Participant,
156
+ e2ee: GetE2EEManager | undefined,
131
157
  ): StreamVideoParticipantPatch | undefined => {
132
158
  const orphanTracks = state.takeOrphanedTracks(participant.trackLookupPrefix);
133
159
  if (!orphanTracks.length) return;
160
+ const manager = e2ee?.();
134
161
  const reconciledTracks: StreamVideoParticipantPatch = {};
135
162
  for (const orphan of orphanTracks) {
136
163
  const key = trackTypeToParticipantStreamKey(orphan.trackType);
137
164
  if (!key) continue;
138
165
  reconciledTracks[key] = orphan.track;
166
+
167
+ if (manager && orphan.receiver) {
168
+ manager.decrypt(
169
+ orphan.receiver,
170
+ participant.userId,
171
+ TrackType[orphan.trackType],
172
+ );
173
+ }
139
174
  }
140
175
  return reconciledTracks;
141
176
  };