still with broken tests...

This commit is contained in:
Timo K
2025-12-15 18:23:30 +01:00
parent ee2b0c6a5d
commit 909d980dff
10 changed files with 353 additions and 357 deletions

View File

@@ -109,7 +109,7 @@
"livekit-client": "^2.13.0", "livekit-client": "^2.13.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"loglevel": "^1.9.1", "loglevel": "^1.9.1",
"matrix-js-sdk": "^39.2.0", "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#head=toger5/use-membershipID-for-session-state-events&commit=f5f1b8efb46b3d55a7eebfabb4a61496640b8b00",
"matrix-widget-api": "^1.14.0", "matrix-widget-api": "^1.14.0",
"normalize.css": "^8.0.1", "normalize.css": "^8.0.1",
"observable-hooks": "^4.2.3", "observable-hooks": "^4.2.3",

View File

@@ -11,6 +11,12 @@ import {
type MatrixRTCSession, type MatrixRTCSession,
MatrixRTCSessionEvent, MatrixRTCSessionEvent,
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import {
computeLivekitParticipantIdentity,
livekitIdentityInput,
} from "../state/CallViewModel/remoteMembers/MatrixLivekitMembers";
export class MatrixKeyProvider extends BaseKeyProvider { export class MatrixKeyProvider extends BaseKeyProvider {
private rtcSession?: MatrixRTCSession; private rtcSession?: MatrixRTCSession;
@@ -42,31 +48,46 @@ export class MatrixKeyProvider extends BaseKeyProvider {
private onEncryptionKeyChanged = ( private onEncryptionKeyChanged = (
encryptionKey: Uint8Array, encryptionKey: Uint8Array,
encryptionKeyIndex: number, encryptionKeyIndex: number,
participantId: string, membership: CallMembershipIdentityParts,
): void => { ): void => {
crypto.subtle const unhashedIdentity = livekitIdentityInput(membership);
.importKey("raw", encryptionKey, "HKDF", false, [
// This is the only way we can get the kind of the membership event we just received the key for.
// best case we want to recompute this once the memberships change (you can receive the key before the participant...)
//
// TODO change this to `?? "rtc"` for newer versions.
const kind =
this.rtcSession?.memberships.find(
(m) =>
m.userId === membership.userId &&
m.deviceId === membership.deviceId &&
m.memberId === membership.memberId,
)?.kind ?? "session";
Promise.all([
crypto.subtle.importKey("raw", encryptionKey, "HKDF", false, [
"deriveBits", "deriveBits",
"deriveKey", "deriveKey",
]) ]),
.then( computeLivekitParticipantIdentity(membership, kind),
(keyMaterial) => { ]).then(
this.onSetEncryptionKey( ([keyMaterial, livekitParticipantId]) => {
keyMaterial, this.onSetEncryptionKey(
participantId, keyMaterial,
encryptionKeyIndex, livekitParticipantId,
); encryptionKeyIndex,
);
logger.debug( logger.debug(
`Sent new key to livekit room=${this.rtcSession?.room.roomId} participantId=${participantId} encryptionKeyIndex=${encryptionKeyIndex}`, `Sent new key to livekit room=${this.rtcSession?.room.roomId} participantId=${livekitParticipantId} (before hash: ${unhashedIdentity}) encryptionKeyIndex=${encryptionKeyIndex}`,
); );
}, },
(e) => { (e) => {
logger.error( logger.error(
`Failed to create key material from buffer for livekit room=${this.rtcSession?.room.roomId} participantId=${participantId} encryptionKeyIndex=${encryptionKeyIndex}`, `Failed to create key material from buffer for livekit room=${this.rtcSession?.room.roomId} participantId before hash=${unhashedIdentity} encryptionKeyIndex=${encryptionKeyIndex}`,
e, e,
); );
}, },
); );
}; };
} }

View File

@@ -15,7 +15,6 @@ import {
type AudioTrackProps, type AudioTrackProps,
} from "@livekit/components-react"; } from "@livekit/components-react";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { type ParticipantId } from "matrix-js-sdk/lib/matrixrtc";
import { useEarpieceAudioConfig } from "../MediaDevicesContext"; import { useEarpieceAudioConfig } from "../MediaDevicesContext";
import { useReactiveState } from "../useReactiveState"; import { useReactiveState } from "../useReactiveState";
@@ -32,7 +31,7 @@ export interface MatrixAudioRendererProps {
* This list needs to be composed based on the matrixRTC members so that we do not play audio from users * This list needs to be composed based on the matrixRTC members so that we do not play audio from users
* that are not expected to be in the rtc session (local user is excluded). * that are not expected to be in the rtc session (local user is excluded).
*/ */
validIdentities: ParticipantId[]; validIdentities: string[];
/** /**
* If set to `true`, mutes all audio tracks rendered by the component. * If set to `true`, mutes all audio tracks rendered by the component.
* @remarks * @remarks

View File

@@ -785,6 +785,7 @@ export const InCallView: FC<InCallViewProps> = ({
onTouchEnd={onControlsTouchEnd} onTouchEnd={onControlsTouchEnd}
/> />
)} )}
{!showControls && <div className={styles.layout} />}
</div> </div>
); );

View File

@@ -1248,9 +1248,6 @@ describe("CallViewModel", () => {
y: () => { y: () => {
rtcSession.membershipStatus = Status.Connected; rtcSession.membershipStatus = Status.Connected;
}, },
n: () => {
rtcSession.membershipStatus = Status.Reconnecting;
},
}); });
schedule(probablyLeftMarbles, { schedule(probablyLeftMarbles, {
y: () => { y: () => {

View File

@@ -591,10 +591,9 @@ export function createCallViewModel$(
const audioParticipants$ = scope.behavior( const audioParticipants$ = scope.behavior(
matrixLivekitMembers$.pipe( matrixLivekitMembers$.pipe(
switchMap((membersWithEpoch) => { switchMap((members) => {
const members = membersWithEpoch.value;
const a$ = combineLatest( const a$ = combineLatest(
members.map((member) => members.value.map((member) =>
combineLatest([member.connection$, member.participant$]).pipe( combineLatest([member.connection$, member.participant$]).pipe(
map(([connection, participant]) => { map(([connection, participant]) => {
// do not render audio for local participant // do not render audio for local participant
@@ -667,22 +666,22 @@ export function createCallViewModel$(
generateItems( generateItems(
function* ([ function* ([
localMatrixLivekitMember, localMatrixLivekitMember,
{ value: matrixLivekitMembers }, matrixLivekitMembers,
duplicateTiles, duplicateTiles,
]) { ]) {
let localParticipantId: string | undefined = undefined; let localUserMediaId: string | undefined = undefined;
// add local member if available // add local member if available
if (localMatrixLivekitMember) { if (localMatrixLivekitMember) {
const { userId, participant$, connection$, membership$ } = const { userId, participant$, connection$, membership$ } =
localMatrixLivekitMember; localMatrixLivekitMember;
localParticipantId = `${userId}:${membership$.value.deviceId}`; // should be membership$.value.membershipID which is not optional localUserMediaId = `${userId}:${membership$.value.deviceId}`; // should be membership$.value.membershipID which is not optional
// const participantId = membership$.value.membershipID;
if (localParticipantId) { if (localUserMediaId) {
for (let dup = 0; dup < 1 + duplicateTiles; dup++) { for (let dup = 0; dup < 1 + duplicateTiles; dup++) {
yield { yield {
keys: [ keys: [
dup, dup,
localParticipantId, localUserMediaId,
userId, userId,
participant$, participant$,
connection$, connection$,
@@ -698,13 +697,13 @@ export function createCallViewModel$(
participant$, participant$,
connection$, connection$,
membership$, membership$,
} of matrixLivekitMembers) { } of matrixLivekitMembers.value) {
const participantId = `${userId}:${membership$.value.deviceId}`; const userMediaId = `${userId}:${membership$.value.deviceId}`;
if (participantId === localParticipantId) continue; if (userMediaId === localUserMediaId) continue;
// const participantId = membership$.value?.identity; // const participantId = membership$.value?.identity;
for (let dup = 0; dup < 1 + duplicateTiles; dup++) { for (let dup = 0; dup < 1 + duplicateTiles; dup++) {
yield { yield {
keys: [dup, participantId, userId, participant$, connection$], keys: [dup, userMediaId, userId, participant$, connection$],
data: undefined, data: undefined,
}; };
} }

View File

@@ -6,10 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
type LivekitTransport,
type ParticipantId,
} from "matrix-js-sdk/lib/matrixrtc";
import { combineLatest, map, of, switchMap, tap } from "rxjs"; import { combineLatest, map, of, switchMap, tap } from "rxjs";
import { type Logger } from "matrix-js-sdk/lib/logger"; import { type Logger } from "matrix-js-sdk/lib/logger";
import { type LocalParticipant, type RemoteParticipant } from "livekit-client"; import { type LocalParticipant, type RemoteParticipant } from "livekit-client";
@@ -62,24 +59,8 @@ export class ConnectionManagerData {
const key = transport.livekit_service_url + "|" + transport.livekit_alias; const key = transport.livekit_service_url + "|" + transport.livekit_alias;
return this.store.get(key)?.[1] ?? []; return this.store.get(key)?.[1] ?? [];
} }
/**
* Get all connections where the given participant is publishing.
* In theory, there could be several connections where the same participant is publishing but with
* only well behaving clients a participant should only be publishing on a single connection.
* @param participantId
*/
public getConnectionsForParticipant(
participantId: ParticipantId,
): Connection[] {
const connections: Connection[] = [];
for (const [connection, participants] of this.store.values()) {
if (participants.some((p) => p.identity === participantId)) {
connections.push(connection);
}
}
return connections;
}
} }
interface Props { interface Props {
scope: ObservableScope; scope: ObservableScope;
connectionFactory: ConnectionFactory; connectionFactory: ConnectionFactory;
@@ -202,7 +183,7 @@ export function createConnectionManager$({
); );
}), }),
), ),
new Epoch(new ConnectionManagerData()), new Epoch(new ConnectionManagerData(), -1),
); );
return { connectionManagerData$ }; return { connectionManagerData$ };

View File

@@ -10,8 +10,7 @@ import {
type CallMembership, type CallMembership,
type LivekitTransport, type LivekitTransport,
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
import { getParticipantId } from "matrix-js-sdk/lib/matrixrtc/utils"; import { BehaviorSubject, combineLatest, map, type Observable } from "rxjs";
import { combineLatest, map, type Observable } from "rxjs";
import { type IConnectionManager } from "./ConnectionManager.ts"; import { type IConnectionManager } from "./ConnectionManager.ts";
import { import {
@@ -26,14 +25,19 @@ import {
} from "../../ObservableScope.ts"; } from "../../ObservableScope.ts";
import { ConnectionManagerData } from "./ConnectionManager.ts"; import { ConnectionManagerData } from "./ConnectionManager.ts";
import { import {
flushPromises,
mockCallMembership, mockCallMembership,
mockRemoteParticipant, mockRemoteParticipant,
withTestScheduler, withTestScheduler,
} from "../../../utils/test.ts"; } from "../../../utils/test.ts";
import { type Connection } from "./Connection.ts"; import { type Connection } from "./Connection.ts";
import { constant } from "../../Behavior.ts";
let testScope: ObservableScope; let testScope: ObservableScope;
const fallbackMemberId = (userId: string, deviceId: string): string =>
`${userId}:${deviceId}`;
const transportA: LivekitTransport = { const transportA: LivekitTransport = {
type: "livekit", type: "livekit",
livekit_service_url: "https://lk.example.org", livekit_service_url: "https://lk.example.org",
@@ -76,49 +80,41 @@ function epochMeWith$<T, U>(
); );
} }
test("should signal participant not yet connected to livekit", () => { test("should signal participant not yet connected to livekit", async () => {
withTestScheduler(({ behavior, expectObservable }) => { const mockedMemberships$ = new BehaviorSubject([bobMembership]);
const { memberships$, membershipsWithTransport$ } = fromMemberships$( const mockConnectionManagerData$ = new BehaviorSubject(
behavior("a", { new ConnectionManagerData(),
a: [bobMembership], );
}), const { memberships$, membershipsWithTransport$ } =
); createEpochedMemberships$(mockedMemberships$);
const connectionManagerData$ = epochMeWith$( const connectionManagerData$ = epochMeWith$(
memberships$, memberships$,
behavior("a", { mockConnectionManagerData$,
a: new ConnectionManagerData(), );
}),
);
const matrixLivekitMember$ = createMatrixLivekitMembers$({ const matrixLivekitMember$ = createMatrixLivekitMembers$({
scope: testScope, scope: testScope,
membershipsWithTransport$: testScope.behavior(membershipsWithTransport$), membershipsWithTransport$: testScope.behavior(membershipsWithTransport$),
connectionManager: { connectionManager: {
connectionManagerData$: connectionManagerData$, connectionManagerData$: connectionManagerData$,
} as unknown as IConnectionManager, } as unknown as IConnectionManager,
});
expectObservable(matrixLivekitMember$.pipe(map((e) => e.value))).toBe("a", {
a: expect.toSatisfy((data: MatrixLivekitMember[]) => {
expect(data.length).toEqual(1);
expectObservable(data[0].membership$).toBe("a", {
a: bobMembership,
});
expectObservable(data[0].participant$).toBe("a", {
a: null,
});
expectObservable(data[0].connection$).toBe("a", {
a: null,
});
return true;
}),
});
}); });
await flushPromises();
expect(matrixLivekitMember$.value.value).toSatisfy(
(data: MatrixLivekitMember[]) => {
expect(data.length).toEqual(1);
expect(data[0].membership$.value).toBe(bobMembership);
expect(data[0].participant$.value).toBe(null);
expect(data[0].connection$.value).toBe(null);
return true;
},
);
}); });
// Helper to create epoch'ed memberships$ and membershipsWithTransport$ from memberships observable. // Helper to create epoch'ed memberships$ and membershipsWithTransport$ from memberships observable.
function fromMemberships$(m$: Observable<CallMembership[]>): { function createEpochedMemberships$(m$: Observable<CallMembership[]>): {
memberships$: Observable<Epoch<CallMembership[]>>; memberships$: Observable<Epoch<CallMembership[]>>;
membershipsWithTransport$: Observable< membershipsWithTransport$: Observable<
Epoch<{ membership: CallMembership; transport?: LivekitTransport }[]> Epoch<{ membership: CallMembership; transport?: LivekitTransport }[]>
@@ -143,32 +139,115 @@ function fromMemberships$(m$: Observable<CallMembership[]>): {
}; };
} }
test("should signal participant on a connection that is publishing", () => { test("should signal participant on a connection that is publishing", async () => {
withTestScheduler(({ behavior, expectObservable }) => { const bobParticipantId = fallbackMemberId(
const bobParticipantId = getParticipantId( bobMembership.userId,
bobMembership.deviceId,
);
const { memberships$, membershipsWithTransport$ } = createEpochedMemberships$(
constant([bobMembership]),
);
const connection = {
transport: bobMembership.getTransport(bobMembership),
} as unknown as Connection;
const dataWithPublisher = new ConnectionManagerData();
dataWithPublisher.add(connection, [
mockRemoteParticipant({ identity: bobParticipantId }),
]);
const connectionManagerData$ = epochMeWith$(
memberships$,
constant(dataWithPublisher),
);
const matrixLivekitMember$ = createMatrixLivekitMembers$({
scope: testScope,
membershipsWithTransport$: testScope.behavior(membershipsWithTransport$),
connectionManager: {
connectionManagerData$: connectionManagerData$,
} as unknown as IConnectionManager,
});
await flushPromises();
expect(matrixLivekitMember$.value.value).toSatisfy(
(data: MatrixLivekitMember[]) => {
expect(data.length).toEqual(1);
expect(data[0].membership$.value).toBe(bobMembership);
expect(data[0].participant$.value).toSatisfy((participant) => {
expect(participant).toBeDefined();
expect(participant!.identity).toEqual(bobParticipantId);
return true;
});
expect(data[0].connection$.value).toBe(connection);
return true;
},
);
});
test("should signal participant on a connection that is not publishing", async () => {
const { memberships$, membershipsWithTransport$ } = createEpochedMemberships$(
constant([bobMembership]),
);
const connection = {
transport: bobMembership.getTransport(bobMembership),
} as unknown as Connection;
const dataWithPublisher = new ConnectionManagerData();
dataWithPublisher.add(connection, []);
const connectionManagerData$ = epochMeWith$(
memberships$,
constant(dataWithPublisher),
);
const matrixLivekitMember$ = createMatrixLivekitMembers$({
scope: testScope,
membershipsWithTransport$: testScope.behavior(membershipsWithTransport$),
connectionManager: {
connectionManagerData$: connectionManagerData$,
} as unknown as IConnectionManager,
});
await flushPromises();
expect(matrixLivekitMember$.value.value).toSatisfy(
(data: MatrixLivekitMember[]) => {
expect(data.length).toEqual(1);
expect(data[0].membership$.value).toBe(bobMembership);
expect(data[0].participant$.value).toBe(null);
expect(data[0].connection$.value).toBe(connection);
return true;
},
);
});
describe("Publication edge case", () => {
test("bob is publishing in several connections", async () => {
const { memberships$, membershipsWithTransport$ } =
createEpochedMemberships$(constant([bobMembership, carlMembership]));
const connectionWithPublisher = new ConnectionManagerData();
const bobParticipantId = fallbackMemberId(
bobMembership.userId, bobMembership.userId,
bobMembership.deviceId, bobMembership.deviceId,
); );
const connectionA = {
const { memberships$, membershipsWithTransport$ } = fromMemberships$( transport: transportA,
behavior("a", {
a: [bobMembership],
}),
);
const connection = {
transport: bobMembership.getTransport(bobMembership),
} as unknown as Connection; } as unknown as Connection;
const dataWithPublisher = new ConnectionManagerData(); const connectionB = {
dataWithPublisher.add(connection, [ transport: transportB,
} as unknown as Connection;
connectionWithPublisher.add(connectionA, [
mockRemoteParticipant({ identity: bobParticipantId }),
]);
connectionWithPublisher.add(connectionB, [
mockRemoteParticipant({ identity: bobParticipantId }), mockRemoteParticipant({ identity: bobParticipantId }),
]); ]);
const connectionManagerData$ = epochMeWith$( const connectionManagerData$ = epochMeWith$(
memberships$, memberships$,
behavior("a", { constant(connectionWithPublisher),
a: dataWithPublisher,
}),
); );
const matrixLivekitMember$ = createMatrixLivekitMembers$({ const matrixLivekitMember$ = createMatrixLivekitMembers$({
@@ -178,207 +257,73 @@ test("should signal participant on a connection that is publishing", () => {
connectionManagerData$: connectionManagerData$, connectionManagerData$: connectionManagerData$,
} as unknown as IConnectionManager, } as unknown as IConnectionManager,
}); });
await flushPromises();
expect(matrixLivekitMember$.value.value).toSatisfy(
(data: MatrixLivekitMember[]) => {
expect(data.length).toEqual(2);
expect(data[0].membership$.value).toBe(bobMembership);
expect(data[0].connection$.value).toBe(connectionA);
expect(data[0].participant$.value).toSatisfy((participant) => {
expect(participant).toBeDefined();
expect(participant!.identity).toEqual(bobParticipantId);
return true;
});
expectObservable(matrixLivekitMember$.pipe(map((e) => e.value))).toBe("a", {
a: expect.toSatisfy((data: MatrixLivekitMember[]) => {
expect(data.length).toEqual(1);
expectObservable(data[0].membership$).toBe("a", {
a: bobMembership,
});
expectObservable(data[0].participant$).toBe("a", {
a: expect.toSatisfy((participant) => {
expect(participant).toBeDefined();
expect(participant!.identity).toEqual(bobParticipantId);
return true;
}),
});
expectObservable(data[0].connection$).toBe("a", {
a: connection,
});
return true; return true;
}), },
});
});
});
test("should signal participant on a connection that is not publishing", () => {
withTestScheduler(({ behavior, expectObservable }) => {
const { memberships$, membershipsWithTransport$ } = fromMemberships$(
behavior("a", {
a: [bobMembership],
}),
); );
const connection = {
transport: bobMembership.getTransport(bobMembership),
} as unknown as Connection;
const dataWithPublisher = new ConnectionManagerData();
dataWithPublisher.add(connection, []);
const connectionManagerData$ = epochMeWith$(
memberships$,
behavior("a", {
a: dataWithPublisher,
}),
);
const matrixLivekitMember$ = createMatrixLivekitMembers$({
scope: testScope,
membershipsWithTransport$: testScope.behavior(membershipsWithTransport$),
connectionManager: {
connectionManagerData$: connectionManagerData$,
} as unknown as IConnectionManager,
});
expectObservable(matrixLivekitMember$.pipe(map((e) => e.value))).toBe("a", {
a: expect.toSatisfy((data: MatrixLivekitMember[]) => {
expect(data.length).toEqual(1);
expectObservable(data[0].membership$).toBe("a", {
a: bobMembership,
});
expectObservable(data[0].participant$).toBe("a", {
a: null,
});
expectObservable(data[0].connection$).toBe("a", {
a: connection,
});
return true;
}),
});
}); });
}); });
describe("Publication edge case", () => { test("bob is publishing in the wrong connection", async () => {
test("bob is publishing in several connections", () => { const mockedMemberships$ = new BehaviorSubject([
withTestScheduler(({ behavior, expectObservable }) => { bobMembership,
const { memberships$, membershipsWithTransport$ } = fromMemberships$( carlMembership,
behavior("a", { ]);
a: [bobMembership, carlMembership],
}),
);
const connectionWithPublisher = new ConnectionManagerData(); const { memberships$, membershipsWithTransport$ } =
const bobParticipantId = getParticipantId( createEpochedMemberships$(mockedMemberships$);
bobMembership.userId,
bobMembership.deviceId,
);
const connectionA = {
transport: transportA,
} as unknown as Connection;
const connectionB = {
transport: transportB,
} as unknown as Connection;
connectionWithPublisher.add(connectionA, [ const connectionWithPublisher = new ConnectionManagerData();
mockRemoteParticipant({ identity: bobParticipantId }),
]);
connectionWithPublisher.add(connectionB, [
mockRemoteParticipant({ identity: bobParticipantId }),
]);
const connectionManagerData$ = epochMeWith$( const bobParticipantId = fallbackMemberId(
memberships$, bobMembership.userId,
behavior("a", { bobMembership.deviceId,
a: connectionWithPublisher, );
}), const connectionA = { transport: transportA } as unknown as Connection;
); const connectionB = { transport: transportB } as unknown as Connection;
const matrixLivekitMember$ = createMatrixLivekitMembers$({ // Bob is not publishing on A
scope: testScope, connectionWithPublisher.add(connectionA, []);
membershipsWithTransport$: testScope.behavior( // Bob is publishing on B but his membership says A
membershipsWithTransport$, connectionWithPublisher.add(connectionB, [
), mockRemoteParticipant({ identity: bobParticipantId }),
connectionManager: { ]);
connectionManagerData$: connectionManagerData$,
} as unknown as IConnectionManager,
});
expectObservable(matrixLivekitMember$.pipe(map((e) => e.value))).toBe( const connectionsWithPublisher$ = new BehaviorSubject(
"a", connectionWithPublisher,
{ );
a: expect.toSatisfy((data: MatrixLivekitMember[]) => { const connectionManagerData$ = epochMeWith$(
expect(data.length).toEqual(2); memberships$,
expectObservable(data[0].membership$).toBe("a", { connectionsWithPublisher$,
a: bobMembership, );
});
expectObservable(data[0].connection$).toBe("a", { const matrixLivekitMember$ = createMatrixLivekitMembers$({
// The real connection should be from transportA as per the membership scope: testScope,
a: connectionA, membershipsWithTransport$: testScope.behavior(membershipsWithTransport$),
}); connectionManager: {
expectObservable(data[0].participant$).toBe("a", { connectionManagerData$: connectionManagerData$,
a: expect.toSatisfy((participant) => { } as unknown as IConnectionManager,
expect(participant).toBeDefined();
expect(participant!.identity).toEqual(bobParticipantId);
return true;
}),
});
return true;
}),
},
);
});
}); });
test("bob is publishing in the wrong connection", () => { await flushPromises();
withTestScheduler(({ behavior, expectObservable }) => { expect(matrixLivekitMember$.value.value).toSatisfy(
const { memberships$, membershipsWithTransport$ } = fromMemberships$( (data: MatrixLivekitMember[]) => {
behavior("a", { expect(data.length).toEqual(2);
a: [bobMembership, carlMembership], expect(data[0].membership$.value).toBe(bobMembership);
}), expect(data[0].connection$.value).toBe(connectionA);
); expect(data[0].participant$.value).toBe(null);
return true;
const connectionWithPublisher = new ConnectionManagerData(); },
const bobParticipantId = getParticipantId( );
bobMembership.userId,
bobMembership.deviceId,
);
const connectionA = { transport: transportA } as unknown as Connection;
const connectionB = { transport: transportB } as unknown as Connection;
// Bob is not publishing on A
connectionWithPublisher.add(connectionA, []);
// Bob is publishing on B but his membership says A
connectionWithPublisher.add(connectionB, [
mockRemoteParticipant({ identity: bobParticipantId }),
]);
const connectionManagerData$ = epochMeWith$(
memberships$,
behavior("a", {
a: connectionWithPublisher,
}),
);
const matrixLivekitMember$ = createMatrixLivekitMembers$({
scope: testScope,
membershipsWithTransport$: testScope.behavior(
membershipsWithTransport$,
),
connectionManager: {
connectionManagerData$: connectionManagerData$,
} as unknown as IConnectionManager,
});
expectObservable(matrixLivekitMember$.pipe(map((e) => e.value))).toBe(
"a",
{
a: expect.toSatisfy((data: MatrixLivekitMember[]) => {
expect(data.length).toEqual(2);
expectObservable(data[0].membership$).toBe("a", {
a: bobMembership,
});
expectObservable(data[0].connection$).toBe("a", {
// The real connection should be from transportA as per the membership
a: connectionA,
});
expectObservable(data[0].participant$).toBe("a", {
// No participant as Bob is not publishing on his membership transport
a: null,
});
return true;
}),
},
);
});
});
}); });

View File

@@ -13,8 +13,11 @@ import {
type LivekitTransport, type LivekitTransport,
type CallMembership, type CallMembership,
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
import { combineLatest, filter, map } from "rxjs"; import { combineLatest, filter, map, switchMap } from "rxjs";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger"; import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { sha256 } from "matrix-js-sdk/lib/digest";
import { encodeUnpaddedBase64Url } from "matrix-js-sdk";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { type Behavior } from "../../Behavior"; import { type Behavior } from "../../Behavior";
import { type IConnectionManager } from "./ConnectionManager"; import { type IConnectionManager } from "./ConnectionManager";
@@ -62,64 +65,89 @@ export function createMatrixLivekitMembers$({
membershipsWithTransport$, membershipsWithTransport$,
connectionManager, connectionManager,
}: Props): Behavior<Epoch<MatrixLivekitMember[]>> { }: Props): Behavior<Epoch<MatrixLivekitMember[]>> {
/**
* This internal observable is used to compute the async sha256 hash of the user's identity.
* a promise is treated like an observable. So we can switchMap on the promise from the identity computation.
* The last update to `membershipsWithTransport$` will always be the last promise we pass to switchMap.
* So we will eventually always end up with the latest memberships and their identities.
*/
const membershipsWithTransportAndLivekitIdentity$ =
membershipsWithTransport$.pipe(
switchMap(async (membershipsWithTransport) => {
const { value, epoch } = membershipsWithTransport;
const membershipsWithTransportAndLkIdentityPromises = value.map(
async (obj) => {
return computeLivekitParticipantIdentity(
obj.membership,
obj.membership.kind,
);
},
);
const identities = await Promise.all(
membershipsWithTransportAndLkIdentityPromises,
);
const membershipsWithTransportAndLkIdentity = value.map(
({ transport, membership }, index) => {
return { transport, membership, identity: identities[index] };
},
);
return new Epoch(membershipsWithTransportAndLkIdentity, epoch);
}),
);
/** /**
* Stream of all the call members and their associated livekit data (if available). * Stream of all the call members and their associated livekit data (if available).
*/ */
return scope.behavior( return scope.behavior(
combineLatest([ combineLatest([
membershipsWithTransport$, membershipsWithTransportAndLivekitIdentity$,
connectionManager.connectionManagerData$, connectionManager.connectionManagerData$,
]).pipe( ]).pipe(
filter((values) => filter((values) =>
values.every((value) => value.epoch === values[0].epoch), values.every((value) => value.epoch === values[0].epoch),
), ),
map( map(([x, y]) => new Epoch([x.value, y.value] as const, x.epoch)),
([
{ value: membershipsWithTransports, epoch },
{ value: managerData },
]) =>
new Epoch([membershipsWithTransports, managerData] as const, epoch),
),
generateItemsWithEpoch( generateItemsWithEpoch(
// Generator function. // Generator function.
// creates an array of `{key, data}[]` // creates an array of `{key, data}[]`
// Each change in the keys (new key, missing key) will result in a call to the factory function. // Each change in the keys (new key, missing key) will result in a call to the factory function.
function* ([membershipsWithTransports, managerData]) { function* ([membershipsWithTransportAndLivekitIdentity, managerData]) {
for (const { membership, transport } of membershipsWithTransports) { for (const {
// TODO! cannot use membership.membershipID yet, Currently its hardcoded by the jwt service to membership,
const participantId = /*membership.membershipID*/ `${membership.userId}:${membership.deviceId}`; transport,
identity,
} of membershipsWithTransportAndLivekitIdentity) {
const participants = transport const participants = transport
? managerData.getParticipantForTransport(transport) ? managerData.getParticipantForTransport(transport)
: []; : [];
const participant = const participant =
participants.find((p) => p.identity == participantId) ?? null; participants.find((p) => p.identity == identity) ?? null;
const connection = transport const connection = transport
? managerData.getConnectionForTransport(transport) ? managerData.getConnectionForTransport(transport)
: null; : null;
yield { yield {
keys: [participantId, membership.userId], keys: [identity, membership.userId, membership.deviceId],
data: { membership, participant, connection }, data: { membership, participant, connection },
}; };
} }
}, },
// Each update where the key of the generator array do not change will result in updates to the `data$` observable in the factory. // Each update where the key of the generator array do not change will result in updates to the `data$` observable in the factory.
(scope, data$, participantId, userId) => { (scope, data$, identity, userId, deviceId) => {
logger.debug( logger.debug(
`Generating member for participantId: ${participantId}, userId: ${userId}`, `Generating member for livekitIdentity: ${identity}, userId:deviceId: ${userId}${deviceId}`,
); );
// will only get called once per `participantId, userId` pair. // will only get called once per `participantId, userId` pair.
// updates to data$ and as a result to displayName$ and mxcAvatarUrl$ are more frequent. // updates to data$ and as a result to displayName$ and mxcAvatarUrl$ are more frequent.
return { return {
participantId, identity,
userId, userId,
...scope.splitBehavior(data$), ...scope.splitBehavior(data$),
}; };
}, },
), ),
), ),
new Epoch([], -1),
); );
} }
@@ -136,3 +164,42 @@ export function areLivekitTransportsEqual(
if (!t1 && !t2) return true; if (!t1 && !t2) return true;
return false; return false;
} }
const livekitParticipantIdentityCache = new Map<string, string>();
/**
* The string that is computed based on the membership and used for the computing the hash.
* `${userId}:${deviceId}:${membershipID}`
* as the direct imput for: await sha256(input)
*/
export const livekitIdentityInput = ({
userId,
deviceId,
memberId,
}: CallMembershipIdentityParts): string => `${userId}|${deviceId}|${memberId}`;
export async function computeLivekitParticipantIdentity(
membership: CallMembershipIdentityParts,
kind: "rtc" | "session",
): Promise<string> {
switch (kind) {
case "rtc": {
const input = livekitIdentityInput(membership);
if (livekitParticipantIdentityCache.size > 400)
// prevent memory leaks in a stupid/simple way
livekitParticipantIdentityCache.clear();
// TODO use non deprecated memberId
if (livekitParticipantIdentityCache.has(input))
return livekitParticipantIdentityCache.get(input)!;
else {
const hashBuffer = await sha256(input);
const hashedString = encodeUnpaddedBase64Url(hashBuffer);
livekitParticipantIdentityCache.set(input, hashedString);
return hashedString;
}
}
case "session":
default:
return `${membership.userId}:${membership.deviceId}`;
}
}

View File

@@ -2795,10 +2795,10 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@matrix-org/matrix-sdk-crypto-wasm@npm:^15.3.0": "@matrix-org/matrix-sdk-crypto-wasm@npm:^16.0.0":
version: 15.3.0 version: 16.0.0
resolution: "@matrix-org/matrix-sdk-crypto-wasm@npm:15.3.0" resolution: "@matrix-org/matrix-sdk-crypto-wasm@npm:16.0.0"
checksum: 10c0/45628f36b7b0e54a8777ae67a7233dbdf3e3cf14e0d95d21f62f89a7ea7e3f907232f1eb7b1262193b1e227759fad47af829dcccc103ded89011f13c66f01d76 checksum: 10c0/13b4ede3e618da819957abff778afefcf3baf9a2faac04a36bb5a07a44fae2ea05fbfa072eb3408d48b2b7b9aaf27242ce52c594c8ce9bf1fb8b3aade2832be1
languageName: node languageName: node
linkType: hard linkType: hard
@@ -6571,24 +6571,10 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"caniuse-lite@npm:^1.0.30001688": "caniuse-lite@npm:^1.0.30001688, caniuse-lite@npm:^1.0.30001702, caniuse-lite@npm:^1.0.30001726":
version: 1.0.30001701 version: 1.0.30001760
resolution: "caniuse-lite@npm:1.0.30001701" resolution: "caniuse-lite@npm:1.0.30001760"
checksum: 10c0/a814bd4dd8b49645ca51bc6ee42120660a36394bb54eb6084801d3f2bbb9471e5e1a9a8a25f44f83086a032d46e66b33031e2aa345f699b90a7e84a9836b819c checksum: 10c0/cee26dff5c5b15ba073ab230200e43c0d4e88dc3bac0afe0c9ab963df70aaa876c3e513dde42a027f317136bf6e274818d77b073708b74c5807dfad33c029d3c
languageName: node
linkType: hard
"caniuse-lite@npm:^1.0.30001702":
version: 1.0.30001720
resolution: "caniuse-lite@npm:1.0.30001720"
checksum: 10c0/ba9f963364ec4bfc8359d15d7e2cf365185fa1fddc90b4f534c71befedae9b3dd0cd2583a25ffc168a02d7b61b6c18b59bda0a1828ea2a5250fd3e35c2c049e9
languageName: node
linkType: hard
"caniuse-lite@npm:^1.0.30001726":
version: 1.0.30001726
resolution: "caniuse-lite@npm:1.0.30001726"
checksum: 10c0/2c5f91da7fd9ebf8c6b432818b1498ea28aca8de22b30dafabe2a2a6da1e014f10e67e14f8e68e872a0867b6b4cd6001558dde04e3ab9770c9252ca5c8849d0e
languageName: node languageName: node
linkType: hard linkType: hard
@@ -7547,7 +7533,7 @@ __metadata:
livekit-client: "npm:^2.13.0" livekit-client: "npm:^2.13.0"
lodash-es: "npm:^4.17.21" lodash-es: "npm:^4.17.21"
loglevel: "npm:^1.9.1" loglevel: "npm:^1.9.1"
matrix-js-sdk: "npm:^39.2.0" matrix-js-sdk: "github:matrix-org/matrix-js-sdk#head=toger5/use-membershipID-for-session-state-events&commit=f5f1b8efb46b3d55a7eebfabb4a61496640b8b00"
matrix-widget-api: "npm:^1.14.0" matrix-widget-api: "npm:^1.14.0"
normalize.css: "npm:^8.0.1" normalize.css: "npm:^8.0.1"
observable-hooks: "npm:^4.2.3" observable-hooks: "npm:^4.2.3"
@@ -10352,12 +10338,12 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"matrix-js-sdk@npm:^39.2.0": "matrix-js-sdk@github:matrix-org/matrix-js-sdk#head=toger5/use-membershipID-for-session-state-events&commit=f5f1b8efb46b3d55a7eebfabb4a61496640b8b00":
version: 39.2.0 version: 39.3.0
resolution: "matrix-js-sdk@npm:39.2.0" resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=f5f1b8efb46b3d55a7eebfabb4a61496640b8b00"
dependencies: dependencies:
"@babel/runtime": "npm:^7.12.5" "@babel/runtime": "npm:^7.12.5"
"@matrix-org/matrix-sdk-crypto-wasm": "npm:^15.3.0" "@matrix-org/matrix-sdk-crypto-wasm": "npm:^16.0.0"
another-json: "npm:^0.2.0" another-json: "npm:^0.2.0"
bs58: "npm:^6.0.0" bs58: "npm:^6.0.0"
content-type: "npm:^1.0.4" content-type: "npm:^1.0.4"
@@ -10370,7 +10356,7 @@ __metadata:
sdp-transform: "npm:^3.0.0" sdp-transform: "npm:^3.0.0"
unhomoglyph: "npm:^1.0.6" unhomoglyph: "npm:^1.0.6"
uuid: "npm:13" uuid: "npm:13"
checksum: 10c0/f8b5261de2744305330ba3952821ca9303698170bfd3a0ff8a767b9286d4e8d4ed5aaf6fbaf8a1e8ff9dbd859102a2a47d882787e2da3b3078965bec00157959 checksum: 10c0/9607b0c063c873a24c1a2d05cc7500d60c32556ec82b666ebaae5c5e829faf5bb7639780efddea7211e6b9873098bd53b97656f041e932e8b0de0c208ccabbff
languageName: node languageName: node
linkType: hard linkType: hard