Skip to content

Commit e782a2a

Browse files
author
Enrico Schwendig
authored
Enable group calls without video and audio track by configuration of MatrixClient (#3162)
* groupCall: add configuration param to allow no audio and no camera * groupCall: enable datachannel to do no media group calls * groupCall: changed call no media property as object property * groupCall: fix existing unit tests * groupCall: remove not needed flag * groupCall: rename property to allow no media calls * groupCall: mute unmute even without device * groupCall: switch to promise callbacks * groupCall: switch to try catch * test: filter dummy code from coverage * test: extend media mute tests * groupCall: move permission check to device handler * mediaHandler: add error in log statement
1 parent 565339b commit e782a2a

File tree

8 files changed

+123
-16
lines changed

8 files changed

+123
-16
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,4 @@ out
1919

2020
.vscode
2121
.vscode/
22+
.idea/

spec/unit/webrtc/groupCall.spec.ts

+22-3
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ const mockGetStateEvents = (type: EventType, userId?: string): MatrixEvent[] | M
109109
const ONE_HOUR = 1000 * 60 * 60;
110110

111111
const createAndEnterGroupCall = async (cli: MatrixClient, room: Room): Promise<GroupCall> => {
112-
const groupCall = new GroupCall(cli, room, GroupCallType.Video, false, GroupCallIntent.Prompt, FAKE_CONF_ID);
112+
const groupCall = new GroupCall(cli, room, GroupCallType.Video, false, GroupCallIntent.Prompt, false, FAKE_CONF_ID);
113113

114114
await groupCall.create();
115115
await groupCall.enter();
@@ -135,7 +135,7 @@ describe("Group Call", function () {
135135
mockClient = typedMockClient as unknown as MatrixClient;
136136

137137
room = new Room(FAKE_ROOM_ID, mockClient, FAKE_USER_ID_1);
138-
groupCall = new GroupCall(mockClient, room, GroupCallType.Video, false, GroupCallIntent.Prompt);
138+
groupCall = new GroupCall(mockClient, room, GroupCallType.Video, false, GroupCallIntent.Prompt, false);
139139
room.currentState.members[FAKE_USER_ID_1] = {
140140
userId: FAKE_USER_ID_1,
141141
membership: "join",
@@ -484,7 +484,7 @@ describe("Group Call", function () {
484484
describe("PTT calls", () => {
485485
beforeEach(async () => {
486486
// replace groupcall with a PTT one
487-
groupCall = new GroupCall(mockClient, room, GroupCallType.Video, true, GroupCallIntent.Prompt);
487+
groupCall = new GroupCall(mockClient, room, GroupCallType.Video, true, GroupCallIntent.Prompt, false);
488488

489489
await groupCall.create();
490490

@@ -647,6 +647,7 @@ describe("Group Call", function () {
647647
GroupCallType.Video,
648648
false,
649649
GroupCallIntent.Prompt,
650+
false,
650651
FAKE_CONF_ID,
651652
);
652653

@@ -656,6 +657,7 @@ describe("Group Call", function () {
656657
GroupCallType.Video,
657658
false,
658659
GroupCallIntent.Prompt,
660+
false,
659661
FAKE_CONF_ID,
660662
);
661663
});
@@ -882,11 +884,27 @@ describe("Group Call", function () {
882884
expect(await groupCall.setMicrophoneMuted(false)).toBe(false);
883885
});
884886

887+
it("returns false when no permission for audio stream", async () => {
888+
const groupCall = await createAndEnterGroupCall(mockClient, room);
889+
jest.spyOn(mockClient.getMediaHandler(), "getUserMediaStream").mockRejectedValueOnce(
890+
new Error("No Permission"),
891+
);
892+
expect(await groupCall.setMicrophoneMuted(false)).toBe(false);
893+
});
894+
885895
it("returns false when unmuting video with no video device", async () => {
886896
const groupCall = await createAndEnterGroupCall(mockClient, room);
887897
jest.spyOn(mockClient.getMediaHandler(), "hasVideoDevice").mockResolvedValue(false);
888898
expect(await groupCall.setLocalVideoMuted(false)).toBe(false);
889899
});
900+
901+
it("returns false when no permission for video stream", async () => {
902+
const groupCall = await createAndEnterGroupCall(mockClient, room);
903+
jest.spyOn(mockClient.getMediaHandler(), "getUserMediaStream").mockRejectedValueOnce(
904+
new Error("No Permission"),
905+
);
906+
expect(await groupCall.setLocalVideoMuted(false)).toBe(false);
907+
});
890908
});
891909

892910
describe("remote muting", () => {
@@ -1465,6 +1483,7 @@ describe("Group Call", function () {
14651483
GroupCallType.Video,
14661484
false,
14671485
GroupCallIntent.Prompt,
1486+
false,
14681487
FAKE_CONF_ID,
14691488
);
14701489
await groupCall.create();

spec/unit/webrtc/mediaHandler.spec.ts

+10
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,11 @@ describe("Media Handler", function () {
242242
);
243243
expect(await mediaHandler.hasAudioDevice()).toEqual(false);
244244
});
245+
246+
it("returns false if the system not permitting access audio inputs", async () => {
247+
mockMediaDevices.enumerateDevices.mockRejectedValueOnce(new Error("No Permission"));
248+
expect(await mediaHandler.hasAudioDevice()).toEqual(false);
249+
});
245250
});
246251

247252
describe("hasVideoDevice", () => {
@@ -255,6 +260,11 @@ describe("Media Handler", function () {
255260
);
256261
expect(await mediaHandler.hasVideoDevice()).toEqual(false);
257262
});
263+
264+
it("returns false if the system not permitting access video inputs", async () => {
265+
mockMediaDevices.enumerateDevices.mockRejectedValueOnce(new Error("No Permission"));
266+
expect(await mediaHandler.hasVideoDevice()).toEqual(false);
267+
});
258268
});
259269

260270
describe("getUserMediaStream", () => {

src/client.ts

+13-1
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,13 @@ export interface ICreateClientOpts {
371371
* Defaults to a built-in English handler with basic pluralisation.
372372
*/
373373
roomNameGenerator?: (roomId: string, state: RoomNameState) => string | null;
374+
375+
/**
376+
* If true, participant can join group call without video and audio this has to be allowed. By default, a local
377+
* media stream is needed to establish a group call.
378+
* Default: false.
379+
*/
380+
isVoipWithNoMediaAllowed?: boolean;
374381
}
375382

376383
export interface IMatrixClientCreateOpts extends ICreateClientOpts {
@@ -1169,6 +1176,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
11691176
public iceCandidatePoolSize = 0; // XXX: Intended private, used in code.
11701177
public idBaseUrl?: string;
11711178
public baseUrl: string;
1179+
public readonly isVoipWithNoMediaAllowed;
11721180

11731181
// Note: these are all `protected` to let downstream consumers make mistakes if they want to.
11741182
// We don't technically support this usage, but have reasons to do this.
@@ -1313,6 +1321,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
13131321
this.iceCandidatePoolSize = opts.iceCandidatePoolSize === undefined ? 0 : opts.iceCandidatePoolSize;
13141322
this.supportsCallTransfer = opts.supportsCallTransfer || false;
13151323
this.fallbackICEServerAllowed = opts.fallbackICEServerAllowed || false;
1324+
this.isVoipWithNoMediaAllowed = opts.isVoipWithNoMediaAllowed || false;
13161325

13171326
if (opts.useE2eForGroupCall !== undefined) this.useE2eForGroupCall = opts.useE2eForGroupCall;
13181327

@@ -1880,14 +1889,17 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
18801889
throw new Error(`Cannot find room ${roomId}`);
18811890
}
18821891

1892+
// Because without Media section a WebRTC connection is not possible, so need a RTCDataChannel to set up a
1893+
// no media WebRTC connection anyway.
18831894
return new GroupCall(
18841895
this,
18851896
room,
18861897
type,
18871898
isPtt,
18881899
intent,
1900+
this.isVoipWithNoMediaAllowed,
18891901
undefined,
1890-
dataChannelsEnabled,
1902+
dataChannelsEnabled || this.isVoipWithNoMediaAllowed,
18911903
dataChannelOptions,
18921904
).create();
18931905
}

src/webrtc/call.ts

+10-1
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,9 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
390390
// Used to keep the timer for the delay before actually stopping our
391391
// video track after muting (see setLocalVideoMuted)
392392
private stopVideoTrackTimer?: ReturnType<typeof setTimeout>;
393+
// Used to allow connection without Video and Audio. To establish a webrtc connection without media a Data channel is
394+
// needed At the moment this property is true if we allow MatrixClient with isVoipWithNoMediaAllowed = true
395+
private readonly isOnlyDataChannelAllowed: boolean;
393396

394397
/**
395398
* Construct a new Matrix Call.
@@ -420,6 +423,8 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
420423
utils.checkObjectHasKeys(server, ["urls"]);
421424
}
422425
this.callId = genCallID();
426+
// If the Client provides calls without audio and video we need a datachannel for a webrtc connection
427+
this.isOnlyDataChannelAllowed = this.client.isVoipWithNoMediaAllowed;
423428
}
424429

425430
/**
@@ -944,7 +949,11 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
944949
// According to previous comments in this file, firefox at some point did not
945950
// add streams until media started arriving on them. Testing latest firefox
946951
// (81 at time of writing), this is no longer a problem, so let's do it the correct way.
947-
if (!remoteStream || remoteStream.getTracks().length === 0) {
952+
//
953+
// For example in case of no media webrtc connections like screen share only call we have to allow webrtc
954+
// connections without remote media. In this case we always use a data channel. At the moment we allow as well
955+
// only data channel as media in the WebRTC connection with this setup here.
956+
if (!this.isOnlyDataChannelAllowed && (!remoteStream || remoteStream.getTracks().length === 0)) {
948957
logger.error(
949958
`Call ${this.callId} initWithInvite() no remote stream or no tracks after setting remote description!`,
950959
);

src/webrtc/groupCall.ts

+49-6
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ export class GroupCall extends TypedEventEmitter<
216216
public type: GroupCallType,
217217
public isPtt: boolean,
218218
public intent: GroupCallIntent,
219+
public readonly allowCallWithoutVideoAndAudio: boolean,
219220
groupCallId?: string,
220221
private dataChannelsEnabled?: boolean,
221222
private dataChannelOptions?: IGroupCallDataChannelOptions,
@@ -374,8 +375,15 @@ export class GroupCall extends TypedEventEmitter<
374375
try {
375376
stream = await this.client.getMediaHandler().getUserMediaStream(true, this.type === GroupCallType.Video);
376377
} catch (error) {
377-
this.state = GroupCallState.LocalCallFeedUninitialized;
378-
throw error;
378+
// If is allowed to join a call without a media stream, then we
379+
// don't throw an error here. But we need an empty Local Feed to establish
380+
// a connection later.
381+
if (this.allowCallWithoutVideoAndAudio) {
382+
stream = new MediaStream();
383+
} else {
384+
this.state = GroupCallState.LocalCallFeedUninitialized;
385+
throw error;
386+
}
379387
}
380388

381389
// The call could've been disposed while we were waiting, and could
@@ -584,6 +592,31 @@ export class GroupCall extends TypedEventEmitter<
584592
logger.log(
585593
`GroupCall ${this.groupCallId} setMicrophoneMuted() (streamId=${this.localCallFeed.stream.id}, muted=${muted})`,
586594
);
595+
596+
// We needed this here to avoid an error in case user join a call without a device.
597+
// I can not use .then .catch functions because linter :-(
598+
try {
599+
if (!muted) {
600+
const stream = await this.client
601+
.getMediaHandler()
602+
.getUserMediaStream(true, !this.localCallFeed.isVideoMuted());
603+
if (stream === null) {
604+
// if case permission denied to get a stream stop this here
605+
/* istanbul ignore next */
606+
logger.log(
607+
`GroupCall ${this.groupCallId} setMicrophoneMuted() no device to receive local stream, muted=${muted}`,
608+
);
609+
return false;
610+
}
611+
}
612+
} catch (e) {
613+
/* istanbul ignore next */
614+
logger.log(
615+
`GroupCall ${this.groupCallId} setMicrophoneMuted() no device or permission to receive local stream, muted=${muted}`,
616+
);
617+
return false;
618+
}
619+
587620
this.localCallFeed.setAudioVideoMuted(muted, null);
588621
// I don't believe its actually necessary to enable these tracks: they
589622
// are the one on the GroupCall's own CallFeed and are cloned before being
@@ -617,14 +650,24 @@ export class GroupCall extends TypedEventEmitter<
617650
}
618651

619652
if (this.localCallFeed) {
653+
/* istanbul ignore next */
620654
logger.log(
621655
`GroupCall ${this.groupCallId} setLocalVideoMuted() (stream=${this.localCallFeed.stream.id}, muted=${muted})`,
622656
);
623657

624-
const stream = await this.client.getMediaHandler().getUserMediaStream(true, !muted);
625-
await this.updateLocalUsermediaStream(stream);
626-
this.localCallFeed.setAudioVideoMuted(null, muted);
627-
setTracksEnabled(this.localCallFeed.stream.getVideoTracks(), !muted);
658+
try {
659+
const stream = await this.client.getMediaHandler().getUserMediaStream(true, !muted);
660+
await this.updateLocalUsermediaStream(stream);
661+
this.localCallFeed.setAudioVideoMuted(null, muted);
662+
setTracksEnabled(this.localCallFeed.stream.getVideoTracks(), !muted);
663+
} catch (_) {
664+
// No permission to video device
665+
/* istanbul ignore next */
666+
logger.log(
667+
`GroupCall ${this.groupCallId} setLocalVideoMuted() no device or permission to receive local stream, muted=${muted}`,
668+
);
669+
return false;
670+
}
628671
} else {
629672
logger.log(`GroupCall ${this.groupCallId} setLocalVideoMuted() no stream muted (muted=${muted})`);
630673
this.initWithVideoMuted = muted;

src/webrtc/groupCallEventHandler.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -183,8 +183,11 @@ export class GroupCallEventHandler {
183183
callType,
184184
isPtt,
185185
callIntent,
186+
this.client.isVoipWithNoMediaAllowed,
186187
groupCallId,
187-
content?.dataChannelsEnabled,
188+
// Because without Media section a WebRTC connection is not possible, so need a RTCDataChannel to set up a
189+
// no media WebRTC connection anyway.
190+
content?.dataChannelsEnabled || this.client.isVoipWithNoMediaAllowed,
188191
dataChannelOptions,
189192
);
190193

src/webrtc/mediaHandler.ts

+14-4
Original file line numberDiff line numberDiff line change
@@ -185,13 +185,23 @@ export class MediaHandler extends TypedEventEmitter<
185185
}
186186

187187
public async hasAudioDevice(): Promise<boolean> {
188-
const devices = await navigator.mediaDevices.enumerateDevices();
189-
return devices.filter((device) => device.kind === "audioinput").length > 0;
188+
try {
189+
const devices = await navigator.mediaDevices.enumerateDevices();
190+
return devices.filter((device) => device.kind === "audioinput").length > 0;
191+
} catch (err) {
192+
logger.log(`MediaHandler hasAudioDevice() calling navigator.mediaDevices.enumerateDevices with error`, err);
193+
return false;
194+
}
190195
}
191196

192197
public async hasVideoDevice(): Promise<boolean> {
193-
const devices = await navigator.mediaDevices.enumerateDevices();
194-
return devices.filter((device) => device.kind === "videoinput").length > 0;
198+
try {
199+
const devices = await navigator.mediaDevices.enumerateDevices();
200+
return devices.filter((device) => device.kind === "videoinput").length > 0;
201+
} catch (err) {
202+
logger.log(`MediaHandler hasVideoDevice() calling navigator.mediaDevices.enumerateDevices with error`, err);
203+
return false;
204+
}
195205
}
196206

197207
/**

0 commit comments

Comments
 (0)