From 6a15e8f1a0722ee2725cf4e125989ebf7767b622 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Fri, 21 Jun 2024 20:40:27 +0900 Subject: [PATCH] Use legacy call membership if anyone else is (#4260) * Use legacy call membership if anyone else is * Convert nullish to boolean * Update tests * Lint * Use computed decision to use legacy events or not * Check if discovered legacy sessions are ongoing * Lint * Lint again * Increase test coverage --- spec/unit/matrixrtc/MatrixRTCSession.spec.ts | 146 ++++++++++++++++--- spec/unit/matrixrtc/mocks.ts | 35 +++-- src/matrixrtc/MatrixRTCSession.ts | 25 +++- 3 files changed, 169 insertions(+), 37 deletions(-) diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index e481be966..d0ca37b2d 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -16,7 +16,11 @@ limitations under the License. import { EventTimeline, EventType, MatrixClient, MatrixError, MatrixEvent, Room } from "../../../src"; import { KnownMembership } from "../../../src/@types/membership"; -import { CallMembershipData } from "../../../src/matrixrtc/CallMembership"; +import { + CallMembershipData, + CallMembershipDataLegacy, + SessionMembershipData, +} from "../../../src/matrixrtc/CallMembership"; import { MatrixRTCSession, MatrixRTCSessionEvent } from "../../../src/matrixrtc/MatrixRTCSession"; import { EncryptionKeysEventContent } from "../../../src/matrixrtc/types"; import { randomString } from "../../../src/randomstring"; @@ -99,22 +103,33 @@ describe("MatrixRTCSession", () => { }); it("safely ignores events with no memberships section", () => { + const roomId = randomString(8); + const event = { + getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix), + getContent: jest.fn().mockReturnValue({}), + getSender: jest.fn().mockReturnValue("@mock:user.example"), + getTs: jest.fn().mockReturnValue(1000), + getLocalAge: jest.fn().mockReturnValue(0), + }; const mockRoom = { ...makeMockRoom([]), - roomId: randomString(8), + roomId, getLiveTimeline: jest.fn().mockReturnValue({ getState: jest.fn().mockReturnValue({ on: jest.fn(), off: jest.fn(), - getStateEvents: (_type: string, _stateKey: string) => [ - { - getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix), - getContent: jest.fn().mockReturnValue({}), - getSender: jest.fn().mockReturnValue("@mock:user.example"), - getTs: jest.fn().mockReturnValue(1000), - getLocalAge: jest.fn().mockReturnValue(0), - }, - ], + getStateEvents: (_type: string, _stateKey: string) => [event], + events: new Map([ + [ + EventType.GroupCallMemberPrefix, + { + size: () => true, + has: (_stateKey: string) => true, + get: (_stateKey: string) => event, + values: () => [event], + }, + ], + ]), }), }), }; @@ -123,22 +138,33 @@ describe("MatrixRTCSession", () => { }); it("safely ignores events with junk memberships section", () => { + const roomId = randomString(8); + const event = { + getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix), + getContent: jest.fn().mockReturnValue({ memberships: ["i am a fish"] }), + getSender: jest.fn().mockReturnValue("@mock:user.example"), + getTs: jest.fn().mockReturnValue(1000), + getLocalAge: jest.fn().mockReturnValue(0), + }; const mockRoom = { ...makeMockRoom([]), - roomId: randomString(8), + roomId, getLiveTimeline: jest.fn().mockReturnValue({ getState: jest.fn().mockReturnValue({ on: jest.fn(), off: jest.fn(), - getStateEvents: (_type: string, _stateKey: string) => [ - { - getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix), - getContent: jest.fn().mockReturnValue({ memberships: "i am a fish" }), - getSender: jest.fn().mockReturnValue("@mock:user.example"), - getTs: jest.fn().mockReturnValue(1000), - getLocalAge: jest.fn().mockReturnValue(0), - }, - ], + getStateEvents: (_type: string, _stateKey: string) => [event], + events: new Map([ + [ + EventType.GroupCallMemberPrefix, + { + size: () => true, + has: (_stateKey: string) => true, + get: (_stateKey: string) => event, + values: () => [event], + }, + ], + ]), }), }), }; @@ -186,6 +212,67 @@ describe("MatrixRTCSession", () => { expect(sess.memberships).toHaveLength(0); }); + describe("updateCallMembershipEvent", () => { + const mockFocus = { type: "livekit", livekit_service_url: "https://test.org" }; + const joinSessionConfig = { useLegacyMemberEvents: false }; + + const legacyMembershipData: CallMembershipDataLegacy = { + call_id: "", + scope: "m.room", + application: "m.call", + device_id: "AAAAAAA_legacy", + expires: 60 * 60 * 1000, + membershipID: "bloop", + foci_active: [mockFocus], + }; + + const expiredLegacyMembershipData: CallMembershipDataLegacy = { + ...legacyMembershipData, + device_id: "AAAAAAA_legacy_expired", + expires: 0, + }; + + const sessionMembershipData: SessionMembershipData = { + call_id: "", + scope: "m.room", + application: "m.call", + device_id: "AAAAAAA_session", + focus_active: mockFocus, + foci_preferred: [mockFocus], + }; + + function testSession( + membershipData: CallMembershipData[] | SessionMembershipData, + shouldUseLegacy: boolean, + ): void { + sess = MatrixRTCSession.roomSessionForRoom(client, makeMockRoom(membershipData)); + + const makeNewLegacyMembershipsMock = jest.spyOn(sess as any, "makeNewLegacyMemberships"); + const makeNewMembershipMock = jest.spyOn(sess as any, "makeNewMembership"); + + sess.joinRoomSession([mockFocus], mockFocus, joinSessionConfig); + + expect(makeNewLegacyMembershipsMock).toHaveBeenCalledTimes(shouldUseLegacy ? 1 : 0); + expect(makeNewMembershipMock).toHaveBeenCalledTimes(shouldUseLegacy ? 0 : 1); + } + + it("uses legacy events if there are any active legacy calls", () => { + testSession([expiredLegacyMembershipData, legacyMembershipData, sessionMembershipData], true); + }); + + it('uses legacy events if a non-legacy call is in a "memberships" array', () => { + testSession([sessionMembershipData], true); + }); + + it("uses non-legacy events if all legacy calls are expired", () => { + testSession([expiredLegacyMembershipData], false); + }); + + it("uses non-legacy events if there are only non-legacy calls", () => { + testSession(sessionMembershipData, false); + }); + }); + describe("getOldestMembership", () => { it("returns the oldest membership event", () => { const mockRoom = makeMockRoom([ @@ -340,9 +427,20 @@ describe("MatrixRTCSession", () => { // definitely should have renewed by 1 second before the expiry! const timeElapsed = 60 * 60 * 1000 - 1000; - mockRoom.getLiveTimeline().getState(EventTimeline.FORWARDS)!.getStateEvents = jest - .fn() - .mockReturnValue(mockRTCEvent(eventContent.memberships, mockRoom.roomId, timeElapsed)); + const event = mockRTCEvent(eventContent.memberships, mockRoom.roomId, timeElapsed); + const getState = mockRoom.getLiveTimeline().getState(EventTimeline.FORWARDS)!; + getState.getStateEvents = jest.fn().mockReturnValue(event); + getState.events = new Map([ + [ + event.getType(), + { + size: () => true, + has: (_stateKey: string) => true, + get: (_stateKey: string) => event, + values: () => [event], + } as unknown as Map, + ], + ]); const eventReSentPromise = new Promise>((r) => { resolveFn = (_roomId: string, _type: string, val: Record) => { diff --git a/spec/unit/matrixrtc/mocks.ts b/spec/unit/matrixrtc/mocks.ts index 84496e657..7d63b32b7 100644 --- a/spec/unit/matrixrtc/mocks.ts +++ b/spec/unit/matrixrtc/mocks.ts @@ -15,13 +15,15 @@ limitations under the License. */ import { EventType, MatrixEvent, Room } from "../../../src"; -import { CallMembershipData } from "../../../src/matrixrtc/CallMembership"; +import { CallMembershipData, SessionMembershipData } from "../../../src/matrixrtc/CallMembership"; import { randomString } from "../../../src/randomstring"; -export function makeMockRoom(memberships: CallMembershipData[], localAge: number | null = null): Room { +type MembershipData = CallMembershipData[] | SessionMembershipData; + +export function makeMockRoom(membershipData: MembershipData, localAge: number | null = null): Room { const roomId = randomString(8); // Caching roomState here so it does not get recreated when calling `getLiveTimeline.getState()` - const roomState = makeMockRoomState(memberships, roomId, localAge); + const roomState = makeMockRoomState(membershipData, roomId, localAge); return { roomId: roomId, hasMembershipState: jest.fn().mockReturnValue(true), @@ -31,8 +33,8 @@ export function makeMockRoom(memberships: CallMembershipData[], localAge: number } as unknown as Room; } -export function makeMockRoomState(memberships: CallMembershipData[], roomId: string, localAge: number | null = null) { - const event = mockRTCEvent(memberships, roomId, localAge); +export function makeMockRoomState(membershipData: MembershipData, roomId: string, localAge: number | null = null) { + const event = mockRTCEvent(membershipData, roomId, localAge); return { on: jest.fn(), off: jest.fn(), @@ -40,15 +42,30 @@ export function makeMockRoomState(memberships: CallMembershipData[], roomId: str if (stateKey !== undefined) return event; return [event]; }, + events: new Map([ + [ + event.getType(), + { + size: () => true, + has: (_stateKey: string) => true, + get: (_stateKey: string) => event, + values: () => [event], + }, + ], + ]), }; } -export function mockRTCEvent(memberships: CallMembershipData[], roomId: string, localAge: number | null): MatrixEvent { +export function mockRTCEvent(membershipData: MembershipData, roomId: string, localAge: number | null): MatrixEvent { return { getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix), - getContent: jest.fn().mockReturnValue({ - memberships: memberships, - }), + getContent: jest.fn().mockReturnValue( + !Array.isArray(membershipData) + ? membershipData + : { + memberships: membershipData, + }, + ), getSender: jest.fn().mockReturnValue("@mock:user.example"), getTs: jest.fn().mockReturnValue(1000), localTimestamp: Date.now() - (localAge ?? 10), diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index e7fbd7a48..2f06eba63 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -823,11 +823,14 @@ export class MatrixRTCSession extends TypedEventEmitter): boolean { + for (const callMemberEvent of callMemberEvents.values()) { + const content = callMemberEvent.getContent(); + if (Array.isArray(content["memberships"])) { + for (const membership of content.memberships) { + if (!new CallMembership(callMemberEvent, membership).isExpired()) { + return true; + } + } + } + } + return false; + } + private onRotateKeyTimeout = (): void => { if (!this.manageMediaKeys) return;