Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enable group calls without video and audio track by configuration of MatrixClient #3162

Merged
merged 16 commits into from
Mar 2, 2023
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ out

.vscode
.vscode/
.idea/
25 changes: 22 additions & 3 deletions spec/unit/webrtc/groupCall.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ const mockGetStateEvents = (type: EventType, userId?: string): MatrixEvent[] | M
const ONE_HOUR = 1000 * 60 * 60;

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

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

room = new Room(FAKE_ROOM_ID, mockClient, FAKE_USER_ID_1);
groupCall = new GroupCall(mockClient, room, GroupCallType.Video, false, GroupCallIntent.Prompt);
groupCall = new GroupCall(mockClient, room, GroupCallType.Video, false, GroupCallIntent.Prompt, false);
room.currentState.members[FAKE_USER_ID_1] = {
userId: FAKE_USER_ID_1,
membership: "join",
Expand Down Expand Up @@ -484,7 +484,7 @@ describe("Group Call", function () {
describe("PTT calls", () => {
beforeEach(async () => {
// replace groupcall with a PTT one
groupCall = new GroupCall(mockClient, room, GroupCallType.Video, true, GroupCallIntent.Prompt);
groupCall = new GroupCall(mockClient, room, GroupCallType.Video, true, GroupCallIntent.Prompt, false);

await groupCall.create();

Expand Down Expand Up @@ -647,6 +647,7 @@ describe("Group Call", function () {
GroupCallType.Video,
false,
GroupCallIntent.Prompt,
false,
FAKE_CONF_ID,
);

Expand All @@ -656,6 +657,7 @@ describe("Group Call", function () {
GroupCallType.Video,
false,
GroupCallIntent.Prompt,
false,
FAKE_CONF_ID,
);
});
Expand Down Expand Up @@ -882,11 +884,27 @@ describe("Group Call", function () {
expect(await groupCall.setMicrophoneMuted(false)).toBe(false);
});

it("returns false when no permission for audio stream", async () => {
const groupCall = await createAndEnterGroupCall(mockClient, room);
jest.spyOn(mockClient.getMediaHandler(), "getUserMediaStream").mockRejectedValueOnce(
new Error("No Permission"),
);
expect(await groupCall.setMicrophoneMuted(false)).toBe(false);
SimonBrandner marked this conversation as resolved.
Show resolved Hide resolved
});

it("returns false when unmuting video with no video device", async () => {
const groupCall = await createAndEnterGroupCall(mockClient, room);
jest.spyOn(mockClient.getMediaHandler(), "hasVideoDevice").mockResolvedValue(false);
expect(await groupCall.setLocalVideoMuted(false)).toBe(false);
});

it("returns false when no permission for video stream", async () => {
const groupCall = await createAndEnterGroupCall(mockClient, room);
jest.spyOn(mockClient.getMediaHandler(), "getUserMediaStream").mockRejectedValueOnce(
new Error("No Permission"),
);
expect(await groupCall.setLocalVideoMuted(false)).toBe(false);
SimonBrandner marked this conversation as resolved.
Show resolved Hide resolved
});
});

describe("remote muting", () => {
Expand Down Expand Up @@ -1465,6 +1483,7 @@ describe("Group Call", function () {
GroupCallType.Video,
false,
GroupCallIntent.Prompt,
false,
FAKE_CONF_ID,
);
await groupCall.create();
Expand Down
10 changes: 10 additions & 0 deletions spec/unit/webrtc/mediaHandler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,11 @@ describe("Media Handler", function () {
);
expect(await mediaHandler.hasAudioDevice()).toEqual(false);
});

it("returns false if the system not permitting access audio inputs", async () => {
mockMediaDevices.enumerateDevices.mockRejectedValueOnce(new Error("No Permission"));
expect(await mediaHandler.hasAudioDevice()).toEqual(false);
});
});

describe("hasVideoDevice", () => {
Expand All @@ -255,6 +260,11 @@ describe("Media Handler", function () {
);
expect(await mediaHandler.hasVideoDevice()).toEqual(false);
});

it("returns false if the system not permitting access video inputs", async () => {
mockMediaDevices.enumerateDevices.mockRejectedValueOnce(new Error("No Permission"));
expect(await mediaHandler.hasVideoDevice()).toEqual(false);
});
});

describe("getUserMediaStream", () => {
Expand Down
14 changes: 13 additions & 1 deletion src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,13 @@ export interface ICreateClientOpts {
* Defaults to a built-in English handler with basic pluralisation.
*/
roomNameGenerator?: (roomId: string, state: RoomNameState) => string | null;

/**
* If true, participant can join group call without video and audio this has to be allowed. By default, a local
* media stream is needed to establish a group call.
* Default: false.
*/
isVoipWithNoMediaAllowed?: boolean;
}

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

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

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

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

// Because without Media section a WebRTC connection is not possible, so need a RTCDataChannel to set up a
// no media WebRTC connection anyway.
return new GroupCall(
this,
room,
type,
isPtt,
intent,
this.isVoipWithNoMediaAllowed,
undefined,
dataChannelsEnabled,
dataChannelsEnabled || this.isVoipWithNoMediaAllowed,
dataChannelOptions,
).create();
}
Expand Down
11 changes: 10 additions & 1 deletion src/webrtc/call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,9 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
// Used to keep the timer for the delay before actually stopping our
// video track after muting (see setLocalVideoMuted)
private stopVideoTrackTimer?: ReturnType<typeof setTimeout>;
// Used to allow connection without Video and Audio. To establish a webrtc connection without media a Data channel is
// needed At the moment this property is true if we allow MatrixClient with isVoipWithNoMediaAllowed = true
private readonly isOnlyDataChannelAllowed: boolean;

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

/**
Expand Down Expand Up @@ -944,7 +949,11 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
// According to previous comments in this file, firefox at some point did not
// add streams until media started arriving on them. Testing latest firefox
// (81 at time of writing), this is no longer a problem, so let's do it the correct way.
if (!remoteStream || remoteStream.getTracks().length === 0) {
//
// For example in case of no media webrtc connections like screen share only call we have to allow webrtc
// connections without remote media. In this case we always use a data channel. At the moment we allow as well
// only data channel as media in the WebRTC connection with this setup here.
if (!this.isOnlyDataChannelAllowed && (!remoteStream || remoteStream.getTracks().length === 0)) {
logger.error(
`Call ${this.callId} initWithInvite() no remote stream or no tracks after setting remote description!`,
);
Expand Down
55 changes: 49 additions & 6 deletions src/webrtc/groupCall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ export class GroupCall extends TypedEventEmitter<
public type: GroupCallType,
public isPtt: boolean,
public intent: GroupCallIntent,
public readonly allowCallWithoutVideoAndAudio: boolean,
groupCallId?: string,
private dataChannelsEnabled?: boolean,
private dataChannelOptions?: IGroupCallDataChannelOptions,
Expand Down Expand Up @@ -374,8 +375,15 @@ export class GroupCall extends TypedEventEmitter<
try {
stream = await this.client.getMediaHandler().getUserMediaStream(true, this.type === GroupCallType.Video);
} catch (error) {
this.state = GroupCallState.LocalCallFeedUninitialized;
throw error;
// If is allowed to join a call without a media stream, then we
// don't throw an error here. But we need an empty Local Feed to establish
// a connection later.
if (this.allowCallWithoutVideoAndAudio) {
stream = new MediaStream();
} else {
this.state = GroupCallState.LocalCallFeedUninitialized;
throw error;
}
}

// The call could've been disposed while we were waiting, and could
Expand Down Expand Up @@ -584,6 +592,31 @@ export class GroupCall extends TypedEventEmitter<
logger.log(
`GroupCall ${this.groupCallId} setMicrophoneMuted() (streamId=${this.localCallFeed.stream.id}, muted=${muted})`,
);

// We needed this here to avoid an error in case user join a call without a device.
// I can not use .then .catch functions because linter :-(
try {
if (!muted) {
const stream = await this.client
.getMediaHandler()
.getUserMediaStream(true, !this.localCallFeed.isVideoMuted());
if (stream === null) {
// if case permission denied to get a stream stop this here
/* istanbul ignore next */
logger.log(
`GroupCall ${this.groupCallId} setMicrophoneMuted() no device to receive local stream, muted=${muted}`,
);
return false;
}
}
} catch (e) {
/* istanbul ignore next */
logger.log(
`GroupCall ${this.groupCallId} setMicrophoneMuted() no device or permission to receive local stream, muted=${muted}`,
);
return false;
}

this.localCallFeed.setAudioVideoMuted(muted, null);
// I don't believe its actually necessary to enable these tracks: they
// are the one on the GroupCall's own CallFeed and are cloned before being
Expand Down Expand Up @@ -617,14 +650,24 @@ export class GroupCall extends TypedEventEmitter<
}

if (this.localCallFeed) {
/* istanbul ignore next */
logger.log(
`GroupCall ${this.groupCallId} setLocalVideoMuted() (stream=${this.localCallFeed.stream.id}, muted=${muted})`,
);

const stream = await this.client.getMediaHandler().getUserMediaStream(true, !muted);
await this.updateLocalUsermediaStream(stream);
this.localCallFeed.setAudioVideoMuted(null, muted);
setTracksEnabled(this.localCallFeed.stream.getVideoTracks(), !muted);
try {
const stream = await this.client.getMediaHandler().getUserMediaStream(true, !muted);
await this.updateLocalUsermediaStream(stream);
this.localCallFeed.setAudioVideoMuted(null, muted);
setTracksEnabled(this.localCallFeed.stream.getVideoTracks(), !muted);
} catch (_) {
// No permission to video device
/* istanbul ignore next */
logger.log(
`GroupCall ${this.groupCallId} setLocalVideoMuted() no device or permission to receive local stream, muted=${muted}`,
);
return false;
}
} else {
logger.log(`GroupCall ${this.groupCallId} setLocalVideoMuted() no stream muted (muted=${muted})`);
this.initWithVideoMuted = muted;
Expand Down
5 changes: 4 additions & 1 deletion src/webrtc/groupCallEventHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,8 +183,11 @@ export class GroupCallEventHandler {
callType,
isPtt,
callIntent,
this.client.isVoipWithNoMediaAllowed,
groupCallId,
content?.dataChannelsEnabled,
// Because without Media section a WebRTC connection is not possible, so need a RTCDataChannel to set up a
// no media WebRTC connection anyway.
content?.dataChannelsEnabled || this.client.isVoipWithNoMediaAllowed,
dataChannelOptions,
);

Expand Down
18 changes: 14 additions & 4 deletions src/webrtc/mediaHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,13 +185,23 @@ export class MediaHandler extends TypedEventEmitter<
}

public async hasAudioDevice(): Promise<boolean> {
const devices = await navigator.mediaDevices.enumerateDevices();
return devices.filter((device) => device.kind === "audioinput").length > 0;
try {
const devices = await navigator.mediaDevices.enumerateDevices();
return devices.filter((device) => device.kind === "audioinput").length > 0;
} catch (err) {
logger.log(`MediaHandler hasAudioDevice() calling navigator.mediaDevices.enumerateDevices with error`, err);
return false;
}
}

public async hasVideoDevice(): Promise<boolean> {
const devices = await navigator.mediaDevices.enumerateDevices();
return devices.filter((device) => device.kind === "videoinput").length > 0;
try {
const devices = await navigator.mediaDevices.enumerateDevices();
return devices.filter((device) => device.kind === "videoinput").length > 0;
} catch (err) {
logger.log(`MediaHandler hasVideoDevice() calling navigator.mediaDevices.enumerateDevices with error`, err);
return false;
}
}

/**
Expand Down