diff --git a/spec/test-utils/webrtc.ts b/spec/test-utils/webrtc.ts index 6a1b1a35794..c2f1a0b25a3 100644 --- a/spec/test-utils/webrtc.ts +++ b/spec/test-utils/webrtc.ts @@ -26,6 +26,7 @@ import { MatrixClient, MatrixEvent, Room, + RoomMember, RoomState, RoomStateEvent, RoomStateEventHandlerMap, @@ -33,7 +34,7 @@ import { import { TypedEventEmitter } from "../../src/models/typed-event-emitter"; import { ReEmitter } from "../../src/ReEmitter"; import { SyncState } from "../../src/sync"; -import { CallEvent, CallEventHandlerMap, MatrixCall } from "../../src/webrtc/call"; +import { CallEvent, CallEventHandlerMap, CallState, MatrixCall } from "../../src/webrtc/call"; import { CallEventHandlerEvent, CallEventHandlerEventHandlerMap } from "../../src/webrtc/callEventHandler"; import { CallFeed } from "../../src/webrtc/callFeed"; import { GroupCallEventHandlerMap } from "../../src/webrtc/groupCall"; @@ -83,6 +84,17 @@ export const DUMMY_SDP = ( export const USERMEDIA_STREAM_ID = "mock_stream_from_media_handler"; export const SCREENSHARE_STREAM_ID = "mock_screen_stream_from_media_handler"; +export const FAKE_ROOM_ID = "!fake:test.dummy"; +export const FAKE_CONF_ID = "fakegroupcallid"; + +export const FAKE_USER_ID_1 = "@alice:test.dummy"; +export const FAKE_DEVICE_ID_1 = "@AAAAAA"; +export const FAKE_SESSION_ID_1 = "alice1"; +export const FAKE_USER_ID_2 = "@bob:test.dummy"; +export const FAKE_DEVICE_ID_2 = "@BBBBBB"; +export const FAKE_SESSION_ID_2 = "bob1"; +export const FAKE_USER_ID_3 = "@charlie:test.dummy"; + class MockMediaStreamAudioSourceNode { public connect() {} } @@ -431,6 +443,43 @@ export class MockCallMatrixClient extends TypedEventEmitter { + constructor(public roomId: string, public groupCallId?: string) { + super(); + } + + public state = CallState.Ringing; + public opponentUserId = FAKE_USER_ID_1; + public opponentDeviceId = FAKE_DEVICE_ID_1; + public opponentMember = { userId: this.opponentUserId }; + public callId = "1"; + public localUsermediaFeed = { + setAudioVideoMuted: jest.fn(), + stream: new MockMediaStream("stream"), + }; + public remoteUsermediaFeed?: CallFeed; + public remoteScreensharingFeed?: CallFeed; + + public reject = jest.fn(); + public answerWithCallFeeds = jest.fn(); + public hangup = jest.fn(); + + public sendMetadataUpdate = jest.fn(); + + public on = jest.fn(); + public removeListener = jest.fn(); + + public getOpponentMember(): Partial { + return this.opponentMember; + } + + public getOpponentDeviceId(): string | undefined { + return this.opponentDeviceId; + } + + public typed(): MatrixCall { return this as unknown as MatrixCall; } +} + export class MockCallFeed { constructor( public userId: string, diff --git a/spec/unit/webrtc/call.spec.ts b/spec/unit/webrtc/call.spec.ts index ee52a518a36..d1cb5d29616 100644 --- a/spec/unit/webrtc/call.spec.ts +++ b/spec/unit/webrtc/call.spec.ts @@ -1392,7 +1392,7 @@ describe('Call', function() { it("ends call on onHangupReceived() if state is ringing", async () => { expect(call.callHasEnded()).toBe(false); - call.state = CallState.Ringing; + (call as any).state = CallState.Ringing; call.onHangupReceived({} as MCallHangupReject); expect(call.callHasEnded()).toBe(true); @@ -1424,7 +1424,7 @@ describe('Call', function() { )("ends call on onRejectReceived() if in correct state (state=%s)", async (state: CallState) => { expect(call.callHasEnded()).toBe(false); - call.state = state; + (call as any).state = state; call.onRejectReceived({} as MCallHangupReject); expect(call.callHasEnded()).toBe( diff --git a/spec/unit/webrtc/callFeed.spec.ts b/spec/unit/webrtc/callFeed.spec.ts index 635fa14fd8f..e14a1a0c56b 100644 --- a/spec/unit/webrtc/callFeed.spec.ts +++ b/spec/unit/webrtc/callFeed.spec.ts @@ -17,13 +17,30 @@ limitations under the License. import { SDPStreamMetadataPurpose } from "../../../src/webrtc/callEventTypes"; import { CallFeed } from "../../../src/webrtc/callFeed"; import { TestClient } from "../../TestClient"; -import { MockMediaStream, MockMediaStreamTrack } from "../../test-utils/webrtc"; +import { MockMatrixCall, MockMediaStream, MockMediaStreamTrack } from "../../test-utils/webrtc"; +import { CallEvent, CallState } from "../../../src/webrtc/call"; describe("CallFeed", () => { - let client; + const roomId = "room1"; + let client: TestClient; + let call: MockMatrixCall; + let feed: CallFeed; beforeEach(() => { client = new TestClient("@alice:foo", "somedevice", "token", undefined, {}); + call = new MockMatrixCall(roomId); + + feed = new CallFeed({ + client: client.client, + call: call.typed(), + roomId, + userId: "user1", + // @ts-ignore Mock + stream: new MockMediaStream("stream1"), + purpose: SDPStreamMetadataPurpose.Usermedia, + audioMuted: false, + videoMuted: false, + }); }); afterEach(() => { @@ -31,21 +48,6 @@ describe("CallFeed", () => { }); describe("muting", () => { - let feed: CallFeed; - - beforeEach(() => { - feed = new CallFeed({ - client, - roomId: "room1", - userId: "user1", - // @ts-ignore Mock - stream: new MockMediaStream("stream1"), - purpose: SDPStreamMetadataPurpose.Usermedia, - audioMuted: false, - videoMuted: false, - }); - }); - describe("muting by default", () => { it("should mute audio by default", () => { expect(feed.isAudioMuted()).toBeTruthy(); @@ -86,4 +88,23 @@ describe("CallFeed", () => { }); }); }); + + describe("connected", () => { + it.each([true, false])("should always be connected, if isLocal()", (val: boolean) => { + // @ts-ignore + feed._connected = val; + jest.spyOn(feed, "isLocal").mockReturnValue(true); + + expect(feed.connected).toBeTruthy(); + }); + + it.each([ + [CallState.Connected, true], + [CallState.Connecting, false], + ])("should react to call state, when !isLocal()", (state: CallState, expected: Boolean) => { + call.emit(CallEvent.State, state); + + expect(feed.connected).toBe(expected); + }); + }); }); diff --git a/spec/unit/webrtc/groupCall.spec.ts b/spec/unit/webrtc/groupCall.spec.ts index 3c9266b98c8..ad77e15bdc7 100644 --- a/spec/unit/webrtc/groupCall.spec.ts +++ b/spec/unit/webrtc/groupCall.spec.ts @@ -33,6 +33,16 @@ import { MockMediaStream, MockMediaStreamTrack, MockRTCPeerConnection, + MockMatrixCall, + FAKE_ROOM_ID, + FAKE_USER_ID_1, + FAKE_CONF_ID, + FAKE_DEVICE_ID_2, + FAKE_SESSION_ID_2, + FAKE_USER_ID_2, + FAKE_DEVICE_ID_1, + FAKE_SESSION_ID_1, + FAKE_USER_ID_3, } from '../../test-utils/webrtc'; import { SDPStreamMetadataKey, SDPStreamMetadataPurpose } from "../../../src/webrtc/callEventTypes"; import { sleep } from "../../../src/utils"; @@ -41,16 +51,6 @@ import { CallFeed } from '../../../src/webrtc/callFeed'; import { CallEvent, CallState } from '../../../src/webrtc/call'; import { flushPromises } from '../../test-utils/flushPromises'; -const FAKE_ROOM_ID = "!fake:test.dummy"; -const FAKE_CONF_ID = "fakegroupcallid"; - -const FAKE_USER_ID_1 = "@alice:test.dummy"; -const FAKE_DEVICE_ID_1 = "@AAAAAA"; -const FAKE_SESSION_ID_1 = "alice1"; -const FAKE_USER_ID_2 = "@bob:test.dummy"; -const FAKE_DEVICE_ID_2 = "@BBBBBB"; -const FAKE_SESSION_ID_2 = "bob1"; -const FAKE_USER_ID_3 = "@charlie:test.dummy"; const FAKE_STATE_EVENTS = [ { getContent: () => ({ @@ -123,42 +123,6 @@ const createAndEnterGroupCall = async (cli: MatrixClient, room: Room): Promise(), - stream: new MockMediaStream("stream"), - }; - public remoteUsermediaFeed?: CallFeed; - public remoteScreensharingFeed?: CallFeed; - - public reject = jest.fn(); - public answerWithCallFeeds = jest.fn(); - public hangup = jest.fn(); - - public sendMetadataUpdate = jest.fn(); - - public on = jest.fn(); - public removeListener = jest.fn(); - - public getOpponentMember(): Partial { - return this.opponentMember; - } - - public getOpponentDeviceId(): string { - return this.opponentDeviceId; - } - - public typed(): MatrixCall { return this as unknown as MatrixCall; } -} - describe('Group Call', function() { beforeEach(function() { installWebRTCMocks(); @@ -351,7 +315,7 @@ describe('Group Call', function() { }); describe("call feeds changing", () => { - let call: MockCall; + let call: MockMatrixCall; const currentFeed = new MockCallFeed(FAKE_USER_ID_1, FAKE_DEVICE_ID_1, new MockMediaStream("current")); const newFeed = new MockCallFeed(FAKE_USER_ID_1, FAKE_DEVICE_ID_1, new MockMediaStream("new")); @@ -361,13 +325,13 @@ describe('Group Call', function() { jest.spyOn(groupCall, "emit"); - call = new MockCall(room.roomId, groupCall.groupCallId); + call = new MockMatrixCall(room.roomId, groupCall.groupCallId); await groupCall.create(); }); it("ignores changes, if we can't get user id of opponent", async () => { - const call = new MockCall(room.roomId, groupCall.groupCallId); + const call = new MockMatrixCall(room.roomId, groupCall.groupCallId); jest.spyOn(call, "getOpponentMember").mockReturnValue({ userId: undefined }); // @ts-ignore Mock @@ -514,10 +478,11 @@ describe('Group Call', function() { }); it("sends metadata updates before unmuting in PTT mode", async () => { - const mockCall = new MockCall(FAKE_ROOM_ID, groupCall.groupCallId); + const mockCall = new MockMatrixCall(FAKE_ROOM_ID, groupCall.groupCallId); + // @ts-ignore groupCall.calls.set( mockCall.getOpponentMember() as RoomMember, - new Map([[mockCall.getOpponentDeviceId(), mockCall.typed()]]), + new Map([[mockCall.getOpponentDeviceId()!, mockCall.typed()]]), ); let metadataUpdateResolve: () => void; @@ -539,10 +504,11 @@ describe('Group Call', function() { }); it("sends metadata updates after muting in PTT mode", async () => { - const mockCall = new MockCall(FAKE_ROOM_ID, groupCall.groupCallId); + const mockCall = new MockMatrixCall(FAKE_ROOM_ID, groupCall.groupCallId); + // @ts-ignore groupCall.calls.set( mockCall.getOpponentMember() as RoomMember, - new Map([[mockCall.getOpponentDeviceId(), mockCall.typed()]]), + new Map([[mockCall.getOpponentDeviceId()!, mockCall.typed()]]), ); // the call starts muted, so unmute to get in the right state to test @@ -698,6 +664,7 @@ describe('Group Call', function() { expect(client1.sendToDevice).toHaveBeenCalled(); + // @ts-ignore const oldCall = groupCall1.calls.get( groupCall1.room.getMember(client2.userId)!, )!.get(client2.deviceId)!; @@ -719,6 +686,7 @@ describe('Group Call', function() { // to even be created... let newCall: MatrixCall | undefined; while ( + // @ts-ignore (newCall = groupCall1.calls.get( groupCall1.room.getMember(client2.userId)!, )?.get(client2.deviceId)) === undefined @@ -763,6 +731,7 @@ describe('Group Call', function() { groupCall1.setMicrophoneMuted(false); groupCall1.setLocalVideoMuted(false); + // @ts-ignore const call = groupCall1.calls.get( groupCall1.room.getMember(client2.userId)!, )!.get(client2.deviceId)!; @@ -874,7 +843,10 @@ describe('Group Call', function() { // It takes a bit of time for the calls to get created await sleep(10); - const call = groupCall.calls.get(groupCall.room.getMember(FAKE_USER_ID_2)!)!.get(FAKE_DEVICE_ID_2)!; + // @ts-ignore + const call = groupCall.calls + .get(groupCall.room.getMember(FAKE_USER_ID_2)!)! + .get(FAKE_DEVICE_ID_2)!; call.getOpponentMember = () => ({ userId: call.invitee }) as RoomMember; // @ts-ignore Mock call.pushRemoteFeed(new MockMediaStream("stream", [ @@ -897,7 +869,10 @@ describe('Group Call', function() { // It takes a bit of time for the calls to get created await sleep(10); - const call = groupCall.calls.get(groupCall.room.getMember(FAKE_USER_ID_2)!)!.get(FAKE_DEVICE_ID_2)!; + // @ts-ignore + const call = groupCall.calls + .get(groupCall.room.getMember(FAKE_USER_ID_2)!)! + .get(FAKE_DEVICE_ID_2)!; call.getOpponentMember = () => ({ userId: call.invitee }) as RoomMember; // @ts-ignore Mock call.pushRemoteFeed(new MockMediaStream("stream", [ @@ -939,7 +914,7 @@ describe('Group Call', function() { }); it("ignores incoming calls for other rooms", async () => { - const mockCall = new MockCall("!someotherroom.fake.dummy", groupCall.groupCallId); + const mockCall = new MockMatrixCall("!someotherroom.fake.dummy", groupCall.groupCallId); mockClient.emit(CallEventHandlerEvent.Incoming, mockCall as unknown as MatrixCall); @@ -948,7 +923,7 @@ describe('Group Call', function() { }); it("rejects incoming calls for the wrong group call", async () => { - const mockCall = new MockCall(room.roomId, "not " + groupCall.groupCallId); + const mockCall = new MockMatrixCall(room.roomId, "not " + groupCall.groupCallId); mockClient.emit(CallEventHandlerEvent.Incoming, mockCall as unknown as MatrixCall); @@ -956,7 +931,7 @@ describe('Group Call', function() { }); it("ignores incoming calls not in the ringing state", async () => { - const mockCall = new MockCall(room.roomId, groupCall.groupCallId); + const mockCall = new MockMatrixCall(room.roomId, groupCall.groupCallId); mockCall.state = CallState.Connected; mockClient.emit(CallEventHandlerEvent.Incoming, mockCall as unknown as MatrixCall); @@ -966,12 +941,13 @@ describe('Group Call', function() { }); it("answers calls for the right room & group call ID", async () => { - const mockCall = new MockCall(room.roomId, groupCall.groupCallId); + const mockCall = new MockMatrixCall(room.roomId, groupCall.groupCallId); mockClient.emit(CallEventHandlerEvent.Incoming, mockCall as unknown as MatrixCall); expect(mockCall.reject).not.toHaveBeenCalled(); expect(mockCall.answerWithCallFeeds).toHaveBeenCalled(); + // @ts-ignore expect(groupCall.calls).toEqual(new Map([[ groupCall.room.getMember(FAKE_USER_ID_1)!, new Map([[FAKE_DEVICE_ID_1, mockCall]]), @@ -979,8 +955,8 @@ describe('Group Call', function() { }); it("replaces calls if it already has one with the same user", async () => { - const oldMockCall = new MockCall(room.roomId, groupCall.groupCallId); - const newMockCall = new MockCall(room.roomId, groupCall.groupCallId); + const oldMockCall = new MockMatrixCall(room.roomId, groupCall.groupCallId); + const newMockCall = new MockMatrixCall(room.roomId, groupCall.groupCallId); newMockCall.opponentMember = oldMockCall.opponentMember; // Ensure referential equality newMockCall.callId = "not " + oldMockCall.callId; @@ -989,6 +965,7 @@ describe('Group Call', function() { expect(oldMockCall.hangup).toHaveBeenCalled(); expect(newMockCall.answerWithCallFeeds).toHaveBeenCalled(); + // @ts-ignore expect(groupCall.calls).toEqual(new Map([[ groupCall.room.getMember(FAKE_USER_ID_1)!, new Map([[FAKE_DEVICE_ID_1, newMockCall]]), @@ -999,7 +976,7 @@ describe('Group Call', function() { // First we leave the call since we have already entered groupCall.leave(); - const call = new MockCall(room.roomId, groupCall.groupCallId); + const call = new MockMatrixCall(room.roomId, groupCall.groupCallId); mockClient.callEventHandler!.calls = new Map([ [call.callId, call.typed()], ]); @@ -1072,7 +1049,10 @@ describe('Group Call', function() { // It takes a bit of time for the calls to get created await sleep(10); - const call = groupCall.calls.get(groupCall.room.getMember(FAKE_USER_ID_2)!)!.get(FAKE_DEVICE_ID_2)!; + // @ts-ignore + const call = groupCall.calls + .get(groupCall.room.getMember(FAKE_USER_ID_2)!)! + .get(FAKE_DEVICE_ID_2)!; call.getOpponentMember = () => ({ userId: call.invitee }) as RoomMember; call.onNegotiateReceived({ getContent: () => ({ diff --git a/src/webrtc/call.ts b/src/webrtc/call.ts index 8b4882c960a..ad5e6668bcd 100644 --- a/src/webrtc/call.ts +++ b/src/webrtc/call.ts @@ -334,7 +334,6 @@ export class MatrixCall extends TypedEventEmitter; @@ -482,6 +482,16 @@ export class MatrixCall extends TypedEventEmitter { @@ -1762,7 +1774,7 @@ export class MatrixCall extends TypedEventEmitter { this.inviteTimeout = undefined; if (this.state === CallState.InviteSent) { @@ -2088,7 +2100,7 @@ export class MatrixCall extends TypedEventEmitter { @@ -2112,7 +2124,7 @@ export class MatrixCall extends TypedEventEmitter void; [CallFeedEvent.LocalVolumeChanged]: (localVolume: number) => void; [CallFeedEvent.VolumeChanged]: (volume: number) => void; + [CallFeedEvent.ConnectedChanged]: (connected: boolean) => void; [CallFeedEvent.Speaking]: (speaking: boolean) => void; [CallFeedEvent.Disposed]: () => void; }; @@ -69,6 +76,7 @@ export class CallFeed extends TypedEventEmitter public speakingVolumeSamples: number[]; private client: MatrixClient; + private call?: MatrixCall; private roomId?: string; private audioMuted: boolean; private videoMuted: boolean; @@ -81,11 +89,13 @@ export class CallFeed extends TypedEventEmitter private speaking = false; private volumeLooperTimeout?: ReturnType; private _disposed = false; + private _connected = false; public constructor(opts: ICallFeedOpts) { super(); this.client = opts.client; + this.call = opts.call; this.roomId = opts.roomId; this.userId = opts.userId; this.deviceId = opts.deviceId; @@ -101,6 +111,21 @@ export class CallFeed extends TypedEventEmitter if (this.hasAudioTrack) { this.initVolumeMeasuring(); } + + if (opts.call) { + opts.call.addListener(CallEvent.State, this.onCallState); + this.onCallState(opts.call.state); + } + } + + public get connected(): boolean { + // Local feeds are always considered connected + return this.isLocal() || this._connected; + } + + private set connected(connected: boolean) { + this._connected = connected; + this.emit(CallFeedEvent.ConnectedChanged, this.connected); } private get hasAudioTrack(): boolean { @@ -145,6 +170,14 @@ export class CallFeed extends TypedEventEmitter this.emit(CallFeedEvent.NewStream, this.stream); }; + private onCallState = (state: CallState): void => { + if (state === CallState.Connected) { + this.connected = true; + } else if (state === CallState.Connecting) { + this.connected = false; + } + }; + /** * Returns callRoom member * @returns member of the callRoom @@ -297,6 +330,7 @@ export class CallFeed extends TypedEventEmitter public dispose(): void { clearTimeout(this.volumeLooperTimeout); this.stream?.removeEventListener("addtrack", this.onAddTrack); + this.call?.removeListener(CallEvent.State, this.onCallState); if (this.audioContext) { this.audioContext = undefined; this.analyser = undefined; diff --git a/src/webrtc/groupCall.ts b/src/webrtc/groupCall.ts index fde46182bc1..5941b6a3728 100644 --- a/src/webrtc/groupCall.ts +++ b/src/webrtc/groupCall.ts @@ -168,11 +168,11 @@ export class GroupCall extends TypedEventEmitter< public localCallFeed?: CallFeed; public localScreenshareFeed?: CallFeed; public localDesktopCapturerSourceId?: string; - public readonly calls = new Map>(); public readonly userMediaFeeds: CallFeed[] = []; public readonly screenshareFeeds: CallFeed[] = []; public groupCallId: string; + private readonly calls = new Map>(); // RoomMember -> device ID -> MatrixCall private callHandlers = new Map>(); // User ID -> device ID -> handlers private activeSpeakerLoopInterval?: ReturnType; private retryCallLoopInterval?: ReturnType;