6836720e1e
* 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>
419 lines
16 KiB
TypeScript
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);
|
|
}
|
|
}
|