1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-08-09 10:22:46 +03:00

Check permission on mute mic, only if no audio track exists. (#3359)

* check permission only if no audio track

* fix linter issues

* add missing tests for perfect negotiation pattern

* add null case in unit tests for audio muting

* fix issue with type MediaStream

* force right type of mock methode

* format code
This commit is contained in:
Enrico Schwendig
2023-05-15 16:38:43 +02:00
committed by GitHub
parent af38021d28
commit fcbc195fbe
4 changed files with 177 additions and 25 deletions

View File

@@ -25,6 +25,7 @@ import {
CallType, CallType,
CallState, CallState,
CallParty, CallParty,
CallDirection,
} from "../../../src/webrtc/call"; } from "../../../src/webrtc/call";
import { import {
MCallAnswer, MCallAnswer,
@@ -1712,4 +1713,110 @@ describe("Call", function () {
expect(onReplace).toHaveBeenCalled(); expect(onReplace).toHaveBeenCalled();
}); });
}); });
describe("should handle glare in negotiation process", () => {
beforeEach(async () => {
// cut methods not want to test
call.hangup = () => null;
call.isLocalOnHold = () => true;
// @ts-ignore
call.updateRemoteSDPStreamMetadata = jest.fn();
// @ts-ignore
call.getRidOfRTXCodecs = jest.fn();
// @ts-ignore
call.createAnswer = jest.fn().mockResolvedValue({});
// @ts-ignore
call.sendVoipEvent = jest.fn();
});
it("and reject remote offer if not polite and have pending local offer", async () => {
// not polite user == CallDirection.Outbound
call.direction = CallDirection.Outbound;
// have already a local offer
// @ts-ignore
call.makingOffer = true;
const offerEvent = makeMockEvent("@test:foo", {
description: {
type: "offer",
sdp: DUMMY_SDP,
},
});
// @ts-ignore
call.peerConn = {
signalingState: "have-local-offer",
setRemoteDescription: jest.fn(),
};
await call.onNegotiateReceived(offerEvent);
expect(call.peerConn?.setRemoteDescription).not.toHaveBeenCalled();
});
it("and not reject remote offer if not polite and do have pending answer", async () => {
// not polite user == CallDirection.Outbound
call.direction = CallDirection.Outbound;
// have not a local offer
// @ts-ignore
call.makingOffer = false;
// If we have a setRemoteDescription() answer operation pending, then
// we will be "stable" by the time the next setRemoteDescription() is
// executed, so we count this being readyForOffer when deciding whether to
// ignore the offer.
// @ts-ignore
call.isSettingRemoteAnswerPending = true;
const offerEvent = makeMockEvent("@test:foo", {
description: {
type: "offer",
sdp: DUMMY_SDP,
},
});
// @ts-ignore
call.peerConn = {
signalingState: "have-local-offer",
setRemoteDescription: jest.fn(),
};
await call.onNegotiateReceived(offerEvent);
expect(call.peerConn?.setRemoteDescription).toHaveBeenCalled();
});
it("and not reject remote offer if not polite and do not have pending local offer", async () => {
// not polite user == CallDirection.Outbound
call.direction = CallDirection.Outbound;
// have no local offer
// @ts-ignore
call.makingOffer = false;
const offerEvent = makeMockEvent("@test:foo", {
description: {
type: "offer",
sdp: DUMMY_SDP,
},
});
// @ts-ignore
call.peerConn = {
signalingState: "stable",
setRemoteDescription: jest.fn(),
};
await call.onNegotiateReceived(offerEvent);
expect(call.peerConn?.setRemoteDescription).toHaveBeenCalled();
});
it("and if polite do rollback pending local offer", async () => {
// polite user == CallDirection.Inbound
call.direction = CallDirection.Inbound;
// have already a local offer
// @ts-ignore
call.makingOffer = true;
const offerEvent = makeMockEvent("@test:foo", {
description: {
type: "offer",
sdp: DUMMY_SDP,
},
});
// @ts-ignore
call.peerConn = {
signalingState: "have-local-offer",
setRemoteDescription: jest.fn(),
};
await call.onNegotiateReceived(offerEvent);
expect(call.peerConn?.setRemoteDescription).toHaveBeenCalled();
});
});
}); });

View File

@@ -517,8 +517,7 @@ describe("Group Call", function () {
await groupCall.setMicrophoneMuted(false); await groupCall.setMicrophoneMuted(false);
expect(groupCall.isMicrophoneMuted()).toEqual(false); expect(groupCall.isMicrophoneMuted()).toEqual(false);
jest.advanceTimersByTime(groupCall.pttMaxTransmitTime + 100); await jest.advanceTimersByTimeAsync(groupCall.pttMaxTransmitTime + 100);
expect(groupCall.isMicrophoneMuted()).toEqual(true); expect(groupCall.isMicrophoneMuted()).toEqual(true);
}); });
@@ -585,7 +584,15 @@ describe("Group Call", function () {
}); });
mockCall.sendMetadataUpdate = jest.fn().mockReturnValue(metadataUpdatePromise); mockCall.sendMetadataUpdate = jest.fn().mockReturnValue(metadataUpdatePromise);
const getUserMediaStreamFlush = Promise.resolve("stream");
// @ts-ignore
mockCall.cleint = {
getMediaHandler: {
getUserMediaStream: jest.fn().mockReturnValue(getUserMediaStreamFlush),
},
};
const mutePromise = groupCall.setMicrophoneMuted(true); const mutePromise = groupCall.setMicrophoneMuted(true);
await getUserMediaStreamFlush;
// we should be muted at this point, before the metadata update has been sent // we should be muted at this point, before the metadata update has been sent
expect(groupCall.isMicrophoneMuted()).toEqual(true); expect(groupCall.isMicrophoneMuted()).toEqual(true);
expect(mockCall.localUsermediaFeed.setAudioVideoMuted).toHaveBeenCalled(); expect(mockCall.localUsermediaFeed.setAudioVideoMuted).toHaveBeenCalled();
@@ -892,14 +899,34 @@ describe("Group Call", function () {
expect(await groupCall.setMicrophoneMuted(false)).toBe(false); expect(await groupCall.setMicrophoneMuted(false)).toBe(false);
}); });
it("returns false when no permission for audio stream", async () => { it("returns false when no permission for audio stream and localCallFeed do not have an audio track", async () => {
const groupCall = await createAndEnterGroupCall(mockClient, room); const groupCall = await createAndEnterGroupCall(mockClient, room);
// @ts-ignore
jest.spyOn(groupCall.localCallFeed, "hasAudioTrack", "get").mockReturnValue(false);
jest.spyOn(mockClient.getMediaHandler(), "getUserMediaStream").mockRejectedValueOnce( jest.spyOn(mockClient.getMediaHandler(), "getUserMediaStream").mockRejectedValueOnce(
new Error("No Permission"), new Error("No Permission"),
); );
expect(await groupCall.setMicrophoneMuted(false)).toBe(false); expect(await groupCall.setMicrophoneMuted(false)).toBe(false);
}); });
it("returns false when user media stream null", async () => {
const groupCall = await createAndEnterGroupCall(mockClient, room);
// @ts-ignore
jest.spyOn(groupCall.localCallFeed, "hasAudioTrack", "get").mockReturnValue(false);
// @ts-ignore
jest.spyOn(mockClient.getMediaHandler(), "getUserMediaStream").mockResolvedValue({} as MediaStream);
expect(await groupCall.setMicrophoneMuted(false)).toBe(false);
});
it("returns true when no permission for audio stream but localCallFeed has a audio track already", async () => {
const groupCall = await createAndEnterGroupCall(mockClient, room);
// @ts-ignore
jest.spyOn(groupCall.localCallFeed, "hasAudioTrack", "get").mockReturnValue(true);
jest.spyOn(mockClient.getMediaHandler(), "getUserMediaStream");
expect(mockClient.getMediaHandler().getUserMediaStream).not.toHaveBeenCalled();
expect(await groupCall.setMicrophoneMuted(false)).toBe(true);
});
it("returns false when unmuting video with no video device", async () => { it("returns false when unmuting video with no video device", async () => {
const groupCall = await createAndEnterGroupCall(mockClient, room); const groupCall = await createAndEnterGroupCall(mockClient, room);
jest.spyOn(mockClient.getMediaHandler(), "hasVideoDevice").mockResolvedValue(false); jest.spyOn(mockClient.getMediaHandler(), "hasVideoDevice").mockResolvedValue(false);

View File

@@ -128,7 +128,7 @@ export class CallFeed extends TypedEventEmitter<CallFeedEvent, EventHandlerMap>
this.emit(CallFeedEvent.ConnectedChanged, this.connected); this.emit(CallFeedEvent.ConnectedChanged, this.connected);
} }
private get hasAudioTrack(): boolean { public get hasAudioTrack(): boolean {
return this.stream.getAudioTracks().length > 0; return this.stream.getAudioTracks().length > 0;
} }

View File

@@ -655,27 +655,9 @@ export class GroupCall extends TypedEventEmitter<
`GroupCall ${this.groupCallId} setMicrophoneMuted() (streamId=${this.localCallFeed.stream.id}, muted=${muted})`, `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. const hasPermission = await this.checkAudioPermissionIfNecessary(muted);
// I can not use .then .catch functions because linter :-(
try { if (!hasPermission) {
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; return false;
} }
@@ -700,6 +682,42 @@ export class GroupCall extends TypedEventEmitter<
return true; return true;
} }
/**
* If we allow entering a call without a camera and without video, it can happen that the access rights to the
* devices have not yet been queried. If a stream does not yet have an audio track, we assume that the rights have
* not yet been checked.
*
* `this.client.getMediaHandler().getUserMediaStream` clones the current stream, so it only wanted to be called when
* not Audio Track exists.
* As such, this is a compromise, because, the access rights should always be queried before the call.
*/
private async checkAudioPermissionIfNecessary(muted: boolean): Promise<boolean> {
// We needed this here to avoid an error in case user join a call without a device.
try {
if (!muted && this.localCallFeed && !this.localCallFeed.hasAudioTrack) {
const stream = await this.client
.getMediaHandler()
.getUserMediaStream(true, !this.localCallFeed.isVideoMuted());
if (stream?.getTracks().length === 0) {
// 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;
}
return true;
}
/** /**
* Sets the mute state of the local participants's video. * Sets the mute state of the local participants's video.
* @param muted - Whether to mute the video * @param muted - Whether to mute the video