diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index 7fc4b2f7c..e7e64c9cb 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -26,6 +26,8 @@ const mockFocus = { type: "mock" }; const textEncoder = new TextEncoder(); +const callSession = { id: "", application: "m.call" }; + describe("MatrixRTCSession", () => { let client: MatrixClient; let sess: MatrixRTCSession | undefined; @@ -49,14 +51,14 @@ describe("MatrixRTCSession", () => { it("creates a room-scoped session from room state", () => { const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); expect(sess?.memberships.length).toEqual(1); - expect(sess?.memberships[0].callId).toEqual(""); + expect(sess?.memberships[0].sessionDescription.id).toEqual(""); expect(sess?.memberships[0].scope).toEqual("m.room"); expect(sess?.memberships[0].application).toEqual("m.call"); expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA"); expect(sess?.memberships[0].isExpired()).toEqual(false); - expect(sess?.callId).toEqual(""); + expect(sess?.sessionDescription.id).toEqual(""); }); it("ignores memberships where application is not m.call", () => { @@ -64,7 +66,7 @@ describe("MatrixRTCSession", () => { application: "not-m.call", }); const mockRoom = makeMockRoom([testMembership]); - const sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + const sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); expect(sess?.memberships).toHaveLength(0); }); @@ -74,7 +76,7 @@ describe("MatrixRTCSession", () => { scope: "m.room", }); const mockRoom = makeMockRoom([testMembership]); - const sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + const sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); expect(sess?.memberships).toHaveLength(0); }); @@ -86,7 +88,7 @@ describe("MatrixRTCSession", () => { const mockRoom = makeMockRoom([membershipTemplate, expiredMembership]); jest.advanceTimersByTime(2000); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); expect(sess?.memberships.length).toEqual(1); expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA"); jest.useRealTimers(); @@ -95,7 +97,7 @@ describe("MatrixRTCSession", () => { it("ignores memberships events of members not in the room", () => { const mockRoom = makeMockRoom([membershipTemplate]); mockRoom.hasMembershipState = (state) => state === KnownMembership.Join; - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); expect(sess?.memberships.length).toEqual(0); }); @@ -106,14 +108,14 @@ describe("MatrixRTCSession", () => { expiredMembership.created_ts = 500; expiredMembership.expires = 1000; const mockRoom = makeMockRoom([expiredMembership]); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); expect(sess?.memberships[0].getAbsoluteExpiry()).toEqual(1500); jest.useRealTimers(); }); it("returns empty session if no membership events are present", () => { const mockRoom = makeMockRoom([]); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); expect(sess?.memberships).toHaveLength(0); }); @@ -148,7 +150,7 @@ describe("MatrixRTCSession", () => { }), }), }; - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom as unknown as Room); + sess = MatrixRTCSession.sessionForRoom(client, mockRoom as unknown as Room, callSession); expect(sess.memberships).toHaveLength(0); }); @@ -183,7 +185,7 @@ describe("MatrixRTCSession", () => { }), }), }; - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom as unknown as Room); + sess = MatrixRTCSession.sessionForRoom(client, mockRoom as unknown as Room, callSession); expect(sess.memberships).toHaveLength(0); }); @@ -191,7 +193,7 @@ describe("MatrixRTCSession", () => { const testMembership = Object.assign({}, membershipTemplate); (testMembership.device_id as string | undefined) = undefined; const mockRoom = makeMockRoom([testMembership]); - const sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + const sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); expect(sess.memberships).toHaveLength(0); }); @@ -199,23 +201,7 @@ describe("MatrixRTCSession", () => { const testMembership = Object.assign({}, membershipTemplate); (testMembership.call_id as string | undefined) = undefined; const mockRoom = makeMockRoom([testMembership]); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); - expect(sess.memberships).toHaveLength(0); - }); - - it("ignores memberships with no scope", () => { - const testMembership = Object.assign({}, membershipTemplate); - (testMembership.scope as string | undefined) = undefined; - const mockRoom = makeMockRoom([testMembership]); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); - expect(sess.memberships).toHaveLength(0); - }); - - it("ignores anything that's not a room-scoped call (for now)", () => { - const testMembership = Object.assign({}, membershipTemplate); - testMembership.scope = "m.user"; - const mockRoom = makeMockRoom([testMembership]); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); expect(sess.memberships).toHaveLength(0); }); }); @@ -230,7 +216,7 @@ describe("MatrixRTCSession", () => { Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }), ]); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); expect(sess.getOldestMembership()!.deviceId).toEqual("old"); jest.useRealTimers(); }); @@ -255,7 +241,7 @@ describe("MatrixRTCSession", () => { Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }), ]); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); sess.joinRoomSession([{ type: "livekit", livekit_service_url: "htts://test.org" }], { type: "livekit", @@ -275,7 +261,7 @@ describe("MatrixRTCSession", () => { Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }), ]); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); sess.joinRoomSession([{ type: "livekit", livekit_service_url: "htts://test.org" }], { type: "livekit", @@ -302,7 +288,7 @@ describe("MatrixRTCSession", () => { client._unstable_updateDelayedEvent = jest.fn(); mockRoom = makeMockRoom([]); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); }); afterEach(async () => { @@ -385,7 +371,7 @@ describe("MatrixRTCSession", () => { describe("onMembershipsChanged", () => { it("does not emit if no membership changes", () => { const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); const onMembershipsChanged = jest.fn(); sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged); @@ -396,7 +382,7 @@ describe("MatrixRTCSession", () => { it("emits on membership changes", () => { const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); const onMembershipsChanged = jest.fn(); sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged); @@ -451,7 +437,7 @@ describe("MatrixRTCSession", () => { client.encryptAndSendToDevice = sendToDeviceMock; mockRoom = makeMockRoom([]); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); }); afterEach(async () => { @@ -570,7 +556,7 @@ describe("MatrixRTCSession", () => { device_id: "BBBBBBB", }); const mockRoom = makeMockRoom([membershipTemplate, member2]); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); // joining will trigger an initial key send const keysSentPromise1 = new Promise((resolve) => { @@ -619,7 +605,7 @@ describe("MatrixRTCSession", () => { jest.useFakeTimers(); try { const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); const keysSentPromise1 = new Promise((resolve) => { sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload)); @@ -670,7 +656,7 @@ describe("MatrixRTCSession", () => { const mockRoom = makeMockRoom([member1, member2]); mockRoomState(mockRoom, [member1, member2]); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); await keysSentPromise1; @@ -715,7 +701,7 @@ describe("MatrixRTCSession", () => { }; const mockRoom = makeMockRoom([member1, member2]); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); await keysSentPromise1; @@ -779,7 +765,7 @@ describe("MatrixRTCSession", () => { device_id: "BBBBBBB", }); const mockRoom = makeMockRoom([membershipTemplate, member2]); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); const onMyEncryptionKeyChanged = jest.fn(); sess.on( @@ -869,7 +855,7 @@ describe("MatrixRTCSession", () => { if (i === 0) { // if first time around then set up the session - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); } else { // otherwise update the state reducing the membership each time in order to trigger key rotation @@ -895,7 +881,7 @@ describe("MatrixRTCSession", () => { jest.useFakeTimers(); try { const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); const keysSentPromise1 = new Promise((resolve) => { sendEventMock.mockImplementation(resolve); @@ -936,7 +922,7 @@ describe("MatrixRTCSession", () => { }); const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true, @@ -959,7 +945,7 @@ describe("MatrixRTCSession", () => { describe("receiving", () => { it("collects keys from encryption events", async () => { const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); mockRoom.emitTimelineEvent( makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", { @@ -984,7 +970,7 @@ describe("MatrixRTCSession", () => { it("collects keys at non-zero indices", async () => { const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); mockRoom.emitTimelineEvent( makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", { @@ -1010,7 +996,7 @@ describe("MatrixRTCSession", () => { it("collects keys by merging", async () => { const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); mockRoom.emitTimelineEvent( makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", { @@ -1061,7 +1047,7 @@ describe("MatrixRTCSession", () => { it("ignores older keys at same index", async () => { const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); mockRoom.emitTimelineEvent( makeMockEvent( @@ -1120,7 +1106,7 @@ describe("MatrixRTCSession", () => { it("key timestamps are treated as monotonic", async () => { const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); mockRoom.emitTimelineEvent( makeMockEvent( @@ -1164,7 +1150,7 @@ describe("MatrixRTCSession", () => { it("ignores keys event for the local participant", () => { const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); mockRoom.emitTimelineEvent( @@ -1187,7 +1173,7 @@ describe("MatrixRTCSession", () => { jest.useFakeTimers(); try { const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); // defaults to getTs() jest.setSystemTime(1000); diff --git a/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts b/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts index 377e0eaf0..9472dc16e 100644 --- a/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts @@ -16,8 +16,9 @@ limitations under the License. import { ClientEvent, EventTimeline, MatrixClient } from "../../../src"; import { RoomStateEvent } from "../../../src/models/room-state"; -import { MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc/MatrixRTCSessionManager"; +import { MatrixRTCSessionManager, MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc/MatrixRTCSessionManager"; import { makeMockRoom, membershipTemplate, mockRoomState } from "./mocks"; +import { logger } from "../../../src/logger"; describe("MatrixRTCSessionManager", () => { let client: MatrixClient; @@ -47,6 +48,21 @@ describe("MatrixRTCSessionManager", () => { } }); + it("Doesn't fire event if unrelated sessions starts", () => { + const onStarted = jest.fn(); + client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted); + + try { + const room1 = makeMockRoom([{ ...membershipTemplate, application: "m.other" }]); + jest.spyOn(client, "getRooms").mockReturnValue([room1]); + + client.emit(ClientEvent.Room, room1); + expect(onStarted).not.toHaveBeenCalled(); + } finally { + client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted); + } + }); + it("Fires event when session ends", () => { const onEnded = jest.fn(); client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded); @@ -59,9 +75,75 @@ describe("MatrixRTCSessionManager", () => { mockRoomState(room1, [{ user_id: membershipTemplate.user_id }]); const roomState = room1.getLiveTimeline().getState(EventTimeline.FORWARDS)!; - const membEvent = roomState.getStateEvents("")[0]; + const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0]; client.emit(RoomStateEvent.Events, membEvent, roomState, null); expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1)); }); + + it("Fires correctly with for with custom sessionDescription", () => { + const onStarted = jest.fn(); + const onEnded = jest.fn(); + // create a session manager with a custom session description + const sessionManager = new MatrixRTCSessionManager(logger, client, { id: "test", application: "m.notCall" }); + + // manually start the session manager (its not the default one started by the client) + sessionManager.start(); + sessionManager.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded); + sessionManager.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted); + + try { + const room1 = makeMockRoom([{ ...membershipTemplate, application: "m.other" }]); + jest.spyOn(client, "getRooms").mockReturnValue([room1]); + + client.emit(ClientEvent.Room, room1); + expect(onStarted).not.toHaveBeenCalled(); + onStarted.mockClear(); + + const room2 = makeMockRoom([{ ...membershipTemplate, application: "m.notCall", call_id: "test" }]); + jest.spyOn(client, "getRooms").mockReturnValue([room1, room2]); + + client.emit(ClientEvent.Room, room2); + expect(onStarted).toHaveBeenCalled(); + onStarted.mockClear(); + + mockRoomState(room2, [{ user_id: membershipTemplate.user_id }]); + jest.spyOn(client, "getRoom").mockReturnValue(room2); + + const roomState = room2.getLiveTimeline().getState(EventTimeline.FORWARDS)!; + const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0]; + client.emit(RoomStateEvent.Events, membEvent, roomState, null); + expect(onEnded).toHaveBeenCalled(); + onEnded.mockClear(); + + mockRoomState(room1, [{ user_id: membershipTemplate.user_id }]); + jest.spyOn(client, "getRoom").mockReturnValue(room1); + + const roomStateOther = room1.getLiveTimeline().getState(EventTimeline.FORWARDS)!; + const membEventOther = roomStateOther.getStateEvents("org.matrix.msc3401.call.member")[0]; + client.emit(RoomStateEvent.Events, membEventOther, roomStateOther, null); + expect(onEnded).not.toHaveBeenCalled(); + } finally { + client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted); + client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, onEnded); + } + }); + + it("Doesn't fire event if unrelated sessions ends", () => { + const onEnded = jest.fn(); + client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded); + const room1 = makeMockRoom([{ ...membershipTemplate, application: "m.other_app" }]); + jest.spyOn(client, "getRooms").mockReturnValue([room1]); + jest.spyOn(client, "getRoom").mockReturnValue(room1); + + client.emit(ClientEvent.Room, room1); + + mockRoomState(room1, [{ user_id: membershipTemplate.user_id }]); + + const roomState = room1.getLiveTimeline().getState(EventTimeline.FORWARDS)!; + const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0]; + client.emit(RoomStateEvent.Events, membEvent, roomState, null); + + expect(onEnded).not.toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1)); + }); }); diff --git a/spec/unit/matrixrtc/MembershipManager.spec.ts b/spec/unit/matrixrtc/MembershipManager.spec.ts index b5a4e22f1..e014730ba 100644 --- a/spec/unit/matrixrtc/MembershipManager.spec.ts +++ b/spec/unit/matrixrtc/MembershipManager.spec.ts @@ -64,6 +64,8 @@ function createAsyncHandle(method: MockedFunction) { return { reject, resolve }; } +const callSession = { id: "", application: "m.call" }; + describe("MembershipManager", () => { let client: MockClient; let room: Room; @@ -95,12 +97,12 @@ describe("MembershipManager", () => { describe("isActivated()", () => { it("defaults to false", () => { - const manager = new MembershipManager({}, room, client, () => undefined); + const manager = new MembershipManager({}, room, client, () => undefined, callSession); expect(manager.isActivated()).toEqual(false); }); it("returns true after join()", () => { - const manager = new MembershipManager({}, room, client, () => undefined); + const manager = new MembershipManager({}, room, client, () => undefined, callSession); manager.join([]); expect(manager.isActivated()).toEqual(true); }); @@ -114,7 +116,7 @@ describe("MembershipManager", () => { const updateDelayedEventHandle = createAsyncHandle(client._unstable_updateDelayedEvent as Mock); // Test - const memberManager = new MembershipManager(undefined, room, client, () => undefined); + const memberManager = new MembershipManager(undefined, room, client, () => undefined, callSession); memberManager.join([focus], focusActive); // expects await waitForMockCall(client.sendStateEvent, Promise.resolve({ event_id: "id" })); @@ -130,7 +132,7 @@ describe("MembershipManager", () => { focus_active: focusActive, scope: "m.room", }, - "_@alice:example.org_AAAAAAA", + "_@alice:example.org_AAAAAAA_m.call", ); updateDelayedEventHandle.resolve?.(); expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith( @@ -138,13 +140,13 @@ describe("MembershipManager", () => { { delay: 8000 }, "org.matrix.msc3401.call.member", {}, - "_@alice:example.org_AAAAAAA", + "_@alice:example.org_AAAAAAA_m.call", ); expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1); }); it("reschedules delayed leave event if sending state cancels it", async () => { - const memberManager = new MembershipManager(undefined, room, client, () => undefined); + const memberManager = new MembershipManager(undefined, room, client, () => undefined, callSession); const waitForSendState = waitForMockCall(client.sendStateEvent); const waitForUpdateDelaye = waitForMockCallOnce( client._unstable_updateDelayedEvent, @@ -189,7 +191,7 @@ describe("MembershipManager", () => { }); }); - const userStateKey = `${!useOwnedStateEvents ? "_" : ""}@alice:example.org_AAAAAAA`; + const userStateKey = `${!useOwnedStateEvents ? "_" : ""}@alice:example.org_AAAAAAA_m.call`; // preparing the delayed disconnect should handle ratelimiting const sendDelayedStateAttempt = new Promise((resolve) => { const error = new MatrixError({ errcode: "M_LIMIT_EXCEEDED" }); @@ -220,6 +222,7 @@ describe("MembershipManager", () => { room, client, () => undefined, + callSession, ); manager.join([focus], focusActive); @@ -276,7 +279,7 @@ describe("MembershipManager", () => { describe("delayed leave event", () => { it("does not try again to schedule a delayed leave event if not supported", () => { const delayedHandle = createAsyncHandle(client._unstable_sendDelayedStateEvent as Mock); - const manager = new MembershipManager({}, room, client, () => undefined); + const manager = new MembershipManager({}, room, client, () => undefined, callSession); manager.join([focus], focusActive); delayedHandle.reject?.( new UnsupportedDelayedEventsEndpointError( @@ -288,7 +291,7 @@ describe("MembershipManager", () => { }); it("does try to schedule a delayed leave event again if rate limited", async () => { const delayedHandle = createAsyncHandle(client._unstable_sendDelayedStateEvent as Mock); - const manager = new MembershipManager({}, room, client, () => undefined); + const manager = new MembershipManager({}, room, client, () => undefined, callSession); manager.join([focus], focusActive); delayedHandle.reject?.(new HTTPError("rate limited", 429, undefined)); await jest.advanceTimersByTimeAsync(5000); @@ -300,6 +303,7 @@ describe("MembershipManager", () => { room, client, () => undefined, + callSession, ); manager.join([focus], focusActive); expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith( @@ -307,7 +311,7 @@ describe("MembershipManager", () => { { delay: 123456 }, "org.matrix.msc3401.call.member", {}, - "_@alice:example.org_AAAAAAA", + "_@alice:example.org_AAAAAAA_m.call", ); }); }); @@ -319,6 +323,7 @@ describe("MembershipManager", () => { room, client, () => undefined, + callSession, ); // Join with the membership manager manager.join([focus], focusActive); @@ -351,7 +356,13 @@ describe("MembershipManager", () => { }); it("uses membershipEventExpiryMs from config", async () => { - const manager = new MembershipManager({ membershipEventExpiryMs: 1234567 }, room, client, () => undefined); + const manager = new MembershipManager( + { membershipEventExpiryMs: 1234567 }, + room, + client, + () => undefined, + callSession, + ); manager.join([focus], focusActive); await waitForMockCall(client.sendStateEvent); @@ -370,12 +381,12 @@ describe("MembershipManager", () => { type: "livekit", }, }, - "_@alice:example.org_AAAAAAA", + "_@alice:example.org_AAAAAAA_m.call", ); }); it("does nothing if join called when already joined", async () => { - const manager = new MembershipManager({}, room, client, () => undefined); + const manager = new MembershipManager({}, room, client, () => undefined, callSession); manager.join([focus], focusActive); await waitForMockCall(client.sendStateEvent); expect(client.sendStateEvent).toHaveBeenCalledTimes(1); @@ -387,7 +398,7 @@ describe("MembershipManager", () => { describe("leave()", () => { // TODO add rate limit cases. it("resolves delayed leave event when leave is called", async () => { - const manager = new MembershipManager({}, room, client, () => undefined); + const manager = new MembershipManager({}, room, client, () => undefined, callSession); manager.join([focus], focusActive); await jest.advanceTimersByTimeAsync(1); await manager.leave(); @@ -395,7 +406,7 @@ describe("MembershipManager", () => { expect(client.sendStateEvent).toHaveBeenCalled(); }); it("send leave event when leave is called and resolving delayed leave fails", async () => { - const manager = new MembershipManager({}, room, client, () => undefined); + const manager = new MembershipManager({}, room, client, () => undefined, callSession); manager.join([focus], focusActive); await jest.advanceTimersByTimeAsync(1); (client._unstable_updateDelayedEvent as Mock).mockRejectedValue("unknown"); @@ -406,11 +417,11 @@ describe("MembershipManager", () => { room.roomId, "org.matrix.msc3401.call.member", {}, - "_@alice:example.org_AAAAAAA", + "_@alice:example.org_AAAAAAA_m.call", ); }); it("does nothing if not joined", () => { - const manager = new MembershipManager({}, room, client, () => undefined); + const manager = new MembershipManager({}, room, client, () => undefined, callSession); expect(async () => await manager.leave()).not.toThrow(); expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled(); expect(client.sendStateEvent).not.toHaveBeenCalled(); @@ -420,7 +431,7 @@ describe("MembershipManager", () => { describe("getsActiveFocus", () => { it("gets the correct active focus with oldest_membership", () => { const getOldestMembership = jest.fn(); - const manager = new MembershipManager({}, room, client, getOldestMembership); + const manager = new MembershipManager({}, room, client, getOldestMembership, callSession); // Before joining the active focus should be undefined (see FocusInUse on MatrixRTCSession) expect(manager.getActiveFocus()).toBe(undefined); manager.join([focus], focusActive); @@ -455,7 +466,7 @@ describe("MembershipManager", () => { }); it("does not provide focus if the selection method is unknown", () => { - const manager = new MembershipManager({}, room, client, () => undefined); + const manager = new MembershipManager({}, room, client, () => undefined, callSession); manager.join([focus], Object.assign(focusActive, { type: "unknown_type" })); expect(manager.getActiveFocus()).toBe(undefined); }); @@ -463,7 +474,7 @@ describe("MembershipManager", () => { describe("onRTCSessionMemberUpdate()", () => { it("does nothing if not joined", async () => { - const manager = new MembershipManager({}, room, client, () => undefined); + const manager = new MembershipManager({}, room, client, () => undefined, callSession); await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]); await jest.advanceTimersToNextTimerAsync(); expect(client.sendStateEvent).not.toHaveBeenCalled(); @@ -471,7 +482,7 @@ describe("MembershipManager", () => { expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled(); }); it("does nothing if own membership still present", async () => { - const manager = new MembershipManager({}, room, client, () => undefined); + const manager = new MembershipManager({}, room, client, () => undefined, callSession); manager.join([focus], focusActive); await jest.advanceTimersByTimeAsync(1); const myMembership = (client.sendStateEvent as Mock).mock.calls[0][2]; @@ -495,7 +506,7 @@ describe("MembershipManager", () => { expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled(); }); it("recreates membership if it is missing", async () => { - const manager = new MembershipManager({}, room, client, () => undefined); + const manager = new MembershipManager({}, room, client, () => undefined, callSession); manager.join([focus], focusActive); await jest.advanceTimersByTimeAsync(1); // clearing all mocks before checking what happens when calling: `onRTCSessionMemberUpdate` @@ -513,7 +524,7 @@ describe("MembershipManager", () => { }); it("updates the UpdateExpiry entry in the action scheduler", async () => { - const manager = new MembershipManager({}, room, client, () => undefined); + const manager = new MembershipManager({}, room, client, () => undefined, callSession); manager.join([focus], focusActive); await jest.advanceTimersByTimeAsync(1); // clearing all mocks before checking what happens when calling: `onRTCSessionMemberUpdate` @@ -547,6 +558,7 @@ describe("MembershipManager", () => { room, client, () => undefined, + { id: "", application: "m.call" }, ); manager.join([focus], focusActive); await jest.advanceTimersByTimeAsync(1); @@ -578,6 +590,7 @@ describe("MembershipManager", () => { room, client, () => undefined, + { id: "", application: "m.call" }, ); manager.join([focus], focusActive); await waitForMockCall(client.sendStateEvent); @@ -600,14 +613,14 @@ describe("MembershipManager", () => { }); describe("status updates", () => { it("starts 'Disconnected'", () => { - const manager = new MembershipManager({}, room, client, () => undefined); + const manager = new MembershipManager({}, room, client, () => undefined, callSession); expect(manager.status).toBe(Status.Disconnected); }); it("emits 'Connection' and 'Connected' after join", async () => { const handleDelayedEvent = createAsyncHandle(client._unstable_sendDelayedStateEvent); const handleStateEvent = createAsyncHandle(client.sendStateEvent); - const manager = new MembershipManager({}, room, client, () => undefined); + const manager = new MembershipManager({}, room, client, () => undefined, callSession); expect(manager.status).toBe(Status.Disconnected); const connectEmit = jest.fn(); manager.on(MembershipManagerEvent.StatusChanged, connectEmit); @@ -621,7 +634,7 @@ describe("MembershipManager", () => { expect(connectEmit).toHaveBeenCalledWith(Status.Connecting, Status.Connected); }); it("emits 'Disconnecting' and 'Disconnected' after leave", async () => { - const manager = new MembershipManager({}, room, client, () => undefined); + const manager = new MembershipManager({}, room, client, () => undefined, callSession); const connectEmit = jest.fn(); manager.on(MembershipManagerEvent.StatusChanged, connectEmit); manager.join([focus], focusActive); @@ -637,7 +650,7 @@ describe("MembershipManager", () => { it("sends retry if call membership event is still valid at time of retry", async () => { const handle = createAsyncHandle(client._unstable_sendDelayedStateEvent); - const manager = new MembershipManager({}, room, client, () => undefined); + const manager = new MembershipManager({}, room, client, () => undefined, callSession); manager.join([focus], focusActive); expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1); @@ -664,7 +677,7 @@ describe("MembershipManager", () => { new Headers({ "Retry-After": "1" }), ), ); - const manager = new MembershipManager({}, room, client, () => undefined); + const manager = new MembershipManager({}, room, client, () => undefined, callSession); // Should call _unstable_sendDelayedStateEvent but not sendStateEvent because of the // RateLimit error. manager.join([focus], focusActive); @@ -684,7 +697,7 @@ describe("MembershipManager", () => { it("abandons retry loop if leave() was called before sending state event", async () => { const handle = createAsyncHandle(client._unstable_sendDelayedStateEvent); - const manager = new MembershipManager({}, room, client, () => undefined); + const manager = new MembershipManager({}, room, client, () => undefined, callSession); manager.join([focus], focusActive); handle.reject?.( new MatrixError( @@ -719,7 +732,7 @@ describe("MembershipManager", () => { new Headers({ "Retry-After": "1" }), ), ); - const manager = new MembershipManager({}, room, client, () => undefined); + const manager = new MembershipManager({}, room, client, () => undefined, callSession); manager.join([focus], focusActive); // Hit rate limit @@ -752,7 +765,7 @@ describe("MembershipManager", () => { new Headers({ "Retry-After": "2" }), ), ); - const manager = new MembershipManager({}, room, client, () => undefined); + const manager = new MembershipManager({}, room, client, () => undefined, callSession); manager.join([focus], focusActive, delayEventSendError); for (let i = 0; i < 10; i++) { @@ -772,7 +785,7 @@ describe("MembershipManager", () => { new Headers({ "Retry-After": "1" }), ), ); - const manager = new MembershipManager({}, room, client, () => undefined); + const manager = new MembershipManager({}, room, client, () => undefined, callSession); manager.join([focus], focusActive, delayEventRestartError); for (let i = 0; i < 10; i++) { @@ -783,7 +796,7 @@ describe("MembershipManager", () => { it("falls back to using pure state events when some error occurs while sending delayed events", async () => { const unrecoverableError = jest.fn(); (client._unstable_sendDelayedStateEvent as Mock).mockRejectedValue(new HTTPError("unknown", 601)); - const manager = new MembershipManager({}, room, client, () => undefined); + const manager = new MembershipManager({}, room, client, () => undefined, callSession); manager.join([focus], focusActive, unrecoverableError); await waitForMockCall(client.sendStateEvent); expect(unrecoverableError).not.toHaveBeenCalledWith(); @@ -797,6 +810,7 @@ describe("MembershipManager", () => { room, client, () => undefined, + callSession, ); manager.join([focus], focusActive, unrecoverableError); for (let retries = 0; retries < 7; retries++) { @@ -814,7 +828,7 @@ describe("MembershipManager", () => { (client._unstable_sendDelayedStateEvent as Mock).mockRejectedValue( new UnsupportedDelayedEventsEndpointError("not supported", "sendDelayedStateEvent"), ); - const manager = new MembershipManager({}, room, client, () => undefined); + const manager = new MembershipManager({}, room, client, () => undefined, callSession); manager.join([focus], focusActive, unrecoverableError); await jest.advanceTimersByTimeAsync(1); @@ -828,7 +842,7 @@ it("Should prefix log with MembershipManager used", () => { const client = makeMockClient("@alice:example.org", "AAAAAAA"); const room = makeMockRoom([membershipTemplate]); - const membershipManager = new MembershipManager(undefined, room, client, () => undefined, logger); + const membershipManager = new MembershipManager(undefined, room, client, () => undefined, callSession, logger); const spy = jest.spyOn(console, "error"); // Double join diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index 5c62705d8..b3f50f028 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -18,6 +18,7 @@ import { type MatrixEvent } from "../matrix.ts"; import { deepCompare } from "../utils.ts"; import { type Focus } from "./focus.ts"; import { isLivekitFocusActive } from "./LivekitFocus.ts"; +import { type SessionDescription } from "./MatrixRTCSession.ts"; /** * The default duration in milliseconds that a membership is considered valid for. @@ -130,6 +131,9 @@ export class CallMembership { return this.parentEvent.getId(); } + /** + * @deprecated Use sessionDescription.id instead. + */ public get callId(): string { return this.membershipData.call_id; } @@ -138,6 +142,13 @@ export class CallMembership { return this.membershipData.device_id; } + public get sessionDescription(): SessionDescription { + return { + application: this.membershipData.application, + id: this.membershipData.call_id, + }; + } + public get application(): string | undefined { return this.membershipData.application; } diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 61340307f..883635571 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -26,7 +26,7 @@ import { type Focus } from "./focus.ts"; import { KnownMembership } from "../@types/membership.ts"; import { MembershipManager } from "./MembershipManager.ts"; import { EncryptionManager, type IEncryptionManager } from "./EncryptionManager.ts"; -import { logDurationSync } from "../utils.ts"; +import { deepCompare, logDurationSync } from "../utils.ts"; import { type Statistics, type RTCNotificationType } from "./types.ts"; import { RoomKeyTransport } from "./RoomKeyTransport.ts"; import type { IMembershipManager } from "./IMembershipManager.ts"; @@ -74,6 +74,14 @@ export interface SessionConfig { notificationType?: RTCNotificationType; } +/** + * The session description is used to identify a session. Used in the state event. + */ +export interface SessionDescription { + id: string; + application: string; +} + // The names follow these principles: // - we use the technical term delay if the option is related to delayed events. // - we use delayedLeaveEvent if the option is related to the delayed leave event. @@ -86,7 +94,7 @@ export interface MembershipConfig { * Use the new Manager. * * Default: `false`. - * @deprecated does nothing anymore we always default to the new memberhip manager. + * @deprecated does nothing anymore we always default to the new membership manager. */ useNewMembershipManager?: boolean; @@ -254,10 +262,27 @@ export class MatrixRTCSession extends TypedEventEmitter< } /** - * Returns all the call memberships for a room, oldest first + * Returns all the call memberships for a room that match the provided `sessionDescription`, + * oldest first. + * + * @deprecated Use `MatrixRTCSession.sessionMembershipsForRoom` instead. */ public static callMembershipsForRoom( room: Pick, + ): CallMembership[] { + return MatrixRTCSession.sessionMembershipsForRoom(room, { + id: "", + application: "m.call", + }); + } + + /** + * Returns all the call memberships for a room that match the provided `sessionDescription`, + * oldest first. + */ + public static sessionMembershipsForRoom( + room: Pick, + sessionDescription: SessionDescription, ): CallMembership[] { const logger = rootLogger.getChild(`[MatrixRTCSession ${room.roomId}]`); const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS); @@ -290,15 +315,10 @@ export class MatrixRTCSession extends TypedEventEmitter< try { const membership = new CallMembership(memberEvent, membershipData); - if (membership.application !== "m.call") { - // Only process MatrixRTC sessions associated with calls - logger.info("Skipping non-call MatrixRTC session"); - continue; - } - - if (membership.callId !== "" || membership.scope !== "m.room") { - // for now, just ignore anything that isn't a room scope call - logger.info(`Ignoring user-scoped call`); + if (!deepCompare(membership.sessionDescription, sessionDescription)) { + logger.info( + `Ignoring membership of user ${membership.sender} for a different session: ${JSON.stringify(membership.sessionDescription)}`, + ); continue; } @@ -329,12 +349,33 @@ export class MatrixRTCSession extends TypedEventEmitter< } /** - * Return the MatrixRTC session for the room, whether there are currently active members or not + * Return the MatrixRTC session for the room. + * This returned session can be used to find out if there are active room call sessions + * for the requested room. + * + * This method is an alias for `MatrixRTCSession.sessionForRoom` with + * sessionDescription `{ id: "", application: "m.call" }`. + * + * @deprecated Use `MatrixRTCSession.sessionForRoom` with sessionDescription `{ id: "", application: "m.call" }` instead. */ public static roomSessionForRoom(client: MatrixClient, room: Room): MatrixRTCSession { - const callMemberships = MatrixRTCSession.callMembershipsForRoom(room); + const callMemberships = MatrixRTCSession.sessionMembershipsForRoom(room, { id: "", application: "m.call" }); + return new MatrixRTCSession(client, room, callMemberships, { id: "", application: "m.call" }); + } - return new MatrixRTCSession(client, room, callMemberships); + /** + * Return the MatrixRTC session for the room. + * This returned session can be used to find out if there are active sessions + * for the requested room and `sessionDescription`. + */ + public static sessionForRoom( + client: MatrixClient, + room: Room, + sessionDescription: SessionDescription, + ): MatrixRTCSession { + const callMemberships = MatrixRTCSession.sessionMembershipsForRoom(room, sessionDescription); + + return new MatrixRTCSession(client, room, callMemberships, sessionDescription); } /** @@ -379,10 +420,15 @@ export class MatrixRTCSession extends TypedEventEmitter< "getLiveTimeline" | "roomId" | "getVersion" | "hasMembershipState" | "on" | "off" >, public memberships: CallMembership[], + /** + * The session description is used to define the exact session this object is tracking. + * A session is distinct from another session if one of those properties differ: `roomSubset.roomId`, `sessionDescription.application`, `sessionDescription.id`. + */ + public readonly sessionDescription: SessionDescription, ) { super(); this.logger = rootLogger.getChild(`[MatrixRTCSession ${roomSubset.roomId}]`); - this._callId = memberships[0]?.callId; + this._callId = memberships[0]?.sessionDescription.id; const roomState = this.roomSubset.getLiveTimeline().getState(EventTimeline.FORWARDS); // TODO: double check if this is actually needed. Should be covered by refreshRoom in MatrixRTCSessionManager roomState?.on(RoomStateEvent.Members, this.onRoomMemberUpdate); @@ -440,6 +486,7 @@ export class MatrixRTCSession extends TypedEventEmitter< this.roomSubset, this.client, () => this.getOldestMembership(), + this.sessionDescription, this.logger, ); @@ -648,9 +695,9 @@ export class MatrixRTCSession extends TypedEventEmitter< */ private recalculateSessionMembers = (): void => { const oldMemberships = this.memberships; - this.memberships = MatrixRTCSession.callMembershipsForRoom(this.room); + this.memberships = MatrixRTCSession.sessionMembershipsForRoom(this.room, this.sessionDescription); - this._callId = this._callId ?? this.memberships[0]?.callId; + this._callId = this._callId ?? this.memberships[0]?.sessionDescription.id; const changed = oldMemberships.length != this.memberships.length || diff --git a/src/matrixrtc/MatrixRTCSessionManager.ts b/src/matrixrtc/MatrixRTCSessionManager.ts index 2bf0d7409..cc25105d9 100644 --- a/src/matrixrtc/MatrixRTCSessionManager.ts +++ b/src/matrixrtc/MatrixRTCSessionManager.ts @@ -20,7 +20,7 @@ import { TypedEventEmitter } from "../models/typed-event-emitter.ts"; import { type Room } from "../models/room.ts"; import { type RoomState, RoomStateEvent } from "../models/room-state.ts"; import { type MatrixEvent } from "../models/event.ts"; -import { MatrixRTCSession } from "./MatrixRTCSession.ts"; +import { MatrixRTCSession, type SessionDescription } from "./MatrixRTCSession.ts"; import { EventType } from "../@types/event.ts"; export enum MatrixRTCSessionManagerEvents { @@ -37,6 +37,9 @@ type EventHandlerMap = { /** * Holds all active MatrixRTC session objects and creates new ones as events arrive. + * One `MatrixRTCSessionManager` is required for each MatrixRTC sessionDescription (application, session id) that the client wants to support. + * If no application type is specified in the constructor, the default is "m.call". + * * This interface is UNSTABLE and may change without warning. */ export class MatrixRTCSessionManager extends TypedEventEmitter { @@ -53,6 +56,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter 0) { this.roomSessions.set(room.roomId, session); } @@ -96,7 +100,10 @@ export class MatrixRTCSessionManager extends TypedEventEmitter MembershipActionType.Update if the timeout has passed so the next update is required. SendScheduledDelayedLeaveEvent = "SendScheduledDelayedLeaveEvent", - // -> MembershipActionType.SendLeaveEvent on failiour (not found) we need to send the leave manually and cannot use the scheduled delayed event + // -> MembershipActionType.SendLeaveEvent on failure (not found) we need to send the leave manually and cannot use the scheduled delayed event // -> DelayedLeaveActionType.SendScheduledDelayedLeaveEvent on error we try again. SendLeaveEvent = "SendLeaveEvent", @@ -294,6 +294,7 @@ export class MembershipManager | "_unstable_updateDelayedEvent" >, private getOldestMembership: () => CallMembership | undefined, + public readonly sessionDescription: SessionDescription, parentLogger?: Logger, ) { super(); @@ -700,7 +701,7 @@ export class MembershipManager // HELPERS private makeMembershipStateKey(localUserId: string, localDeviceId: string): string { - const stateKey = `${localUserId}_${localDeviceId}`; + const stateKey = `${localUserId}_${localDeviceId}_${this.sessionDescription.application}${this.sessionDescription.id}`; if (/^org\.matrix\.msc(3757|3779)\b/.exec(this.room.getVersion())) { return stateKey; } else { @@ -713,9 +714,10 @@ export class MembershipManager */ private makeMyMembership(expires: number): SessionMembershipData { return { - call_id: "", + // TODO: use the new format for m.rtc.member events where call_id becomes session.id + application: this.sessionDescription.application, + call_id: this.sessionDescription.id, scope: "m.room", - application: "m.call", device_id: this.deviceId, expires, focus_active: { type: "livekit", focus_selection: "oldest_membership" },