From 9ed555050185457f2dc80f452de534c30fc72146 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Wed, 10 Aug 2022 08:51:54 +0200 Subject: [PATCH] Implement GroupCallUtils (#9131) * Implement GroupCallUtils * Trigger CI * Use UnstableValue for new call event types * Implement PR feedback --- src/utils/GroupCallUtils.ts | 175 ++++++++ test/utils/GroupCallUtils-test.ts | 673 ++++++++++++++++++++++++++++++ 2 files changed, 848 insertions(+) create mode 100644 src/utils/GroupCallUtils.ts create mode 100644 test/utils/GroupCallUtils-test.ts diff --git a/src/utils/GroupCallUtils.ts b/src/utils/GroupCallUtils.ts new file mode 100644 index 0000000000..3af6a2b07a --- /dev/null +++ b/src/utils/GroupCallUtils.ts @@ -0,0 +1,175 @@ +/* +Copyright 2022 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 { EventTimeline, MatrixClient, MatrixEvent, RoomState } from "matrix-js-sdk/src/matrix"; +import { UnstableValue } from "matrix-js-sdk/src/NamespacedValue"; +import { deepCopy } from "matrix-js-sdk/src/utils"; + +export const STUCK_DEVICE_TIMEOUT_MS = 1000 * 60 * 60; // 1 hour + +export const CALL_STATE_EVENT_TYPE = new UnstableValue("m.call", "org.matrix.msc3401.call"); +export const CALL_MEMBER_STATE_EVENT_TYPE = new UnstableValue("m.call.member", "org.matrix.msc3401.call.member"); +const CALL_STATE_EVENT_TERMINATED = "m.terminated"; + +interface MDevice { + ["m.device_id"]: string; +} + +interface MCall { + ["m.call_id"]: string; + ["m.devices"]: Array; +} + +interface MCallMemberContent { + ["m.expires_ts"]: number; + ["m.calls"]: Array; +} + +const getRoomState = (client: MatrixClient, roomId: string): RoomState => { + return client.getRoom(roomId) + ?.getLiveTimeline() + ?.getState?.(EventTimeline.FORWARDS); +}; + +/** + * Returns all room state events for the stable and unstable type value. + */ +const getRoomStateEvents = ( + client: MatrixClient, + roomId: string, + type: UnstableValue, +): MatrixEvent[] => { + const roomState = getRoomState(client, roomId); + if (!roomState) return []; + + return [ + ...roomState.getStateEvents(type.name), + ...roomState.getStateEvents(type.altName), + ]; +}; + +/** + * Finds the latest, non-terminated call state event. + */ +export const getGroupCall = (client: MatrixClient, roomId: string): MatrixEvent => { + return getRoomStateEvents(client, roomId, CALL_STATE_EVENT_TYPE) + .sort((a: MatrixEvent, b: MatrixEvent) => b.getTs() - a.getTs()) + .find((event: MatrixEvent) => { + return !(CALL_STATE_EVENT_TERMINATED in event.getContent()); + }); +}; + +/** + * Finds the "m.call.member" events for an "m.call" event. + * + * @returns {MatrixEvent[]} non-expired "m.call.member" events for the call + */ +export const useConnectedMembers = (client: MatrixClient, callEvent: MatrixEvent): MatrixEvent[] => { + if (!CALL_STATE_EVENT_TYPE.matches(callEvent.getType())) return []; + + const callId = callEvent.getStateKey(); + const now = Date.now(); + + return getRoomStateEvents(client, callEvent.getRoomId(), CALL_MEMBER_STATE_EVENT_TYPE) + .filter((callMemberEvent: MatrixEvent): boolean => { + const { + ["m.expires_ts"]: expiresTs, + ["m.calls"]: calls, + } = callMemberEvent.getContent(); + + // state event expired + if (expiresTs && expiresTs < now) return false; + + return !!calls?.find((call: MCall) => call["m.call_id"] === callId); + }) || []; +}; + +/** + * Removes a list of devices from a call. + * Only works for the current user's devices. + */ +const removeDevices = async (client: MatrixClient, callEvent: MatrixEvent, deviceIds: string[]): Promise => { + if (!CALL_STATE_EVENT_TYPE.matches(callEvent.getType())) return; + + const roomId = callEvent.getRoomId(); + const roomState = getRoomState(client, roomId); + if (!roomState) return; + + const callMemberEvent = roomState.getStateEvents(CALL_MEMBER_STATE_EVENT_TYPE.name, client.getUserId()) + ?? roomState.getStateEvents(CALL_MEMBER_STATE_EVENT_TYPE.altName, client.getUserId()); + const callMemberEventContent = callMemberEvent?.getContent(); + if ( + !Array.isArray(callMemberEventContent?.["m.calls"]) + || callMemberEventContent?.["m.calls"].length === 0 + ) { + return; + } + + // copy the content to prevent mutations + const newContent = deepCopy(callMemberEventContent); + const callId = callEvent.getStateKey(); + let changed = false; + + newContent["m.calls"].forEach((call: MCall) => { + // skip other calls + if (call["m.call_id"] !== callId) return; + + call["m.devices"] = call["m.devices"]?.filter((device: MDevice) => { + if (deviceIds.includes(device["m.device_id"])) { + changed = true; + return false; + } + + return true; + }); + }); + + if (changed) { + // only send a new state event if there has been a change + newContent["m.expires_ts"] = Date.now() + STUCK_DEVICE_TIMEOUT_MS; + await client.sendStateEvent( + roomId, + CALL_MEMBER_STATE_EVENT_TYPE.name, + newContent, + client.getUserId(), + ); + } +}; + +/** + * Removes the current device from a call. + */ +export const removeOurDevice = async (client: MatrixClient, callEvent: MatrixEvent) => { + return removeDevices(client, callEvent, [client.getDeviceId()]); +}; + +/** + * Removes all devices of the current user that have not been seen within the STUCK_DEVICE_TIMEOUT_MS. + * Does per default not remove the current device unless includeCurrentDevice is true. + * + * @param {boolean} includeCurrentDevice - Whether to include the current device of this session here. + */ +export const fixStuckDevices = async (client: MatrixClient, callEvent: MatrixEvent, includeCurrentDevice: boolean) => { + const now = Date.now(); + const { devices: myDevices } = await client.getDevices(); + const currentDeviceId = client.getDeviceId(); + const devicesToBeRemoved = myDevices.filter(({ last_seen_ts: lastSeenTs, device_id: deviceId }) => { + return lastSeenTs + && (deviceId !== currentDeviceId || includeCurrentDevice) + && (now - lastSeenTs) > STUCK_DEVICE_TIMEOUT_MS; + }).map(d => d.device_id); + return removeDevices(client, callEvent, devicesToBeRemoved); +}; diff --git a/test/utils/GroupCallUtils-test.ts b/test/utils/GroupCallUtils-test.ts new file mode 100644 index 0000000000..971527e803 --- /dev/null +++ b/test/utils/GroupCallUtils-test.ts @@ -0,0 +1,673 @@ +/* +Copyright 2022 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 { mocked } from "jest-mock"; +import { IMyDevice, MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; + +import { + CALL_MEMBER_STATE_EVENT_TYPE, + CALL_STATE_EVENT_TYPE, + fixStuckDevices, + getGroupCall, + removeOurDevice, + STUCK_DEVICE_TIMEOUT_MS, + useConnectedMembers, +} from "../../src/utils/GroupCallUtils"; +import { createTestClient, mkEvent } from "../test-utils"; + +[ + { + callStateEventType: CALL_STATE_EVENT_TYPE.name, + callMemberStateEventType: CALL_MEMBER_STATE_EVENT_TYPE.name, + }, + { + callStateEventType: CALL_STATE_EVENT_TYPE.altName, + callMemberStateEventType: CALL_MEMBER_STATE_EVENT_TYPE.altName, + }, +].forEach(({ callStateEventType, callMemberStateEventType }) => { + describe(`GroupCallUtils (${callStateEventType}, ${callMemberStateEventType})`, () => { + const roomId = "!room:example.com"; + let client: MatrixClient; + let callEvent: MatrixEvent; + const callId = "test call"; + const callId2 = "test call 2"; + const userId1 = "@user1:example.com"; + const now = 1654616071686; + + const setUpNonCallStateEvent = () => { + callEvent = mkEvent({ + room: roomId, + user: userId1, + event: true, + type: "test", + skey: userId1, + content: {}, + }); + }; + + const setUpEmptyStateKeyCallEvent = () => { + callEvent = mkEvent({ + room: roomId, + user: userId1, + event: true, + type: callStateEventType, + skey: "", + content: {}, + }); + }; + + const setUpValidCallEvent = () => { + callEvent = mkEvent({ + room: roomId, + user: userId1, + event: true, + type: callStateEventType, + skey: callId, + content: {}, + }); + }; + + beforeEach(() => { + client = createTestClient(); + }); + + describe("getGroupCall", () => { + describe("for a non-existing room", () => { + beforeEach(() => { + mocked(client.getRoom).mockReturnValue(null); + }); + + it("should return null", () => { + expect(getGroupCall(client, roomId)).toBeUndefined(); + }); + }); + + describe("for an existing room", () => { + let room: Room; + + beforeEach(() => { + room = new Room(roomId, client, client.getUserId()); + mocked(client.getRoom).mockImplementation((rid: string) => { + return rid === roomId + ? room + : null; + }); + }); + + it("should return null if no 'call' state event exist", () => { + expect(getGroupCall(client, roomId)).toBeUndefined(); + }); + + describe("with call state events", () => { + let callEvent1: MatrixEvent; + let callEvent2: MatrixEvent; + let callEvent3: MatrixEvent; + + beforeEach(() => { + callEvent1 = mkEvent({ + room: roomId, + user: client.getUserId(), + event: true, + type: callStateEventType, + content: {}, + ts: 150, + skey: "call1", + }); + room.getLiveTimeline().addEvent(callEvent1, { + toStartOfTimeline: false, + }); + + callEvent2 = mkEvent({ + room: roomId, + user: client.getUserId(), + event: true, + type: callStateEventType, + content: {}, + ts: 100, + skey: "call2", + }); + room.getLiveTimeline().addEvent(callEvent2, { + toStartOfTimeline: false, + }); + + // terminated call - should never be returned + callEvent3 = mkEvent({ + room: roomId, + user: client.getUserId(), + event: true, + type: callStateEventType, + content: { + ["m.terminated"]: "time's up", + }, + ts: 500, + skey: "call3", + }); + room.getLiveTimeline().addEvent(callEvent3, { + toStartOfTimeline: false, + }); + }); + + it("should return the newest call state event (1)", () => { + expect(getGroupCall(client, roomId)).toBe(callEvent1); + }); + + it("should return the newest call state event (2)", () => { + callEvent2.getTs = () => 200; + expect(getGroupCall(client, roomId)).toBe(callEvent2); + }); + }); + }); + }); + + describe("useConnectedMembers", () => { + describe("for a non-call event", () => { + beforeEach(() => { + setUpNonCallStateEvent(); + }); + + it("should return an empty list", () => { + expect(useConnectedMembers(client, callEvent)).toEqual([]); + }); + }); + + describe("for an empty state key", () => { + beforeEach(() => { + setUpEmptyStateKeyCallEvent(); + }); + + it("should return an empty list", () => { + expect(useConnectedMembers(client, callEvent)).toEqual([]); + }); + }); + + describe("for a valid call state event", () => { + beforeEach(() => { + setUpValidCallEvent(); + }); + + describe("and a non-existing room", () => { + beforeEach(() => { + mocked(client.getRoom).mockReturnValue(null); + }); + + it("should return an empty list", () => { + expect(useConnectedMembers(client, callEvent)).toEqual([]); + }); + }); + + describe("and an existing room", () => { + let room: Room; + + beforeEach(() => { + room = new Room(roomId, client, client.getUserId()); + mocked(client.getRoom).mockImplementation((rid: string) => { + return rid === roomId + ? room + : null; + }); + }); + + it("should return an empty list if no call member state events exist", () => { + expect(useConnectedMembers(client, callEvent)).toEqual([]); + }); + + describe("and some call member state events", () => { + const userId2 = "@user2:example.com"; + const userId3 = "@user3:example.com"; + const userId4 = "@user4:example.com"; + let expectedEvent1: MatrixEvent; + let expectedEvent2: MatrixEvent; + + beforeEach(() => { + jest.useFakeTimers() + .setSystemTime(now); + + expectedEvent1 = mkEvent({ + event: true, + room: roomId, + user: userId1, + skey: userId1, + type: callMemberStateEventType, + content: { + ["m.expires_ts"]: now + 100, + ["m.calls"]: [ + { + ["m.call_id"]: callId2, + }, + { + ["m.call_id"]: callId, + }, + ], + }, + }); + room.getLiveTimeline().addEvent(expectedEvent1, { toStartOfTimeline: false }); + + expectedEvent2 = mkEvent({ + event: true, + room: roomId, + user: userId2, + skey: userId2, + type: callMemberStateEventType, + content: { + ["m.expires_ts"]: now + 100, + ["m.calls"]: [ + { + ["m.call_id"]: callId, + }, + ], + }, + }); + room.getLiveTimeline().addEvent(expectedEvent2, { toStartOfTimeline: false }); + + // expired event + const event3 = mkEvent({ + event: true, + room: roomId, + user: userId3, + skey: userId3, + type: callMemberStateEventType, + content: { + ["m.expires_ts"]: now - 100, + ["m.calls"]: [ + { + ["m.call_id"]: callId, + }, + ], + }, + }); + room.getLiveTimeline().addEvent(event3, { toStartOfTimeline: false }); + + // other call + const event4 = mkEvent({ + event: true, + room: roomId, + user: userId4, + skey: userId4, + type: callMemberStateEventType, + content: { + ["m.expires_ts"]: now + 100, + ["m.calls"]: [ + { + ["m.call_id"]: callId2, + }, + ], + }, + }); + room.getLiveTimeline().addEvent(event4, { toStartOfTimeline: false }); + + // empty calls + const event5 = mkEvent({ + event: true, + room: roomId, + user: userId4, + skey: userId4, + type: callMemberStateEventType, + content: { + ["m.expires_ts"]: now + 100, + ["m.calls"]: [], + }, + }); + room.getLiveTimeline().addEvent(event5, { toStartOfTimeline: false }); + + // no calls prop + const event6 = mkEvent({ + event: true, + room: roomId, + user: userId4, + skey: userId4, + type: callMemberStateEventType, + content: { + ["m.expires_ts"]: now + 100, + }, + }); + room.getLiveTimeline().addEvent(event6, { toStartOfTimeline: false }); + }); + + it("should return the expected call member events", () => { + const callMemberEvents = useConnectedMembers(client, callEvent); + expect(callMemberEvents).toHaveLength(2); + expect(callMemberEvents).toContain(expectedEvent1); + expect(callMemberEvents).toContain(expectedEvent2); + }); + }); + }); + }); + }); + + describe("removeOurDevice", () => { + describe("for a non-call event", () => { + beforeEach(() => { + setUpNonCallStateEvent(); + }); + + it("should not update the state", () => { + removeOurDevice(client, callEvent); + expect(client.sendStateEvent).not.toHaveBeenCalled(); + }); + }); + + describe("for an empty state key", () => { + beforeEach(() => { + setUpEmptyStateKeyCallEvent(); + }); + + it("should not update the state", () => { + removeOurDevice(client, callEvent); + expect(client.sendStateEvent).not.toHaveBeenCalled(); + }); + }); + + describe("for a valid call state event", () => { + beforeEach(() => { + setUpValidCallEvent(); + }); + + describe("and a non-existing room", () => { + beforeEach(() => { + mocked(client.getRoom).mockReturnValue(null); + }); + + it("should not update the state", () => { + removeOurDevice(client, callEvent); + expect(client.sendStateEvent).not.toHaveBeenCalled(); + }); + }); + + describe("and an existing room", () => { + let room: Room; + + beforeEach(() => { + room = new Room(roomId, client, client.getUserId()); + room.getLiveTimeline().addEvent(callEvent, { toStartOfTimeline: false }); + mocked(client.getRoom).mockImplementation((rid: string) => { + return rid === roomId + ? room + : null; + }); + }); + + it("should not update the state if no call member event exists", () => { + removeOurDevice(client, callEvent); + expect(client.sendStateEvent).not.toHaveBeenCalled(); + }); + + describe("and a call member state event", () => { + beforeEach(() => { + jest.useFakeTimers() + .setSystemTime(now); + + const callMemberEvent = mkEvent({ + event: true, + room: roomId, + user: client.getUserId(), + skey: client.getUserId(), + type: callMemberStateEventType, + content: { + ["m.expires_ts"]: now - 100, + ["m.calls"]: [ + { + ["m.call_id"]: callId, + ["m.devices"]: [ + // device to be removed + { "m.device_id": client.getDeviceId() }, + { "m.device_id": "device 2" }, + ], + }, + { + // no device list + ["m.call_id"]: callId, + }, + { + // other call + ["m.call_id"]: callId2, + ["m.devices"]: [ + { "m.device_id": client.getDeviceId() }, + ], + }, + ], + }, + }); + room.getLiveTimeline().addEvent(callMemberEvent, { toStartOfTimeline: false }); + }); + + it("should remove the device from the call", async () => { + await removeOurDevice(client, callEvent); + expect(client.sendStateEvent).toHaveBeenCalledTimes(1); + expect(client.sendStateEvent).toHaveBeenCalledWith( + roomId, + CALL_MEMBER_STATE_EVENT_TYPE.name, + { + ["m.expires_ts"]: now + STUCK_DEVICE_TIMEOUT_MS, + ["m.calls"]: [ + { + ["m.call_id"]: callId, + ["m.devices"]: [ + { "m.device_id": "device 2" }, + ], + }, + { + // no device list + ["m.call_id"]: callId, + }, + { + // other call + ["m.call_id"]: callId2, + ["m.devices"]: [ + { "m.device_id": client.getDeviceId() }, + ], + }, + ], + }, + client.getUserId(), + ); + }); + }); + }); + }); + }); + + describe("fixStuckDevices", () => { + let thisDevice: IMyDevice; + let otherDevice: IMyDevice; + let noLastSeenTsDevice: IMyDevice; + let stuckDevice: IMyDevice; + + beforeEach(() => { + jest.useFakeTimers() + .setSystemTime(now); + + thisDevice = { device_id: "ABCDEFGHI", last_seen_ts: now - STUCK_DEVICE_TIMEOUT_MS - 100 }; + otherDevice = { device_id: "ABCDEFGHJ", last_seen_ts: now }; + noLastSeenTsDevice = { device_id: "ABCDEFGHK" }; + stuckDevice = { device_id: "ABCDEFGHL", last_seen_ts: now - STUCK_DEVICE_TIMEOUT_MS - 100 }; + + mocked(client.getDeviceId).mockReturnValue(thisDevice.device_id); + mocked(client.getDevices).mockResolvedValue({ + devices: [ + thisDevice, + otherDevice, + noLastSeenTsDevice, + stuckDevice, + ], + }); + }); + + describe("for a non-call event", () => { + beforeEach(() => { + setUpNonCallStateEvent(); + }); + + it("should not update the state", () => { + fixStuckDevices(client, callEvent, true); + expect(client.sendStateEvent).not.toHaveBeenCalled(); + }); + }); + + describe("for an empty state key", () => { + beforeEach(() => { + setUpEmptyStateKeyCallEvent(); + }); + + it("should not update the state", () => { + fixStuckDevices(client, callEvent, true); + expect(client.sendStateEvent).not.toHaveBeenCalled(); + }); + }); + + describe("for a valid call state event", () => { + beforeEach(() => { + setUpValidCallEvent(); + }); + + describe("and a non-existing room", () => { + beforeEach(() => { + mocked(client.getRoom).mockReturnValue(null); + }); + + it("should not update the state", () => { + fixStuckDevices(client, callEvent, true); + expect(client.sendStateEvent).not.toHaveBeenCalled(); + }); + }); + + describe("and an existing room", () => { + let room: Room; + + beforeEach(() => { + room = new Room(roomId, client, client.getUserId()); + room.getLiveTimeline().addEvent(callEvent, { toStartOfTimeline: false }); + mocked(client.getRoom).mockImplementation((rid: string) => { + return rid === roomId + ? room + : null; + }); + }); + + it("should not update the state if no call member event exists", () => { + fixStuckDevices(client, callEvent, true); + expect(client.sendStateEvent).not.toHaveBeenCalled(); + }); + + describe("and a call member state event", () => { + beforeEach(() => { + const callMemberEvent = mkEvent({ + event: true, + room: roomId, + user: client.getUserId(), + skey: client.getUserId(), + type: callMemberStateEventType, + content: { + ["m.expires_ts"]: now - 100, + ["m.calls"]: [ + { + ["m.call_id"]: callId, + ["m.devices"]: [ + { "m.device_id": thisDevice.device_id }, + { "m.device_id": otherDevice.device_id }, + { "m.device_id": noLastSeenTsDevice.device_id }, + { "m.device_id": stuckDevice.device_id }, + ], + }, + { + // no device list + ["m.call_id"]: callId, + }, + { + // other call + ["m.call_id"]: callId2, + ["m.devices"]: [ + { "m.device_id": stuckDevice.device_id }, + ], + }, + ], + }, + }); + room.getLiveTimeline().addEvent(callMemberEvent, { toStartOfTimeline: false }); + }); + + it("should remove stuck devices from the call, except this device", async () => { + await fixStuckDevices(client, callEvent, false); + expect(client.sendStateEvent).toHaveBeenCalledTimes(1); + expect(client.sendStateEvent).toHaveBeenCalledWith( + roomId, + CALL_MEMBER_STATE_EVENT_TYPE.name, + { + ["m.expires_ts"]: now + STUCK_DEVICE_TIMEOUT_MS, + ["m.calls"]: [ + { + ["m.call_id"]: callId, + ["m.devices"]: [ + { "m.device_id": thisDevice.device_id }, + { "m.device_id": otherDevice.device_id }, + { "m.device_id": noLastSeenTsDevice.device_id }, + ], + }, + { + // no device list + ["m.call_id"]: callId, + }, + { + // other call + ["m.call_id"]: callId2, + ["m.devices"]: [ + { "m.device_id": stuckDevice.device_id }, + ], + }, + ], + }, + client.getUserId(), + ); + }); + + it("should remove stuck devices from the call, including this device", async () => { + await fixStuckDevices(client, callEvent, true); + expect(client.sendStateEvent).toHaveBeenCalledTimes(1); + expect(client.sendStateEvent).toHaveBeenCalledWith( + roomId, + CALL_MEMBER_STATE_EVENT_TYPE.name, + { + ["m.expires_ts"]: now + STUCK_DEVICE_TIMEOUT_MS, + ["m.calls"]: [ + { + ["m.call_id"]: callId, + ["m.devices"]: [ + { "m.device_id": otherDevice.device_id }, + { "m.device_id": noLastSeenTsDevice.device_id }, + ], + }, + { + // no device list + ["m.call_id"]: callId, + }, + { + // other call + ["m.call_id"]: callId2, + ["m.devices"]: [ + { "m.device_id": stuckDevice.device_id }, + ], + }, + ], + }, + client.getUserId(), + ); + }); + }); + }); + }); + }); + }); +}); +