diff --git a/spec/unit/matrixrtc/CallMembership.spec.ts b/spec/unit/matrixrtc/CallMembership.spec.ts index 3d259103cc5..bf6ded10abf 100644 --- a/spec/unit/matrixrtc/CallMembership.spec.ts +++ b/spec/unit/matrixrtc/CallMembership.spec.ts @@ -15,16 +15,7 @@ limitations under the License. */ import { MatrixEvent } from "../../../src"; -import { CallMembership, CallMembershipData } from "../../../src/matrixrtc/CallMembership"; - -const membershipTemplate: CallMembershipData = { - call_id: "", - scope: "m.room", - application: "m.call", - device_id: "AAAAAAA", - expires: 5000, - membershipID: "bloop", -}; +import { CallMembership, CallMembershipDataLegacy, SessionMembershipData } from "../../../src/matrixrtc/CallMembership"; function makeMockEvent(originTs = 0): MatrixEvent { return { @@ -34,96 +25,175 @@ function makeMockEvent(originTs = 0): MatrixEvent { } describe("CallMembership", () => { - it("rejects membership with no expiry and no expires_ts", () => { - expect(() => { - new CallMembership( - makeMockEvent(), - Object.assign({}, membershipTemplate, { expires: undefined, expires_ts: undefined }), + describe("CallMembershipDataLegacy", () => { + const membershipTemplate: CallMembershipDataLegacy = { + call_id: "", + scope: "m.room", + application: "m.call", + device_id: "AAAAAAA", + expires: 5000, + membershipID: "bloop", + foci_active: [{ type: "livekit" }], + }; + it("rejects membership with no expiry and no expires_ts", () => { + expect(() => { + new CallMembership( + makeMockEvent(), + Object.assign({}, membershipTemplate, { expires: undefined, expires_ts: undefined }), + ); + }).toThrow(); + }); + + it("rejects membership with no device_id", () => { + expect(() => { + new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { device_id: undefined })); + }).toThrow(); + }); + + it("rejects membership with no call_id", () => { + expect(() => { + new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { call_id: undefined })); + }).toThrow(); + }); + + it("allow membership with no scope", () => { + expect(() => { + new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { scope: undefined })); + }).not.toThrow(); + }); + it("rejects with malformatted expires_ts", () => { + expect(() => { + new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { expires_ts: "string" })); + }).toThrow(); + }); + it("rejects with malformatted expires", () => { + expect(() => { + new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { expires: "string" })); + }).toThrow(); + }); + + it("uses event timestamp if no created_ts", () => { + const membership = new CallMembership(makeMockEvent(12345), membershipTemplate); + expect(membership.createdTs()).toEqual(12345); + }); + + it("uses created_ts if present", () => { + const membership = new CallMembership( + makeMockEvent(12345), + Object.assign({}, membershipTemplate, { created_ts: 67890 }), ); - }).toThrow(); - }); + expect(membership.createdTs()).toEqual(67890); + }); - it("rejects membership with no device_id", () => { - expect(() => { - new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { device_id: undefined })); - }).toThrow(); - }); + it("computes absolute expiry time based on expires", () => { + const membership = new CallMembership(makeMockEvent(1000), membershipTemplate); + expect(membership.getAbsoluteExpiry()).toEqual(5000 + 1000); + }); - it("rejects membership with no call_id", () => { - expect(() => { - new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { call_id: undefined })); - }).toThrow(); - }); + it("computes absolute expiry time based on expires_ts", () => { + const membership = new CallMembership( + makeMockEvent(1000), + Object.assign({}, membershipTemplate, { expires_ts: 6000 }), + ); + expect(membership.getAbsoluteExpiry()).toEqual(5000 + 1000); + }); - it("rejects membership with no scope", () => { - expect(() => { - new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { scope: undefined })); - }).toThrow(); - }); - it("rejects with malformatted expires_ts", () => { - expect(() => { - new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { expires_ts: "string" })); - }).toThrow(); - }); - it("rejects with malformatted expires", () => { - expect(() => { - new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { expires: "string" })); - }).toThrow(); - }); + it("considers memberships unexpired if local age low enough", () => { + const fakeEvent = makeMockEvent(1000); + fakeEvent.getLocalAge = jest.fn().mockReturnValue(3000); + const membership = new CallMembership(fakeEvent, membershipTemplate); + expect(membership.isExpired()).toEqual(false); + }); - it("uses event timestamp if no created_ts", () => { - const membership = new CallMembership(makeMockEvent(12345), membershipTemplate); - expect(membership.createdTs()).toEqual(12345); - }); + it("considers memberships expired when local age large", () => { + const fakeEvent = makeMockEvent(1000); + fakeEvent.localTimestamp = Date.now() - 6000; + const membership = new CallMembership(fakeEvent, membershipTemplate); + expect(membership.isExpired()).toEqual(true); + }); - it("uses created_ts if present", () => { - const membership = new CallMembership( - makeMockEvent(12345), - Object.assign({}, membershipTemplate, { created_ts: 67890 }), - ); - expect(membership.createdTs()).toEqual(67890); + it("returns preferred foci", () => { + const fakeEvent = makeMockEvent(); + const mockFocus = { type: "this_is_a_mock_focus" }; + const membership = new CallMembership( + fakeEvent, + Object.assign({}, membershipTemplate, { foci_active: [mockFocus] }), + ); + expect(membership.getPreferredFoci()).toEqual([mockFocus]); + }); }); - it("computes absolute expiry time based on expires", () => { - const membership = new CallMembership(makeMockEvent(1000), membershipTemplate); - expect(membership.getAbsoluteExpiry()).toEqual(5000 + 1000); - }); + describe("SessionMembershipData", () => { + const membershipTemplate: SessionMembershipData = { + call_id: "", + scope: "m.room", + application: "m.call", + device_id: "AAAAAAA", + focus_active: { type: "livekit" }, + foci_preferred: [{ type: "livekit" }], + }; + + it("rejects membership with no device_id", () => { + expect(() => { + new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { device_id: undefined })); + }).toThrow(); + }); - it("computes absolute expiry time based on expires_ts", () => { - const membership = new CallMembership( - makeMockEvent(1000), - Object.assign({}, membershipTemplate, { expires: undefined, expires_ts: 6000 }), - ); - expect(membership.getAbsoluteExpiry()).toEqual(5000 + 1000); - }); + it("rejects membership with no call_id", () => { + expect(() => { + new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { call_id: undefined })); + }).toThrow(); + }); - it("considers memberships unexpired if local age low enough", () => { - const fakeEvent = makeMockEvent(1000); - fakeEvent.getLocalAge = jest.fn().mockReturnValue(3000); - const membership = new CallMembership(fakeEvent, membershipTemplate); - expect(membership.isExpired()).toEqual(false); - }); + it("allow membership with no scope", () => { + expect(() => { + new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { scope: undefined })); + }).not.toThrow(); + }); - it("considers memberships expired when local age large", () => { - const fakeEvent = makeMockEvent(1000); - fakeEvent.localTimestamp = Date.now() - 6000; - const membership = new CallMembership(fakeEvent, membershipTemplate); - expect(membership.isExpired()).toEqual(true); - }); + it("uses event timestamp if no created_ts", () => { + const membership = new CallMembership(makeMockEvent(12345), membershipTemplate); + expect(membership.createdTs()).toEqual(12345); + }); + + it("uses created_ts if present", () => { + const membership = new CallMembership( + makeMockEvent(12345), + Object.assign({}, membershipTemplate, { created_ts: 67890 }), + ); + expect(membership.createdTs()).toEqual(67890); + }); + + it("considers memberships unexpired if local age low enough", () => { + const fakeEvent = makeMockEvent(1000); + fakeEvent.getLocalAge = jest.fn().mockReturnValue(3000); + const membership = new CallMembership(fakeEvent, membershipTemplate); + expect(membership.isExpired()).toEqual(false); + }); - it("returns active foci", () => { - const fakeEvent = makeMockEvent(); - const mockFocus = { type: "this_is_a_mock_focus" }; - const membership = new CallMembership( - fakeEvent, - Object.assign({}, membershipTemplate, { foci_active: [mockFocus] }), - ); - expect(membership.getActiveFoci()).toEqual([mockFocus]); + it("returns preferred foci", () => { + const fakeEvent = makeMockEvent(); + const mockFocus = { type: "this_is_a_mock_focus" }; + const membership = new CallMembership( + fakeEvent, + Object.assign({}, membershipTemplate, { foci_preferred: [mockFocus] }), + ); + expect(membership.getPreferredFoci()).toEqual([mockFocus]); + }); }); describe("expiry calculation", () => { let fakeEvent: MatrixEvent; let membership: CallMembership; + const membershipTemplate: CallMembershipDataLegacy = { + call_id: "", + scope: "m.room", + application: "m.call", + device_id: "AAAAAAA", + expires: 5000, + membershipID: "bloop", + foci_active: [{ type: "livekit" }], + }; beforeEach(() => { // server origin timestamp for this event is 1000 diff --git a/spec/unit/matrixrtc/LivekitFocus.spec.ts b/spec/unit/matrixrtc/LivekitFocus.spec.ts new file mode 100644 index 00000000000..728d6a68de6 --- /dev/null +++ b/spec/unit/matrixrtc/LivekitFocus.spec.ts @@ -0,0 +1,60 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { isLivekitFocus, isLivekitFocusActive, isLivekitFocusConfig } from "../../../src/matrixrtc/LivekitFocus"; + +describe("LivekitFocus", () => { + it("isLivekitFocus", () => { + expect( + isLivekitFocus({ + type: "livekit", + livekit_service_url: "http://test.com", + livekit_alias: "test", + }), + ).toBeTruthy(); + expect(isLivekitFocus({ type: "livekit" })).toBeFalsy(); + expect( + isLivekitFocus({ type: "not-livekit", livekit_service_url: "http://test.com", livekit_alias: "test" }), + ).toBeFalsy(); + expect( + isLivekitFocus({ type: "livekit", other_service_url: "http://test.com", livekit_alias: "test" }), + ).toBeFalsy(); + expect( + isLivekitFocus({ type: "livekit", livekit_service_url: "http://test.com", other_alias: "test" }), + ).toBeFalsy(); + }); + it("isLivekitFocusActive", () => { + expect( + isLivekitFocusActive({ + type: "livekit", + focus_selection: "oldest_membership", + }), + ).toBeTruthy(); + expect(isLivekitFocusActive({ type: "livekit" })).toBeFalsy(); + expect(isLivekitFocusActive({ type: "not-livekit", focus_selection: "oldest_membership" })).toBeFalsy(); + }); + it("isLivekitFocusConfig", () => { + expect( + isLivekitFocusConfig({ + type: "livekit", + livekit_service_url: "http://test.com", + }), + ).toBeTruthy(); + expect(isLivekitFocusConfig({ type: "livekit" })).toBeFalsy(); + expect(isLivekitFocusConfig({ type: "not-livekit", livekit_service_url: "http://test.com" })).toBeFalsy(); + expect(isLivekitFocusConfig({ type: "livekit", other_service_url: "oldest_membership" })).toBeFalsy(); + }); +}); diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index b994613b5bb..e481be966d3 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -29,6 +29,7 @@ const membershipTemplate: CallMembershipData = { device_id: "AAAAAAA", expires: 60 * 60 * 1000, membershipID: "bloop", + foci_active: [{ type: "livekit", livekit_service_url: "https://lk.url" }], }; const mockFocus = { type: "mock" }; @@ -198,6 +199,64 @@ describe("MatrixRTCSession", () => { }); }); + describe("getsActiveFocus", () => { + const activeFociConfig = { type: "livekit", livekit_service_url: "https://active.url" }; + it("gets the correct active focus with oldest_membership", () => { + const mockRoom = makeMockRoom([ + Object.assign({}, membershipTemplate, { + device_id: "foo", + created_ts: 500, + foci_active: [activeFociConfig], + }), + Object.assign({}, membershipTemplate, { device_id: "old", created_ts: 1000 }), + Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }), + ]); + + sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + + sess.joinRoomSession([{ type: "livekit", livekit_service_url: "htts://test.org" }], { + type: "livekit", + focus_selection: "oldest_membership", + }); + expect(sess.getActiveFocus()).toBe(activeFociConfig); + }); + it("does not provide focus if the selction method is unknown", () => { + const mockRoom = makeMockRoom([ + Object.assign({}, membershipTemplate, { + device_id: "foo", + created_ts: 500, + foci_active: [activeFociConfig], + }), + Object.assign({}, membershipTemplate, { device_id: "old", created_ts: 1000 }), + Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }), + ]); + + sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + + sess.joinRoomSession([{ type: "livekit", livekit_service_url: "htts://test.org" }], { + type: "livekit", + focus_selection: "unknown", + }); + expect(sess.getActiveFocus()).toBe(undefined); + }); + it("gets the correct active focus legacy", () => { + const mockRoom = makeMockRoom([ + Object.assign({}, membershipTemplate, { + device_id: "foo", + created_ts: 500, + foci_active: [activeFociConfig], + }), + Object.assign({}, membershipTemplate, { device_id: "old", created_ts: 1000 }), + Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }), + ]); + + sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + + sess.joinRoomSession([{ type: "livekit", livekit_service_url: "htts://test.org" }]); + expect(sess.getActiveFocus()).toBe(activeFociConfig); + }); + }); + describe("joining", () => { let mockRoom: Room; let sendStateEventMock: jest.Mock; @@ -223,13 +282,13 @@ describe("MatrixRTCSession", () => { }); it("shows joined once join is called", () => { - sess!.joinRoomSession([mockFocus]); + sess!.joinRoomSession([mockFocus], mockFocus); expect(sess!.isJoined()).toEqual(true); }); it("sends a membership event when joining a call", () => { jest.useFakeTimers(); - sess!.joinRoomSession([mockFocus]); + sess!.joinRoomSession([mockFocus], mockFocus); expect(client.sendStateEvent).toHaveBeenCalledWith( mockRoom!.roomId, EventType.GroupCallMemberPrefix, @@ -242,7 +301,8 @@ describe("MatrixRTCSession", () => { device_id: "AAAAAAA", expires: 3600000, expires_ts: Date.now() + 3600000, - foci_active: [{ type: "mock" }], + foci_active: [mockFocus], + membershipID: expect.stringMatching(".*"), }, ], @@ -253,11 +313,11 @@ describe("MatrixRTCSession", () => { }); it("does nothing if join called when already joined", () => { - sess!.joinRoomSession([mockFocus]); + sess!.joinRoomSession([mockFocus], mockFocus); expect(client.sendStateEvent).toHaveBeenCalledTimes(1); - sess!.joinRoomSession([mockFocus]); + sess!.joinRoomSession([mockFocus], mockFocus); expect(client.sendStateEvent).toHaveBeenCalledTimes(1); }); @@ -274,7 +334,7 @@ describe("MatrixRTCSession", () => { const sendStateEventMock = jest.fn().mockImplementation(resolveFn); client.sendStateEvent = sendStateEventMock; - sess!.joinRoomSession([mockFocus]); + sess!.joinRoomSession([mockFocus], mockFocus); const eventContent = await eventSentPromise; @@ -308,7 +368,7 @@ describe("MatrixRTCSession", () => { device_id: "AAAAAAA", expires: 3600000 * 2, expires_ts: 1000 + 3600000 * 2, - foci_active: [{ type: "mock" }], + foci_active: [mockFocus], created_ts: 1000, membershipID: expect.stringMatching(".*"), }, @@ -322,7 +382,7 @@ describe("MatrixRTCSession", () => { }); it("creates a key when joining", () => { - sess!.joinRoomSession([mockFocus], true); + sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); const keys = sess?.getKeysForParticipant("@alice:example.org", "AAAAAAA"); expect(keys).toHaveLength(1); @@ -336,7 +396,7 @@ describe("MatrixRTCSession", () => { sendEventMock.mockImplementation(resolve); }); - sess!.joinRoomSession([mockFocus], true); + sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); await eventSentPromise; @@ -372,7 +432,7 @@ describe("MatrixRTCSession", () => { }); }); - sess!.joinRoomSession([mockFocus], true); + sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); jest.advanceTimersByTime(10000); await eventSentPromise; @@ -394,7 +454,7 @@ describe("MatrixRTCSession", () => { throw e; }); - sess!.joinRoomSession([mockFocus], true); + sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); expect(client.cancelPendingEvent).toHaveBeenCalledWith(eventSentinel); }); @@ -409,7 +469,7 @@ describe("MatrixRTCSession", () => { sendEventMock.mockImplementation(resolve); }); - sess.joinRoomSession([mockFocus], true); + sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); await keysSentPromise1; sendEventMock.mockClear(); @@ -462,7 +522,7 @@ describe("MatrixRTCSession", () => { sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload)); }); - sess.joinRoomSession([mockFocus], true); + sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); const firstKeysPayload = await keysSentPromise1; expect(firstKeysPayload.keys).toHaveLength(1); @@ -499,7 +559,7 @@ describe("MatrixRTCSession", () => { sendEventMock.mockImplementation(resolve); }); - sess.joinRoomSession([mockFocus], true); + sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); await keysSentPromise1; sendEventMock.mockClear(); @@ -595,7 +655,7 @@ describe("MatrixRTCSession", () => { jest.advanceTimersByTime(10000); - sess.joinRoomSession([mockFocus]); + sess.joinRoomSession([mockFocus], mockFocus); expect(client.sendStateEvent).toHaveBeenCalledWith( mockRoomNoExpired!.roomId, @@ -631,7 +691,7 @@ describe("MatrixRTCSession", () => { ]); sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); - sess.joinRoomSession([mockFocus]); + sess.joinRoomSession([mockFocus], mockFocus); expect(client.sendStateEvent).toHaveBeenCalledWith( mockRoom!.roomId, @@ -645,6 +705,7 @@ describe("MatrixRTCSession", () => { device_id: "OTHERDEVICE", expires: 3600000, created_ts: 1000, + foci_active: [{ type: "livekit", livekit_service_url: "https://lk.url" }], membershipID: expect.stringMatching(".*"), }, { diff --git a/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts b/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts index f5ffc13e05b..c2e6fc52f45 100644 --- a/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts @@ -35,6 +35,7 @@ const membershipTemplate: CallMembershipData = { device_id: "AAAAAAA", expires: 60 * 60 * 1000, membershipID: "bloop", + foci_active: [{ type: "test" }], }; describe("MatrixRTCSessionManager", () => { diff --git a/src/@types/event.ts b/src/@types/event.ts index 44ef4c1434c..c01fc40d3e8 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -57,6 +57,7 @@ import { } from "../webrtc/callEventTypes"; import { EncryptionKeysEventContent, ICallNotifyContent } from "../matrixrtc/types"; import { M_POLL_END, M_POLL_START, PollEndEventContent, PollStartEventContent } from "./polls"; +import { SessionMembershipData } from "../matrixrtc/CallMembership"; export enum EventType { // Room state events @@ -356,7 +357,10 @@ export interface StateEvents { // MSC3401 [EventType.GroupCallPrefix]: IGroupCallRoomState; - [EventType.GroupCallMemberPrefix]: XOR; + [EventType.GroupCallMemberPrefix]: XOR< + XOR, + XOR + >; // MSC3089 [UNSTABLE_MSC3089_BRANCH.name]: MSC3089EventContent; diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index 171751f1d1f..52893841d89 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -14,52 +14,114 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { EitherAnd } from "matrix-events-sdk/lib/types"; + import { MatrixEvent } from "../matrix"; import { deepCompare } from "../utils"; import { Focus } from "./focus"; +import { isLivekitFocusActive } from "./LivekitFocus"; type CallScope = "m.room" | "m.user"; - // Represents an entry in the memberships section of an m.call.member event as it is on the wire -export interface CallMembershipData { - application?: string; + +// There are two different data interfaces. One for the Legacy types and one compliant with MSC4143 + +// MSC4143 (MatrixRTC) session membership data + +export type SessionMembershipData = { + application: string; + call_id: string; + device_id: string; + + focus_active: Focus; + foci_preferred: Focus[]; + created_ts?: number; + + // Application specific data + scope?: CallScope; +}; + +export const isSessionMembershipData = (data: CallMembershipData): data is SessionMembershipData => + "focus_active" in data; + +const checkSessionsMembershipData = (data: any, errors: string[]): data is SessionMembershipData => { + const prefix = "Malformed session membership event: "; + if (typeof data.device_id !== "string") errors.push(prefix + "device_id must be string"); + if (typeof data.call_id !== "string") errors.push(prefix + "call_id must be string"); + if (typeof data.application !== "string") errors.push(prefix + "application must be a string"); + if (typeof data.focus_active?.type !== "string") errors.push(prefix + "focus_active.type must be a string"); + if (!Array.isArray(data.foci_preferred)) errors.push(prefix + "foci_preferred must be an array"); + // optional parameters + if (data.created_ts && typeof data.created_ts !== "number") errors.push(prefix + "created_ts must be number"); + + // application specific data (we first need to check if they exist) + if (data.scope && typeof data.scope !== "string") errors.push(prefix + "scope must be string"); + return errors.length === 0; +}; + +// Legacy session membership data + +export type CallMembershipDataLegacy = { + application: string; call_id: string; scope: CallScope; device_id: string; + membershipID: string; created_ts?: number; - expires?: number; - expires_ts?: number; foci_active?: Focus[]; - membershipID: string; -} +} & EitherAnd<{ expires: number }, { expires_ts: number }>; + +export const isLegacyCallMembershipData = (data: CallMembershipData): data is CallMembershipDataLegacy => + "membershipID" in data; + +const checkCallMembershipDataLegacy = (data: any, errors: string[]): data is CallMembershipDataLegacy => { + const prefix = "Malformed legacy rtc membership event: "; + if (!("expires" in data || "expires_ts" in data)) { + errors.push(prefix + "expires_ts or expires must be present"); + } + if ("expires" in data) { + if (typeof data.expires !== "number") { + errors.push(prefix + "expires must be numeric"); + } + } + if ("expires_ts" in data) { + if (typeof data.expires_ts !== "number") { + errors.push(prefix + "expires_ts must be numeric"); + } + } + + if (typeof data.device_id !== "string") errors.push(prefix + "device_id must be string"); + if (typeof data.call_id !== "string") errors.push(prefix + "call_id must be string"); + if (typeof data.application !== "string") errors.push(prefix + "application must be a string"); + if (typeof data.membershipID !== "string") errors.push(prefix + "membershipID must be a string"); + // optional elements + if (data.created_ts && typeof data.created_ts !== "number") errors.push(prefix + "created_ts must be number"); + // application specific data (we first need to check if they exist) + if (data.scope && typeof data.scope !== "string") errors.push(prefix + "scope must be string"); + return errors.length === 0; +}; + +export type CallMembershipData = CallMembershipDataLegacy | SessionMembershipData; export class CallMembership { public static equal(a: CallMembership, b: CallMembership): boolean { - return deepCompare(a.data, b.data); + return deepCompare(a.membershipData, b.membershipData); } + private membershipData: CallMembershipData; public constructor( private parentEvent: MatrixEvent, - private data: CallMembershipData, + data: any, ) { - if (!(data.expires || data.expires_ts)) { - throw new Error("Malformed membership: expires_ts or expires must be present"); - } - if (data.expires) { - if (typeof data.expires !== "number") { - throw new Error("Malformed membership: expires must be numeric"); - } - } - if (data.expires_ts) { - if (typeof data.expires_ts !== "number") { - throw new Error("Malformed membership: expires_ts must be numeric"); - } + const sessionErrors: string[] = []; + const legacyErrors: string[] = []; + if (!checkSessionsMembershipData(data, sessionErrors) && !checkCallMembershipDataLegacy(data, legacyErrors)) { + throw Error( + `unknown CallMembership data. Does not match legacy call.member (${legacyErrors.join(" & ")}) events nor MSC4143 (${sessionErrors.join(" & ")})`, + ); + } else { + this.membershipData = data; } - - if (typeof data.device_id !== "string") throw new Error("Malformed membership event: device_id must be string"); - if (typeof data.call_id !== "string") throw new Error("Malformed membership event: call_id must be string"); - if (typeof data.scope !== "string") throw new Error("Malformed membership event: scope must be string"); - if (!parentEvent.getSender()) throw new Error("Invalid parent event: sender is null"); } public get sender(): string | undefined { @@ -67,62 +129,89 @@ export class CallMembership { } public get callId(): string { - return this.data.call_id; + return this.membershipData.call_id; } public get deviceId(): string { - return this.data.device_id; + return this.membershipData.device_id; } public get application(): string | undefined { - return this.data.application; + return this.membershipData.application; } - public get scope(): CallScope { - return this.data.scope; + public get scope(): CallScope | undefined { + return this.membershipData.scope; } public get membershipID(): string { - return this.data.membershipID; + if (isLegacyCallMembershipData(this.membershipData)) return this.membershipData.membershipID; + // the createdTs behaves equivalent to the membershipID. + // we only need the field for the legacy member envents where we needed to update them + // synapse ignores sending state events if they have the same content. + else return this.createdTs().toString(); } public createdTs(): number { - return this.data.created_ts ?? this.parentEvent.getTs(); + return this.membershipData.created_ts ?? this.parentEvent.getTs(); } - public getAbsoluteExpiry(): number { - if (this.data.expires) { - return this.createdTs() + this.data.expires; + public getAbsoluteExpiry(): number | undefined { + if (!isLegacyCallMembershipData(this.membershipData)) return undefined; + if ("expires" in this.membershipData) { + // we know createdTs exists since we already do the isLegacyCallMembershipData check + return this.createdTs() + this.membershipData.expires; } else { // We know it exists because we checked for this in the constructor. - return this.data.expires_ts!; + return this.membershipData.expires_ts; } } // gets the expiry time of the event, converted into the device's local time - public getLocalExpiry(): number { - if (this.data.expires) { + public getLocalExpiry(): number | undefined { + if (!isLegacyCallMembershipData(this.membershipData)) return undefined; + if ("expires" in this.membershipData) { + // we know createdTs exists since we already do the isLegacyCallMembershipData check const relativeCreationTime = this.parentEvent.getTs() - this.createdTs(); const localCreationTs = this.parentEvent.localTimestamp - relativeCreationTime; - return localCreationTs + this.data.expires; + return localCreationTs + this.membershipData.expires; } else { // With expires_ts we cannot convert to local time. // TODO: Check the server timestamp and compute a diff to local time. - return this.data.expires_ts!; + return this.membershipData.expires_ts; } } - public getMsUntilExpiry(): number { - return this.getLocalExpiry() - Date.now(); + public getMsUntilExpiry(): number | undefined { + if (isLegacyCallMembershipData(this.membershipData)) return this.getLocalExpiry()! - Date.now(); } public isExpired(): boolean { - return this.getMsUntilExpiry() <= 0; + if (isLegacyCallMembershipData(this.membershipData)) return this.getMsUntilExpiry()! <= 0; + + // MSC4143 events expire by being updated. So if the event exists, its not expired. + return false; + } + + public getPreferredFoci(): Focus[] { + // To support both, the new and the old MatrixRTC memberships have two cases based + // on the availablitiy of `foci_preferred` + if (isLegacyCallMembershipData(this.membershipData)) return this.membershipData.foci_active ?? []; + + // MSC4143 style membership + return this.membershipData.foci_preferred; } - public getActiveFoci(): Focus[] { - return this.data.foci_active ?? []; + public getFocusSelection(): string | undefined { + if (isLegacyCallMembershipData(this.membershipData)) { + return "oldest_membership"; + } else { + const focusActive = this.membershipData.focus_active; + if (isLivekitFocusActive(focusActive)) { + return focusActive.focus_selection; + } + } } } diff --git a/src/matrixrtc/LivekitFocus.ts b/src/matrixrtc/LivekitFocus.ts new file mode 100644 index 00000000000..0a42dda5fd5 --- /dev/null +++ b/src/matrixrtc/LivekitFocus.ts @@ -0,0 +1,39 @@ +/* +Copyright 2023 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { Focus } from "./focus"; + +export interface LivekitFocusConfig extends Focus { + type: "livekit"; + livekit_service_url: string; +} + +export const isLivekitFocusConfig = (object: any): object is LivekitFocusConfig => + object.type === "livekit" && "livekit_service_url" in object; + +export interface LivekitFocus extends LivekitFocusConfig { + livekit_alias: string; +} + +export const isLivekitFocus = (object: any): object is LivekitFocus => + isLivekitFocusConfig(object) && "livekit_alias" in object; + +export interface LivekitFocusActive extends Focus { + type: "livekit"; + focus_selection: "oldest_membership"; +} +export const isLivekitFocusActive = (object: any): object is LivekitFocusActive => + object.type === "livekit" && "focus_selection" in object; diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 7421723ecb4..e7fbd7a48bf 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -20,7 +20,13 @@ import { EventTimeline } from "../models/event-timeline"; import { Room } from "../models/room"; import { MatrixClient } from "../client"; import { EventType } from "../@types/event"; -import { CallMembership, CallMembershipData } from "./CallMembership"; +import { + CallMembership, + CallMembershipData, + CallMembershipDataLegacy, + SessionMembershipData, + isLegacyCallMembershipData, +} from "./CallMembership"; import { RoomStateEvent } from "../models/room-state"; import { Focus } from "./focus"; import { randomString, secureRandomBase64Url } from "../randomstring"; @@ -29,6 +35,8 @@ import { decodeBase64, encodeUnpaddedBase64 } from "../base64"; import { KnownMembership } from "../@types/membership"; import { MatrixError } from "../http-api/errors"; import { MatrixEvent } from "../models/event"; +import { isLivekitFocusActive } from "./LivekitFocus"; +import { ExperimentalGroupCallRoomMemberState } from "../webrtc/groupCall"; const MEMBERSHIP_EXPIRY_TIME = 60 * 60 * 1000; const MEMBER_EVENT_CHECK_PERIOD = 2 * 60 * 1000; // How often we check to see if we need to re-send our member event @@ -57,7 +65,7 @@ export enum MatrixRTCSessionEvent { MembershipsChanged = "memberships_changed", // We joined or left the session: our own local idea of whether we are joined, // separate from MembershipsChanged, ie. independent of whether our member event - // has succesfully gone through. + // has successfully gone through. JoinStateChanged = "join_state_changed", // The key used to encrypt media has changed EncryptionKeyChanged = "encryption_key_changed", @@ -75,7 +83,20 @@ export type MatrixRTCSessionEventHandlerMap = { participantId: string, ) => void; }; - +export interface JoinSessionConfig { + /** If true, generate and share a media key for this participant, + * and emit MatrixRTCSessionEvent.EncryptionKeyChanged when + * media keys for other participants become available. + */ + manageMediaKeys?: boolean; + /** Lets you configure how the events for the session are formatted. + * - legacy: use one event with a membership array. + * - MSC4143: use one event per membership (with only one membership per event) + * More details can be found in MSC4143 and by checking the types: + * `CallMembershipDataLegacy` and `SessionMembershipData` + */ + useLegacyMemberEvents?: boolean; +} /** * A MatrixRTCSession manages the membership & properties of a MatrixRTC session. * This class doesn't deal with media at all, just membership & properties of a session. @@ -102,12 +123,16 @@ export class MatrixRTCSession extends TypedEventEmitter; private setNewKeyTimeouts = new Set>(); - private activeFoci: Focus[] | undefined; + // This is a Focus with the specified fields for an ActiveFocus (e.g. LivekitFocusActive for type="livekit") + private ownFocusActive?: Focus; + // This is a Foci array that contains the Focus objects this user is aware of and proposes to use. + private ownFociPreferred?: Focus[]; private updateCallMembershipRunning = false; private needCallMembershipUpdate = false; private manageMediaKeys = false; + private useLegacyMemberEvents = true; // userId:deviceId => array of keys private encryptionKeys = new Map>(); private lastEncryptionKeyUpdateRequest?: number; @@ -134,21 +159,33 @@ export class MatrixRTCSession extends TypedEventEmitter | undefined { return this.encryptionKeys.get(getParticipantId(userId, deviceId)); } @@ -344,7 +401,7 @@ export class MatrixRTCSession extends TypedEventEmitter { const userId = event.getSender(); const content = event.getContent(); @@ -613,30 +679,41 @@ export class MatrixRTCSession extends TypedEventEmitter { let membershipObj; try { @@ -704,10 +786,10 @@ export class MatrixRTCSession extends TypedEventEmitter => { @@ -742,46 +824,54 @@ export class MatrixRTCSession extends TypedEventEmitter>() ?? {}; - const memberships: CallMembershipData[] = Array.isArray(content["memberships"]) ? content["memberships"] : []; - - const myPrevMembershipData = memberships.find((m) => m.device_id === localDeviceId); - let myPrevMembership; - try { - if (myCallMemberEvent && myPrevMembershipData && myPrevMembershipData.membershipID === this.membershipId) { - myPrevMembership = new CallMembership(myCallMemberEvent, myPrevMembershipData); + const content = myCallMemberEvent?.getContent() ?? {}; + const legacy = "memberships" in content || this.useLegacyMemberEvents; + let newContent: {} | ExperimentalGroupCallRoomMemberState | SessionMembershipData = {}; + if (legacy) { + let myPrevMembership: CallMembership | undefined; + // We know its CallMembershipDataLegacy + const memberships: CallMembershipDataLegacy[] = Array.isArray(content["memberships"]) + ? content["memberships"] + : []; + const myPrevMembershipData = memberships.find((m) => m.device_id === localDeviceId); + try { + if ( + myCallMemberEvent && + myPrevMembershipData && + isLegacyCallMembershipData(myPrevMembershipData) && + myPrevMembershipData.membershipID === this.membershipId + ) { + myPrevMembership = new CallMembership(myCallMemberEvent, myPrevMembershipData); + } + } catch (e) { + // This would indicate a bug or something weird if our own call membership + // wasn't valid + logger.warn("Our previous call membership was invalid - this shouldn't happen.", e); } - } catch (e) { - // This would indicate a bug or something weird if our own call membership - // wasn't valid - logger.warn("Our previous call membership was invalid - this shouldn't happen.", e); - } - - if (myPrevMembership) { - logger.debug(`${myPrevMembership.getMsUntilExpiry()} until our membership expires`); - } - - if (!this.membershipEventNeedsUpdate(myPrevMembershipData, myPrevMembership)) { - // nothing to do - reschedule the check again - this.memberEventTimeout = setTimeout(this.triggerCallMembershipEventUpdate, MEMBER_EVENT_CHECK_PERIOD); - return; + if (myPrevMembership) { + logger.debug(`${myPrevMembership.getMsUntilExpiry()} until our membership expires`); + } + if (!this.membershipEventNeedsUpdate(myPrevMembershipData, myPrevMembership)) { + // nothing to do - reschedule the check again + this.memberEventTimeout = setTimeout(this.triggerCallMembershipEventUpdate, MEMBER_EVENT_CHECK_PERIOD); + return; + } + newContent = this.makeNewLegacyMemberships(memberships, localDeviceId, myCallMemberEvent, myPrevMembership); + } else { + newContent = this.makeNewMembership(localDeviceId); } - const newContent = { - memberships: this.makeNewMemberships(memberships, myCallMemberEvent, myPrevMembership), - }; - try { await this.client.sendStateEvent( this.room.roomId, EventType.GroupCallMemberPrefix, newContent, - localUserId, + this.useLegacyMemberEvents ? localUserId : `${localUserId}_${localDeviceId}`, ); logger.info(`Sent updated call member event.`); // check periodically to see if we need to refresh our member event - if (this.isJoined()) { + if (this.isJoined() && legacy) { this.memberEventTimeout = setTimeout(this.triggerCallMembershipEventUpdate, MEMBER_EVENT_CHECK_PERIOD); } } catch (e) { diff --git a/src/matrixrtc/MatrixRTCSessionManager.ts b/src/matrixrtc/MatrixRTCSessionManager.ts index 188f7b3e176..2fd5d2583f7 100644 --- a/src/matrixrtc/MatrixRTCSessionManager.ts +++ b/src/matrixrtc/MatrixRTCSessionManager.ts @@ -73,9 +73,9 @@ export class MatrixRTCSessionManager extends TypedEventEmitter