Files
matrix-js-sdk/src/matrixrtc/MatrixRTCSession.ts
T
David Baker 6836720e1e Introduce MatrixRTCSession lower level group call primitive (#3663)
* Add hacky option to disable the actual calling part of group calls.

So we can try using livekit instead.

* Put LiveKit info into the `m.call` state event (#3522)

* Put LK info into state

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Update to the new way the LK service works

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

---------

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Send 'contentLoaded' event

As per comment, so we can start digging ourselves out of the widget
API hole we're currently in.

* Add comment on updating the livekit service URL

* Appease CI on `livekit` branch (#3566)

* Update codeowners on `livekit` branch (#3567)

* add getOpenIdToken to embedded client backend

Signed-off-by: Timo K <toger5@hotmail.de>

* add test and update comment

Signed-off-by: Timo K <toger5@hotmail.de>

* Merge `develop` into `livekit` (#3569)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: RiotRobot <releases@riot.im>
Co-authored-by: Florian Duros <florianduros@element.io>
Co-authored-by: Kerry <kerrya@element.io>
Co-authored-by: David Baker <dbkr@users.noreply.github.com>
Co-authored-by: Erik Johnston <erik@matrix.org>
Co-authored-by: Valere <bill.carson@valrsoft.com>
Co-authored-by: Hubert Chathi <hubertc@matrix.org>
Close IDB database before deleting it to prevent spurious unexpected close errors (#3478)
Fix export type `GeneratedSecretStorageKey` (#3479)
Fix order of things in `crypto-api.ts` (#3491)
Fix bug where switching media caused media in subsequent calls to fail (#3489)
fixes (#3515)
fix the integ tests, where #3509 etc fix the unit tests.
fix breakage on node 16 (#3527)
Fix an instance of failed to decrypt error when an in flight `/keys/query` fails. (#3486)
Fix `TypedEventEmitter::removeAllListeners(void)` not working (#3561)

* Revert "Merge `develop` into `livekit`" (#3572)

* Don't update calls with no livekit URL & expose method to update it instead

and generally simplify a bit: change it to a single string rather than
an array of structs.

* Fix other instances of passing focusInfo / livekit url

* Add temporary setter

* WIP refactor for removing m.call events

* Always remember rtcsessions since we need to only have one instance

* Fix tests

* Fix import loop

* Fix more cyclic imports & tests

* Test session joining

* Attempt to make tests happy

* Always leave calls in the tests to clean up

* comment + desperate attempt to work out what's failing

* More test debugging

* Okay, so these ones are fine?

* Stop more timers and hopefully have happy tests

* Test no rejoin

* Test malformed m.call.member events

* Test event emitting

and also move some code to a more sensible place in the file

* Test getActiveFoci()

* Test event emitting (and also fix it)

* Test membership updating & pruning on join

* Test getOldestMembership()

* Test member event renewal

* Don't start the rtc manager until the client has synced

Then we can initialise from the state once it's completed.

* Fix type

* Remove listeners added in constructor

* Stop the client here too

* Stop the client here also also

* ARGH. Disable tests to work out which one is causing the exception

* Disable everything

* Re-jig to avoid setting listeners in the constructor

and re-enable tests

* No need to rename this anymore

* argh, remove the right listener

* Is it this test???

* Re-enable some tests

* Try mocking getRooms to return something valid

* Re-enable other tests

* Give up trying to get the tests to work sensibly and deal with getRooms() returning nothing

* Oops, don't enable the ones that were skipped before

* One more try at the sensible way

* Didn't work, go back to the hack way.

* Log when we manage to send the member event update

* Support `getOpenIdToken()` in embedded mode (#3676)

* Call `sendContentLoaded()` (#3677)

* Start MatrixRTC in embedded mode (#3679)

* Reschedule the membership event check

* Bump widget api version

* Add mock for sendContentLoaded()

* More log detail

* Fix tests

and also better assert because the tests were passing undefined which
was considered fine because we were only checking for null.

* Simplify updateCallMembershipEvent a bit

* Split up updateCallMembershipEvent some more

* Typo

Co-authored-by: Daniel Abramov <inetcrack2@gmail.com>

* Expand comment

* Add comment

* More comments

* Better comment

* Sesson

* Rename some variables

* Comment

* Remove unused method

* Wrap updatecallMembershipEvent so it only runs one at a time

* Do another update if another one is triggered while the update happens

* Make triggerCallMembershipEventUpdate async

* Fix test & some missed timer removals

* Mark session manager as unstable

---------

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
Signed-off-by: Timo K <toger5@hotmail.de>
Co-authored-by: Šimon Brandner <simon.bra.ag@gmail.com>
Co-authored-by: Timo K <toger5@hotmail.de>
Co-authored-by: Timo <16718859+toger5@users.noreply.github.com>
Co-authored-by: Daniel Abramov <inetcrack2@gmail.com>
2023-09-12 15:08:15 +00:00

419 lines
16 KiB
TypeScript

/*
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 { logger } from "../logger";
import { TypedEventEmitter } from "../models/typed-event-emitter";
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 { Focus } from "./focus";
import { MatrixEvent } from "../matrix";
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
const CALL_MEMBER_EVENT_RETRY_DELAY_MIN = 3000;
export enum MatrixRTCSessionEvent {
// A member joined, left, or updated a property of their membership.
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.
JoinStateChanged = "join_state_changed",
}
export type MatrixRTCSessionEventHandlerMap = {
[MatrixRTCSessionEvent.MembershipsChanged]: (
oldMemberships: CallMembership[],
newMemberships: CallMembership[],
) => void;
[MatrixRTCSessionEvent.JoinStateChanged]: (isJoined: boolean) => void;
};
/**
* 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.
*/
export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, MatrixRTCSessionEventHandlerMap> {
// How many ms after we joined the call, that our membership should expire, or undefined
// if we're not yet joined
private relativeExpiry: number | undefined;
private memberEventTimeout?: ReturnType<typeof setTimeout>;
private expiryTimeout?: ReturnType<typeof setTimeout>;
private activeFoci: Focus[] | undefined;
private updateCallMembershipRunning = false;
private needCallMembershipUpdate = false;
/**
* Returns all the call memberships for a room, oldest first
*/
public static callMembershipsForRoom(room: Room): CallMembership[] {
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS);
if (!roomState) {
logger.warn("Couldn't get state for room " + room.roomId);
throw new Error("Could't get state for room " + room.roomId);
}
const callMemberEvents = roomState.getStateEvents(EventType.GroupCallMemberPrefix);
const callMemberships: CallMembership[] = [];
for (const memberEvent of callMemberEvents) {
const eventMemberships: CallMembershipData[] = memberEvent.getContent()["memberships"];
if (eventMemberships === undefined) {
logger.warn(`Ignoring malformed member event from ${memberEvent.getSender()}: no memberships section`);
continue;
}
if (!Array.isArray(eventMemberships)) {
logger.warn(`Malformed member event from ${memberEvent.getSender()}: memberships is not an array`);
continue;
}
for (const membershipData of eventMemberships) {
try {
const membership = new CallMembership(memberEvent, membershipData);
if (membership.callId !== "" || membership.scope !== "m.room") {
// for now, just ignore anything that isn't the a room scope call
logger.info(`Ignoring user-scoped call`);
continue;
}
if (membership.isExpired()) {
logger.info(
`Ignoring expired device membership ${memberEvent.getSender()}/${membership.deviceId}`,
);
continue;
}
callMemberships.push(membership);
} catch (e) {
logger.warn("Couldn't construct call membership: ", e);
}
}
}
callMemberships.sort((a, b) => a.createdTs() - b.createdTs());
logger.debug(
"Call memberships, in order: ",
callMemberships.map((m) => [m.createdTs(), m.member.userId]),
);
return callMemberships;
}
/**
* Return a the MatrixRTC for the room, whether there are currently active members or not
*/
public static roomSessionForRoom(client: MatrixClient, room: Room): MatrixRTCSession {
const callMemberships = MatrixRTCSession.callMembershipsForRoom(room);
return new MatrixRTCSession(client, room, callMemberships);
}
private constructor(
private readonly client: MatrixClient,
public readonly room: Room,
public memberships: CallMembership[],
) {
super();
this.setExpiryTimer();
}
/*
* Returns true if we intend to be participating in the MatrixRTC session.
*/
public isJoined(): boolean {
return this.relativeExpiry !== undefined;
}
/**
* Performs cleanup & removes timers for client shutdown
*/
public stop(): void {
this.leaveRoomSession();
if (this.expiryTimeout) {
clearTimeout(this.expiryTimeout);
this.expiryTimeout = undefined;
}
if (this.memberEventTimeout) {
clearTimeout(this.memberEventTimeout);
this.memberEventTimeout = undefined;
}
}
/**
* Announces this user and device as joined to the MatrixRTC session,
* and continues to update the membership event to keep it valid until
* leaveRoomSession() is called
* This will not subscribe to updates: remember to call subscribe() separately if
* desired.
* This method will return immediately and the session will be joined in the background.
*/
public joinRoomSession(activeFoci: Focus[]): void {
if (this.isJoined()) {
logger.info(`Already joined to session in room ${this.room.roomId}: ignoring join call`);
return;
}
logger.info(`Joining call session in room ${this.room.roomId}`);
this.activeFoci = activeFoci;
this.relativeExpiry = MEMBERSHIP_EXPIRY_TIME;
this.emit(MatrixRTCSessionEvent.JoinStateChanged, true);
// We don't wait for this, mostly because it may fail and schedule a retry, so this
// function returning doesn't really mean anything at all.
this.triggerCallMembershipEventUpdate();
}
/**
* Announces this user and device as having left the MatrixRTC session
* and stops scheduled updates.
* This will not unsubscribe from updates: remember to call unsubscribe() separately if
* desired.
*/
public leaveRoomSession(): void {
if (!this.isJoined()) {
logger.info(`Not joined to session in room ${this.room.roomId}: ignoring leave call`);
return;
}
logger.info(`Leaving call session in room ${this.room.roomId}`);
this.relativeExpiry = undefined;
this.activeFoci = undefined;
this.emit(MatrixRTCSessionEvent.JoinStateChanged, false);
this.triggerCallMembershipEventUpdate();
}
/**
* Sets a timer for the soonest membership expiry
*/
private setExpiryTimer(): void {
if (this.expiryTimeout) {
clearTimeout(this.expiryTimeout);
this.expiryTimeout = undefined;
}
let soonestExpiry;
for (const membership of this.memberships) {
const thisExpiry = membership.getMsUntilExpiry();
if (soonestExpiry === undefined || thisExpiry < soonestExpiry) {
soonestExpiry = thisExpiry;
}
}
if (soonestExpiry != undefined) {
this.expiryTimeout = setTimeout(this.onMembershipUpdate, soonestExpiry);
}
}
public getOldestMembership(): CallMembership | undefined {
return this.memberships[0];
}
public onMembershipUpdate = (): void => {
const oldMemberships = this.memberships;
this.memberships = MatrixRTCSession.callMembershipsForRoom(this.room);
const changed =
oldMemberships.length != this.memberships.length ||
oldMemberships.some((m, i) => !CallMembership.equal(m, this.memberships[i]));
if (changed) {
logger.info(`Memberships for call in room ${this.room.roomId} have changed: emitting`);
this.emit(MatrixRTCSessionEvent.MembershipsChanged, oldMemberships, this.memberships);
}
this.setExpiryTimer();
};
/**
* Constructs our own membership
* @param prevEvent - The previous version of our call membership, if any
*/
private makeMyMembership(prevMembership?: CallMembership): CallMembershipData {
if (this.relativeExpiry === undefined) {
throw new Error("Tried to create our own membership event when we're not joined!");
}
const m: CallMembershipData = {
call_id: "",
scope: "m.room",
application: "m.call",
device_id: this.client.getDeviceId()!,
expires: this.relativeExpiry,
foci_active: this.activeFoci,
};
if (prevMembership) m.created_ts = prevMembership.createdTs();
return m;
}
/**
* Returns true if our membership event needs to be updated
*/
private membershipEventNeedsUpdate(
myPrevMembershipData?: CallMembershipData,
myPrevMembership?: CallMembership,
): boolean {
// work out if we need to update our membership event
let needsUpdate = false;
// Need to update if there's a membership for us but we're not joined (valid or otherwise)
if (!this.isJoined() && myPrevMembershipData) needsUpdate = true;
if (this.isJoined()) {
// ...or if we are joined, but there's no valid membership event
if (!myPrevMembership) {
needsUpdate = true;
} else if (myPrevMembership.getMsUntilExpiry() < MEMBERSHIP_EXPIRY_TIME / 2) {
// ...or if the expiry time needs bumping
needsUpdate = true;
this.relativeExpiry! += MEMBERSHIP_EXPIRY_TIME;
}
}
return needsUpdate;
}
/**
* Makes a new membership list given the old list alonng with this user's previous membership event
* (if any) and this device's previous membership (if any)
*/
private makeNewMemberships(
oldMemberships: CallMembershipData[],
myCallMemberEvent?: MatrixEvent,
myPrevMembership?: CallMembership,
): CallMembershipData[] {
const localDeviceId = this.client.getDeviceId();
if (!localDeviceId) throw new Error("Local device ID is null!");
const filterExpired = (m: CallMembershipData): boolean => {
let membershipObj;
try {
membershipObj = new CallMembership(myCallMemberEvent!, m);
} catch (e) {
return false;
}
return !membershipObj.isExpired();
};
const transformMemberships = (m: CallMembershipData): CallMembershipData => {
if (m.created_ts === undefined) {
// we need to fill this in with the origin_server_ts from its original event
m.created_ts = myCallMemberEvent!.getTs();
}
return m;
};
// Filter our any invalid or expired memberships, and also our own - we'll add that back in next
let newMemberships = oldMemberships.filter(filterExpired).filter((m) => m.device_id !== localDeviceId);
// Fix up any memberships that need their created_ts adding
newMemberships = newMemberships.map(transformMemberships);
// If we're joined, add our own
if (this.isJoined()) {
newMemberships.push(this.makeMyMembership(myPrevMembership));
}
return newMemberships;
}
private triggerCallMembershipEventUpdate = async (): Promise<void> => {
if (this.updateCallMembershipRunning) {
this.needCallMembershipUpdate = true;
return;
}
this.updateCallMembershipRunning = true;
try {
// if anything triggers an update while the update is running, do another update afterwards
do {
this.needCallMembershipUpdate = false;
await this.updateCallMembershipEvent();
} while (this.needCallMembershipUpdate);
} finally {
this.updateCallMembershipRunning = false;
}
};
private async updateCallMembershipEvent(): Promise<void> {
if (this.memberEventTimeout) {
clearTimeout(this.memberEventTimeout);
this.memberEventTimeout = undefined;
}
const roomState = this.room.getLiveTimeline().getState(EventTimeline.FORWARDS);
if (!roomState) throw new Error("Couldn't get room state for room " + this.room.roomId);
const localUserId = this.client.getUserId();
const localDeviceId = this.client.getDeviceId();
if (!localUserId || !localDeviceId) throw new Error("User ID or device ID was null!");
const myCallMemberEvent = roomState.getStateEvents(EventType.GroupCallMemberPrefix, localUserId) ?? undefined;
const content = myCallMemberEvent?.getContent<Record<any, unknown>>() ?? {};
const memberships: CallMembershipData[] = Array.isArray(content["memberships"]) ? content["memberships"] : [];
const myPrevMembershipData = memberships.find((m) => m.device_id === localDeviceId);
let myPrevMembership;
try {
if (myCallMemberEvent && myPrevMembershipData) {
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);
}
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;
}
const newContent = {
memberships: this.makeNewMemberships(memberships, myCallMemberEvent, myPrevMembership),
};
let resendDelay;
try {
await this.client.sendStateEvent(
this.room.roomId,
EventType.GroupCallMemberPrefix,
newContent,
localUserId,
);
logger.info(`Sent updated call member event.`);
// check periodically to see if we need to refresh our member event
if (this.isJoined()) resendDelay = MEMBER_EVENT_CHECK_PERIOD;
} catch (e) {
resendDelay = CALL_MEMBER_EVENT_RETRY_DELAY_MIN + Math.random() * 2000;
logger.warn(`Failed to send call member event: retrying in ${resendDelay}`);
}
if (resendDelay) this.memberEventTimeout = setTimeout(this.triggerCallMembershipEventUpdate, resendDelay);
}
}