From 5bc6ed5885c8de4220f7542fe4382c727c03629f Mon Sep 17 00:00:00 2001 From: Timo K Date: Mon, 15 Dec 2025 20:17:57 +0100 Subject: [PATCH] small refactor to make it testable. --- src/room/CallEventAudioRenderer.test.tsx | 8 +++ src/room/InCallView.test.tsx | 10 ++- src/state/CallViewModel/CallViewModel.test.ts | 22 +++++-- .../LivekitParticipantIdentity.ts | 53 +++++++++++++++ .../MatrixLivekitMembers.test.ts | 1 - .../remoteMembers/MatrixLivekitMembers.ts | 66 ++++--------------- .../remoteMembers/integration.test.ts | 6 ++ src/utils/test.ts | 28 +++++++- 8 files changed, 134 insertions(+), 60 deletions(-) create mode 100644 src/state/CallViewModel/remoteMembers/LivekitParticipantIdentity.ts diff --git a/src/room/CallEventAudioRenderer.test.tsx b/src/room/CallEventAudioRenderer.test.tsx index 733346eb..38f56b14 100644 --- a/src/room/CallEventAudioRenderer.test.tsx +++ b/src/room/CallEventAudioRenderer.test.tsx @@ -23,6 +23,7 @@ import { import { exampleTransport, + mockComputeLivekitParticipantIdentity$, mockMatrixRoomMember, mockRtcMembership, } from "../utils/test"; @@ -47,6 +48,13 @@ vitest.mock("../rtcSessionHelpers", async (importOriginal) => ({ ...(await importOriginal()), makeTransport: (): [LivekitTransport] => [exampleTransport], })); +vitest.mock( + import("../state/CallViewModel/remoteMembers/LivekitParticipantIdentity.ts"), + async (importOriginal) => ({ + ...(await importOriginal()), + computeLivekitParticipantIdentity$: mockComputeLivekitParticipantIdentity$, + }), +); afterEach(() => { vitest.clearAllMocks(); diff --git a/src/room/InCallView.test.tsx b/src/room/InCallView.test.tsx index a137074b..cd0af547 100644 --- a/src/room/InCallView.test.tsx +++ b/src/room/InCallView.test.tsx @@ -22,8 +22,8 @@ import { BrowserRouter } from "react-router-dom"; import { TooltipProvider } from "@vector-im/compound-web"; import { RoomContext, useLocalParticipant } from "@livekit/components-react"; -import { InCallView } from "./InCallView"; import { + mockComputeLivekitParticipantIdentity$, mockLivekitRoom, mockLocalParticipant, mockMatrixRoom, @@ -34,6 +34,7 @@ import { mockRtcMembership, type MockRTCSession, } from "../utils/test"; +import { InCallView } from "./InCallView"; import { E2eeType } from "../e2ee/e2eeType"; import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel"; import { alice, local } from "../utils/test-fixtures"; @@ -61,6 +62,13 @@ vi.mock("../livekit/MatrixAudioRenderer"); vi.mock("react-use-measure", () => ({ default: (): [() => void, object] => [(): void => {}, {}], })); +vi.mock( + import("../state/CallViewModel/remoteMembers/LivekitParticipantIdentity.ts"), + async (importOriginal) => ({ + ...(await importOriginal()), + computeLivekitParticipantIdentity$: mockComputeLivekitParticipantIdentity$, + }), +); const localRtcMember = mockRtcMembership("@carol:example.org", "CCCC"); const localParticipant = mockLocalParticipant({ diff --git a/src/state/CallViewModel/CallViewModel.test.ts b/src/state/CallViewModel/CallViewModel.test.ts index be598702..58814bbe 100644 --- a/src/state/CallViewModel/CallViewModel.test.ts +++ b/src/state/CallViewModel/CallViewModel.test.ts @@ -44,6 +44,7 @@ import { mockRtcMembership, testScope, exampleTransport, + mockComputeLivekitParticipantIdentity$, } from "../../utils/test.ts"; import { E2eeType } from "../../e2ee/e2eeType.ts"; import { @@ -77,11 +78,22 @@ vi.mock("../e2ee/matrixKeyProvider"); const getUrlParams = vi.hoisted(() => vi.fn(() => ({}))); vi.mock("../UrlParams", () => ({ getUrlParams })); -vi.mock("../rtcSessionHelpers", async (importOriginal) => ({ - ...(await importOriginal()), - makeTransport: async (): Promise => - Promise.resolve(exampleTransport), -})); +vi.mock( + "../state/CallViewModel/localMember/localTransport", + async (importOriginal) => ({ + ...(await importOriginal()), + makeTransport: async (): Promise => + Promise.resolve(exampleTransport), + }), +); + +vi.mock( + import("./remoteMembers/LivekitParticipantIdentity.ts"), + async (importOriginal) => ({ + ...(await importOriginal()), + computeLivekitParticipantIdentity$: mockComputeLivekitParticipantIdentity$, + }), +); const yesNo = { y: true, diff --git a/src/state/CallViewModel/remoteMembers/LivekitParticipantIdentity.ts b/src/state/CallViewModel/remoteMembers/LivekitParticipantIdentity.ts new file mode 100644 index 00000000..e43d0bd1 --- /dev/null +++ b/src/state/CallViewModel/remoteMembers/LivekitParticipantIdentity.ts @@ -0,0 +1,53 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { encodeUnpaddedBase64Url } from "matrix-js-sdk"; +import { sha256 } from "matrix-js-sdk/lib/digest"; +import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager"; +import { from, type Observable } from "rxjs"; + +const livekitParticipantIdentityCache = new Map(); + +/** + * 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 function computeLivekitParticipantIdentity$( + membership: CallMembershipIdentityParts, + kind: "rtc" | "session", +): Observable { + const compute = async (): Promise => { + 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}`; + } + }; + return from(compute()); +} diff --git a/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.test.ts b/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.test.ts index c2e60798..68a67546 100644 --- a/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.test.ts +++ b/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.test.ts @@ -28,7 +28,6 @@ import { flushPromises, mockCallMembership, mockRemoteParticipant, - withTestScheduler, } from "../../../utils/test.ts"; import { type Connection } from "./Connection.ts"; import { constant } from "../../Behavior.ts"; diff --git a/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts b/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts index 9c6a05c9..30abfc9b 100644 --- a/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts +++ b/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts @@ -15,15 +15,13 @@ import { } from "matrix-js-sdk/lib/matrixrtc"; import { combineLatest, filter, map, switchMap } from "rxjs"; 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 IConnectionManager } from "./ConnectionManager"; import { Epoch, type ObservableScope } from "../../ObservableScope"; import { type Connection } from "./Connection"; import { generateItemsWithEpoch } from "../../../utils/observable"; +import { computeLivekitParticipantIdentity$ } from "./LivekitParticipantIdentity"; const logger = rootLogger.getChild("[MatrixLivekitMembers]"); @@ -73,25 +71,28 @@ export function createMatrixLivekitMembers$({ */ const membershipsWithTransportAndLivekitIdentity$ = membershipsWithTransport$.pipe( - switchMap(async (membershipsWithTransport) => { + switchMap((membershipsWithTransport) => { const { value, epoch } = membershipsWithTransport; const membershipsWithTransportAndLkIdentityPromises = value.map( - async (obj) => { - return computeLivekitParticipantIdentity( + (obj) => { + return computeLivekitParticipantIdentity$( obj.membership, obj.membership.kind, ); }, ); - const identities = await Promise.all( + return combineLatest( membershipsWithTransportAndLkIdentityPromises, + ).pipe( + map((identities) => { + const membershipsWithTransportAndLkIdentity = value.map( + ({ transport, membership }, index) => { + return { transport, membership, identity: identities[index] }; + }, + ); + return new Epoch(membershipsWithTransportAndLkIdentity, epoch); + }), ); - const membershipsWithTransportAndLkIdentity = value.map( - ({ transport, membership }, index) => { - return { transport, membership, identity: identities[index] }; - }, - ); - return new Epoch(membershipsWithTransportAndLkIdentity, epoch); }), ); @@ -164,42 +165,3 @@ export function areLivekitTransportsEqual( if (!t1 && !t2) return true; return false; } - -const livekitParticipantIdentityCache = new Map(); - -/** - * 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 { - 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}`; - } -} diff --git a/src/state/CallViewModel/remoteMembers/integration.test.ts b/src/state/CallViewModel/remoteMembers/integration.test.ts index e3aa6be8..3ad81fa8 100644 --- a/src/state/CallViewModel/remoteMembers/integration.test.ts +++ b/src/state/CallViewModel/remoteMembers/integration.test.ts @@ -22,6 +22,7 @@ import { ECConnectionFactory } from "./ConnectionFactory.ts"; import { type OpenIDClientParts } from "../../../livekit/openIDSFU.ts"; import { mockCallMembership, + mockComputeLivekitParticipantIdentity$, mockMediaDevices, withTestScheduler, } from "../../../utils/test.ts"; @@ -43,6 +44,11 @@ let lkRoomFactory: () => LivekitRoom; const createdMockLivekitRooms: Map = new Map(); +vi.mock(import("./LivekitParticipantIdentity.ts"), async (importOriginal) => ({ + ...(await importOriginal()), + computeLivekitParticipantIdentity$: mockComputeLivekitParticipantIdentity$, +})); + beforeEach(() => { testScope = new ObservableScope(); mockClient = { diff --git a/src/utils/test.ts b/src/utils/test.ts index bd7dcd6f..28fc8546 100644 --- a/src/utils/test.ts +++ b/src/utils/test.ts @@ -15,6 +15,7 @@ import { vitest, } from "vitest"; import { + encodeUnpaddedBase64, MatrixEvent, type Room as MatrixRoom, type Room, @@ -43,13 +44,14 @@ import { type Room as LivekitRoom, Track, } from "livekit-client"; -import { randomUUID } from "crypto"; +import { createHash, randomUUID } from "crypto"; import { type TrackReference } from "@livekit/components-core"; import EventEmitter from "events"; import { type KeyTransportEvents, type KeyTransportEventsHandlerMap, } from "matrix-js-sdk/lib/matrixrtc/IKeyTransport"; +import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager"; import { LocalUserMediaViewModel, @@ -522,3 +524,27 @@ export function mockMuteStates( const observableScope = new ObservableScope(); return new MuteStates(observableScope, mockMediaDevices({}), joined$); } + +export const mockComputeLivekitParticipantIdentity$ = ( + membership: CallMembershipIdentityParts, + kind: "rtc" | "session", +): Observable => { + function sha256(commitmentStr: string): string { + return encodeUnpaddedBase64( + createHash("sha256").update(commitmentStr, "utf8").digest(), + ); + } + let hash; + switch (kind) { + case "rtc": { + hash = sha256( + `${membership.userId}|${membership.deviceId}|${membership.memberId}`, + ); + break; + } + case "session": + default: + hash = `${membership.userId}:${membership.deviceId}`; + } + return of(hash); +};