@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.
- package/CHANGELOG.md +28 -0
- package/dist/e2ee-worker.browser.es.js +1342 -0
- package/dist/e2ee-worker.browser.es.js.map +1 -0
- package/dist/index.browser.es.js +1141 -117
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +1196 -115
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.es.js +1174 -117
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +66 -9
- package/dist/src/StreamVideoClient.d.ts +9 -1
- package/dist/src/coordinator/connection/connection.d.ts +1 -1
- package/dist/src/devices/CameraManager.d.ts +8 -1
- package/dist/src/devices/DeviceManager.d.ts +2 -1
- package/dist/src/devices/MicrophoneManager.d.ts +8 -1
- package/dist/src/devices/SpeakerManager.d.ts +2 -1
- package/dist/src/events/participant.d.ts +10 -3
- package/dist/src/gen/coordinator/index.d.ts +1386 -240
- package/dist/src/gen/video/sfu/models/models.d.ts +8 -0
- package/dist/src/helpers/MediaStreamRecorder.d.ts +26 -0
- package/dist/src/helpers/TypedEventEmitter.d.ts +23 -0
- package/dist/src/helpers/concurrency.d.ts +8 -0
- package/dist/src/helpers/ensureExhausted.d.ts +1 -1
- package/dist/src/helpers/loopback.d.ts +55 -0
- package/dist/src/permissions/PermissionsContext.d.ts +2 -2
- package/dist/src/reporting/ClientEventReporter.d.ts +0 -2
- package/dist/src/rtc/BasePeerConnection.d.ts +3 -1
- package/dist/src/rtc/Subscriber.d.ts +5 -0
- package/dist/src/rtc/e2ee/E2EEManager.d.ts +12 -0
- package/dist/src/rtc/e2ee/EncryptionManager.d.ts +148 -0
- package/dist/src/rtc/e2ee/e2ee-worker.d.ts +25 -0
- package/dist/src/rtc/e2ee/events.d.ts +163 -0
- package/dist/src/rtc/e2ee/transformSupport.d.ts +21 -0
- package/dist/src/rtc/helpers/rtcConfiguration.d.ts +2 -2
- package/dist/src/rtc/index.d.ts +1 -0
- package/dist/src/rtc/mediaEngine.d.ts +23 -0
- package/dist/src/rtc/types.d.ts +2 -0
- package/dist/src/store/CallState.d.ts +21 -0
- package/dist/src/types.d.ts +15 -6
- package/index.ts +5 -0
- package/package.json +1 -1
- package/src/Call.ts +311 -155
- package/src/StreamVideoClient.ts +35 -8
- package/src/__tests__/Call.lifecycle.test.ts +33 -2
- package/src/__tests__/Call.publishing.test.ts +42 -1
- package/src/__tests__/Call.test.ts +15 -0
- package/src/__tests__/StreamVideoClient.api.test.ts +33 -0
- package/src/__tests__/StreamVideoClient.rewatch.test.ts +213 -0
- package/src/__tests__/StreamVideoClient.ringing.test.ts +11 -4
- package/src/__tests__/StreamVideoClient.test.ts +14 -12
- package/src/coordinator/connection/connection.ts +6 -3
- package/src/devices/AudioDeviceManager.ts +6 -0
- package/src/devices/CameraManager.ts +67 -8
- package/src/devices/DeviceManager.ts +24 -4
- package/src/devices/MicrophoneManager.ts +66 -8
- package/src/devices/ScreenShareManager.ts +3 -0
- package/src/devices/SpeakerManager.ts +42 -12
- package/src/devices/__tests__/CameraManager.test.ts +27 -1
- package/src/devices/__tests__/DeviceManager.test.ts +49 -1
- package/src/devices/__tests__/MicrophoneManager.test.ts +23 -1
- package/src/devices/__tests__/MicrophoneManagerRN.test.ts +1 -20
- package/src/devices/__tests__/SpeakerManager.test.ts +138 -42
- package/src/devices/__tests__/mocks.ts +36 -3
- package/src/events/__tests__/participant.test.ts +134 -1
- package/src/events/call.ts +6 -3
- package/src/events/callEventHandlers.ts +9 -3
- package/src/events/participant.ts +42 -7
- package/src/gen/coordinator/index.ts +1402 -236
- package/src/gen/video/sfu/models/models.ts +15 -0
- package/src/helpers/MediaStreamRecorder.ts +104 -0
- package/src/helpers/TypedEventEmitter.ts +71 -0
- package/src/helpers/__tests__/MediaStreamRecorder.test.ts +324 -0
- package/src/helpers/__tests__/TypedEventEmitter.test.ts +133 -0
- package/src/helpers/__tests__/concurrency.test.ts +74 -0
- package/src/helpers/__tests__/loopback.test.ts +367 -0
- package/src/helpers/concurrency.ts +24 -0
- package/src/helpers/ensureExhausted.ts +4 -2
- package/src/helpers/loopback.ts +200 -0
- package/src/permissions/PermissionsContext.ts +10 -5
- package/src/reporting/ClientEventReporter.ts +15 -28
- package/src/reporting/__tests__/ClientEventReporter.test.ts +3 -7
- package/src/rtc/BasePeerConnection.ts +12 -1
- package/src/rtc/Publisher.ts +8 -0
- package/src/rtc/Subscriber.ts +13 -0
- package/src/rtc/__tests__/Publisher.test.ts +101 -0
- package/src/rtc/__tests__/Subscriber.test.ts +190 -0
- package/src/rtc/__tests__/mocks/webrtc.mocks.ts +31 -0
- package/src/rtc/e2ee/E2EEManager.ts +12 -0
- package/src/rtc/e2ee/EncryptionManager.ts +336 -0
- package/src/rtc/e2ee/SPEC.md +586 -0
- package/src/rtc/e2ee/__tests__/E2EEManager.contract.test.ts +39 -0
- package/src/rtc/e2ee/__tests__/EncryptionManager.test.ts +490 -0
- package/src/rtc/e2ee/__tests__/codec.test.ts +247 -0
- package/src/rtc/e2ee/__tests__/conformance.test.ts +184 -0
- package/src/rtc/e2ee/__tests__/failureTracker.test.ts +45 -0
- package/src/rtc/e2ee/__tests__/frameCounter.test.ts +90 -0
- package/src/rtc/e2ee/__tests__/keyStore.test.ts +221 -0
- package/src/rtc/e2ee/__tests__/notifications.test.ts +166 -0
- package/src/rtc/e2ee/__tests__/perf.test.ts +139 -0
- package/src/rtc/e2ee/__tests__/queue.test.ts +61 -0
- package/src/rtc/e2ee/__tests__/replayWindow.test.ts +133 -0
- package/src/rtc/e2ee/__tests__/trailer.test.ts +143 -0
- package/src/rtc/e2ee/__tests__/transform-pipeline.test.ts +839 -0
- package/src/rtc/e2ee/__tests__/transformSupport.test.ts +65 -0
- package/src/rtc/e2ee/e2ee-worker/codec.ts +220 -0
- package/src/rtc/e2ee/e2ee-worker/constants.ts +47 -0
- package/src/rtc/e2ee/e2ee-worker/decode.ts +159 -0
- package/src/rtc/e2ee/e2ee-worker/e2ee-worker-impl.ts +137 -0
- package/src/rtc/e2ee/e2ee-worker/encode.ts +147 -0
- package/src/rtc/e2ee/e2ee-worker/failureTracker.ts +26 -0
- package/src/rtc/e2ee/e2ee-worker/frameCounter.ts +37 -0
- package/src/rtc/e2ee/e2ee-worker/keyStore.ts +211 -0
- package/src/rtc/e2ee/e2ee-worker/notifications.ts +190 -0
- package/src/rtc/e2ee/e2ee-worker/perf.ts +129 -0
- package/src/rtc/e2ee/e2ee-worker/queue.ts +12 -0
- package/src/rtc/e2ee/e2ee-worker/replayWindow.ts +131 -0
- package/src/rtc/e2ee/e2ee-worker/trailer.ts +155 -0
- package/src/rtc/e2ee/e2ee-worker/tsconfig.json +8 -0
- package/src/rtc/e2ee/e2ee-worker/types.ts +29 -0
- package/src/rtc/e2ee/e2ee-worker.ts +27 -0
- package/src/rtc/e2ee/events.ts +179 -0
- package/src/rtc/e2ee/transformSupport.ts +35 -0
- package/src/rtc/helpers/rtcConfiguration.ts +4 -2
- package/src/rtc/index.ts +1 -0
- package/src/rtc/mediaEngine.ts +44 -0
- package/src/rtc/types.ts +2 -0
- package/src/store/CallState.ts +33 -1
- package/src/store/__tests__/CallState.test.ts +82 -1
- 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
|
-
|
|
126
|
-
|
|
127
|
+
const deviceId = mockAudioOutputDevices[0].deviceId;
|
|
128
|
+
emitDeviceIds([...mockAudioDevices, ...mockAudioOutputDevices]);
|
|
127
129
|
manager.select(deviceId);
|
|
128
130
|
|
|
129
|
-
emitDeviceIds(mockAudioDevices.slice(
|
|
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
|
-
|
|
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
|
-
|
|
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('
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
242
|
+
persist('speaker-1', 'Speaker 1');
|
|
215
243
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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,
|
|
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:
|
|
288
|
+
let deviceIds: ReplaySubject<MediaDeviceInfo[]>;
|
|
256
289
|
export const mockDeviceIds$ = () => {
|
|
257
|
-
deviceIds = new
|
|
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', () => {
|
package/src/events/call.ts
CHANGED
|
@@ -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({
|
|
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
|
|
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
|
|
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 = (
|
|
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 = (
|
|
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(
|
|
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 = (
|
|
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(
|
|
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
|
};
|