@stream-io/video-client 1.57.0 → 1.58.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 (105) hide show
  1. package/CHANGELOG.md +13 -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 +831 -55
  5. package/dist/index.browser.es.js.map +1 -1
  6. package/dist/index.cjs.js +875 -54
  7. package/dist/index.cjs.js.map +1 -1
  8. package/dist/index.d.ts +3 -0
  9. package/dist/index.es.js +864 -55
  10. package/dist/index.es.js.map +1 -1
  11. package/dist/src/Call.d.ts +52 -6
  12. package/dist/src/coordinator/connection/connection.d.ts +1 -1
  13. package/dist/src/devices/CameraManager.d.ts +6 -0
  14. package/dist/src/devices/DeviceManager.d.ts +1 -0
  15. package/dist/src/devices/MicrophoneManager.d.ts +6 -0
  16. package/dist/src/devices/SpeakerManager.d.ts +2 -1
  17. package/dist/src/events/participant.d.ts +10 -3
  18. package/dist/src/gen/coordinator/index.d.ts +1363 -243
  19. package/dist/src/gen/video/sfu/models/models.d.ts +8 -0
  20. package/dist/src/helpers/TypedEventEmitter.d.ts +23 -0
  21. package/dist/src/helpers/ensureExhausted.d.ts +1 -1
  22. package/dist/src/permissions/PermissionsContext.d.ts +2 -2
  23. package/dist/src/rtc/BasePeerConnection.d.ts +3 -1
  24. package/dist/src/rtc/e2ee/E2EEManager.d.ts +12 -0
  25. package/dist/src/rtc/e2ee/EncryptionManager.d.ts +148 -0
  26. package/dist/src/rtc/e2ee/e2ee-worker.d.ts +25 -0
  27. package/dist/src/rtc/e2ee/events.d.ts +163 -0
  28. package/dist/src/rtc/e2ee/transformSupport.d.ts +21 -0
  29. package/dist/src/rtc/helpers/rtcConfiguration.d.ts +2 -2
  30. package/dist/src/rtc/index.d.ts +1 -0
  31. package/dist/src/rtc/mediaEngine.d.ts +23 -0
  32. package/dist/src/rtc/types.d.ts +2 -0
  33. package/dist/src/store/CallState.d.ts +21 -0
  34. package/dist/src/types.d.ts +15 -6
  35. package/index.ts +3 -0
  36. package/package.json +1 -1
  37. package/src/Call.ts +160 -26
  38. package/src/__tests__/Call.test.ts +15 -0
  39. package/src/coordinator/connection/connection.ts +6 -3
  40. package/src/devices/AudioDeviceManager.ts +6 -0
  41. package/src/devices/CameraManager.ts +51 -4
  42. package/src/devices/DeviceManager.ts +17 -0
  43. package/src/devices/MicrophoneManager.ts +55 -5
  44. package/src/devices/ScreenShareManager.ts +3 -0
  45. package/src/devices/SpeakerManager.ts +42 -12
  46. package/src/devices/__tests__/MicrophoneManagerRN.test.ts +1 -20
  47. package/src/devices/__tests__/SpeakerManager.test.ts +138 -42
  48. package/src/devices/__tests__/mocks.ts +36 -3
  49. package/src/events/__tests__/participant.test.ts +134 -1
  50. package/src/events/callEventHandlers.ts +9 -3
  51. package/src/events/participant.ts +42 -7
  52. package/src/gen/coordinator/index.ts +1384 -244
  53. package/src/gen/video/sfu/models/models.ts +15 -0
  54. package/src/helpers/TypedEventEmitter.ts +71 -0
  55. package/src/helpers/__tests__/TypedEventEmitter.test.ts +133 -0
  56. package/src/helpers/ensureExhausted.ts +4 -2
  57. package/src/permissions/PermissionsContext.ts +10 -5
  58. package/src/rtc/BasePeerConnection.ts +12 -1
  59. package/src/rtc/Publisher.ts +8 -0
  60. package/src/rtc/Subscriber.ts +5 -0
  61. package/src/rtc/__tests__/Publisher.test.ts +101 -0
  62. package/src/rtc/__tests__/Subscriber.test.ts +89 -0
  63. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +31 -0
  64. package/src/rtc/e2ee/E2EEManager.ts +12 -0
  65. package/src/rtc/e2ee/EncryptionManager.ts +336 -0
  66. package/src/rtc/e2ee/SPEC.md +586 -0
  67. package/src/rtc/e2ee/__tests__/E2EEManager.contract.test.ts +39 -0
  68. package/src/rtc/e2ee/__tests__/EncryptionManager.test.ts +490 -0
  69. package/src/rtc/e2ee/__tests__/codec.test.ts +247 -0
  70. package/src/rtc/e2ee/__tests__/conformance.test.ts +184 -0
  71. package/src/rtc/e2ee/__tests__/failureTracker.test.ts +45 -0
  72. package/src/rtc/e2ee/__tests__/frameCounter.test.ts +90 -0
  73. package/src/rtc/e2ee/__tests__/keyStore.test.ts +221 -0
  74. package/src/rtc/e2ee/__tests__/notifications.test.ts +166 -0
  75. package/src/rtc/e2ee/__tests__/perf.test.ts +139 -0
  76. package/src/rtc/e2ee/__tests__/queue.test.ts +61 -0
  77. package/src/rtc/e2ee/__tests__/replayWindow.test.ts +133 -0
  78. package/src/rtc/e2ee/__tests__/trailer.test.ts +143 -0
  79. package/src/rtc/e2ee/__tests__/transform-pipeline.test.ts +839 -0
  80. package/src/rtc/e2ee/__tests__/transformSupport.test.ts +65 -0
  81. package/src/rtc/e2ee/e2ee-worker/codec.ts +220 -0
  82. package/src/rtc/e2ee/e2ee-worker/constants.ts +47 -0
  83. package/src/rtc/e2ee/e2ee-worker/decode.ts +159 -0
  84. package/src/rtc/e2ee/e2ee-worker/e2ee-worker-impl.ts +137 -0
  85. package/src/rtc/e2ee/e2ee-worker/encode.ts +147 -0
  86. package/src/rtc/e2ee/e2ee-worker/failureTracker.ts +26 -0
  87. package/src/rtc/e2ee/e2ee-worker/frameCounter.ts +37 -0
  88. package/src/rtc/e2ee/e2ee-worker/keyStore.ts +211 -0
  89. package/src/rtc/e2ee/e2ee-worker/notifications.ts +190 -0
  90. package/src/rtc/e2ee/e2ee-worker/perf.ts +129 -0
  91. package/src/rtc/e2ee/e2ee-worker/queue.ts +12 -0
  92. package/src/rtc/e2ee/e2ee-worker/replayWindow.ts +131 -0
  93. package/src/rtc/e2ee/e2ee-worker/trailer.ts +155 -0
  94. package/src/rtc/e2ee/e2ee-worker/tsconfig.json +8 -0
  95. package/src/rtc/e2ee/e2ee-worker/types.ts +29 -0
  96. package/src/rtc/e2ee/e2ee-worker.ts +27 -0
  97. package/src/rtc/e2ee/events.ts +179 -0
  98. package/src/rtc/e2ee/transformSupport.ts +35 -0
  99. package/src/rtc/helpers/rtcConfiguration.ts +4 -2
  100. package/src/rtc/index.ts +1 -0
  101. package/src/rtc/mediaEngine.ts +44 -0
  102. package/src/rtc/types.ts +2 -0
  103. package/src/store/CallState.ts +33 -1
  104. package/src/store/__tests__/CallState.test.ts +82 -1
  105. package/src/types.ts +23 -5
@@ -1,4 +1,4 @@
1
- import { combineLatest } from 'rxjs';
1
+ import { combineLatest, firstValueFrom, pairwise } from 'rxjs';
2
2
  import { Call } from '../Call';
3
3
  import { isReactNative } from '../helpers/platforms';
4
4
  import { SpeakerState } from './SpeakerState';
@@ -39,11 +39,15 @@ export class SpeakerManager {
39
39
  this.setup();
40
40
  }
41
41
 
42
- apply(settings: CallSettingsResponse) {
43
- return isReactNative() ? this.applyRN(settings) : this.applyWeb();
42
+ async apply(settings: CallSettingsResponse): Promise<void> {
43
+ if (isReactNative()) {
44
+ this.applyRN(settings);
45
+ return;
46
+ }
47
+ await this.applyWeb();
44
48
  }
45
49
 
46
- private applyWeb() {
50
+ private async applyWeb() {
47
51
  const { enabled, storageKey } = this.devicePersistence;
48
52
  if (!enabled) return;
49
53
 
@@ -56,8 +60,26 @@ export class SpeakerManager {
56
60
  preference.selectedDeviceId === defaultDeviceId
57
61
  ? ''
58
62
  : preference.selectedDeviceId;
59
- if (this.state.selectedDevice !== nextDeviceId) {
60
- this.select(nextDeviceId);
63
+ if (!nextDeviceId) {
64
+ if (this.state.selectedDevice !== nextDeviceId) {
65
+ this.select(nextDeviceId);
66
+ }
67
+ return;
68
+ }
69
+
70
+ const permissionState = await firstValueFrom(
71
+ getAudioBrowserPermission(this.call.tracer).asStateObservable(),
72
+ );
73
+ if (permissionState !== 'granted') return;
74
+
75
+ const devices = await firstValueFrom(this.listDevices());
76
+ const device =
77
+ this.findDevice(devices, nextDeviceId) ??
78
+ (preference.selectedDeviceLabel
79
+ ? devices.find((d) => d.label === preference.selectedDeviceLabel)
80
+ : undefined);
81
+ if (device && this.state.selectedDevice !== device.deviceId) {
82
+ this.select(device.deviceId);
61
83
  }
62
84
  }
63
85
 
@@ -90,6 +112,7 @@ export class SpeakerManager {
90
112
  globalThis.streamRNVideoSDK?.callManager.setup({
91
113
  defaultDevice,
92
114
  isRingingTypeCall: this.call.ringing,
115
+ cid: this.call.cid,
93
116
  });
94
117
  }
95
118
  }
@@ -101,13 +124,16 @@ export class SpeakerManager {
101
124
  if (deviceIds$ && !isReactNative()) {
102
125
  this.subscriptions.push(
103
126
  createSubscription(
104
- combineLatest([deviceIds$, this.state.selectedDevice$]),
105
- ([devices, deviceId]) => {
127
+ combineLatest([
128
+ deviceIds$!.pipe(pairwise()),
129
+ this.state.selectedDevice$,
130
+ ]),
131
+ ([[prevDevices, currentDevices], deviceId]) => {
106
132
  if (!deviceId) return;
107
- const device = devices.find(
108
- (d) => d.deviceId === deviceId && d.kind === 'audiooutput',
109
- );
110
- if (!device) this.select('');
133
+ const isDisconnected =
134
+ this.findDevice(prevDevices, deviceId) &&
135
+ !this.findDevice(currentDevices, deviceId);
136
+ if (isDisconnected) this.select('');
111
137
  },
112
138
  ),
113
139
  );
@@ -164,6 +190,7 @@ export class SpeakerManager {
164
190
  this.subscriptions.forEach((unsubscribe) => unsubscribe());
165
191
  this.subscriptions = [];
166
192
  this.areSubscriptionsSetUp = false;
193
+ this.defaultDevice = undefined;
167
194
  };
168
195
 
169
196
  /**
@@ -201,6 +228,9 @@ export class SpeakerManager {
201
228
  });
202
229
  }
203
230
 
231
+ private findDevice = (devices: MediaDeviceInfo[], deviceId: string) =>
232
+ devices.find((d) => d.deviceId === deviceId && d.kind === 'audiooutput');
233
+
204
234
  private persistSpeakerDevicePreference(selectedDevice: string) {
205
235
  const { storageKey } = this.devicePersistence;
206
236
  const devices = getCurrentValue(this.listDevices()) || [];
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
2
  import { MicrophoneManager } from '../MicrophoneManager';
3
3
  import { Call } from '../../Call';
4
4
  import { StreamClient } from '../../coordinator/connection/client';
5
- import { CallingState, StreamVideoWriteableStateStore } from '../../store';
5
+ import { StreamVideoWriteableStateStore } from '../../store';
6
6
  import {
7
7
  mockAudioDevices,
8
8
  mockAudioStream,
@@ -263,25 +263,6 @@ describe('MicrophoneManager React Native', () => {
263
263
  expect(setMutedRecordingPreparedMock).not.toHaveBeenCalledWith(true);
264
264
  });
265
265
 
266
- it('should release prepared muted recording when the call is left', async () => {
267
- await manager.disable();
268
- await vi.waitUntil(
269
- () =>
270
- setMutedRecordingPreparedMock.mock.calls.some(([arg]) => arg === true),
271
- { timeout: 100 },
272
- );
273
- setMutedRecordingPreparedMock.mockClear();
274
-
275
- manager['call'].state.setCallingState(CallingState.LEFT);
276
-
277
- await vi.waitUntil(
278
- () =>
279
- setMutedRecordingPreparedMock.mock.calls.some(([arg]) => arg === false),
280
- { timeout: 100 },
281
- );
282
- expect(setMutedRecordingPreparedMock).toHaveBeenCalledWith(false);
283
- });
284
-
285
266
  afterEach(() => {
286
267
  globalThis.streamRNVideoSDK = undefined;
287
268
  vi.clearAllMocks();
@@ -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', () => {
@@ -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),