Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add E2EE for embedded mode of Element Call #3667

Merged
merged 123 commits into from
Oct 31, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
123 commits
Select commit Hold shift + click to select a range
11ce532
WIP refactor for removing m.call events
dbkr Aug 16, 2023
38f9b56
Always remember rtcsessions since we need to only have one instance
dbkr Aug 18, 2023
f5f63d6
Merge remote-tracking branch 'origin/develop' into dbkr/matrixrtcsession
dbkr Aug 18, 2023
426f498
Fix tests
dbkr Aug 18, 2023
542ce1f
Fix import loop
dbkr Aug 18, 2023
79ad566
Fix more cyclic imports & tests
dbkr Aug 18, 2023
3d715fc
Test session joining
dbkr Aug 18, 2023
ac6fece
Attempt to make tests happy
dbkr Aug 18, 2023
210c3bb
Always leave calls in the tests to clean up
dbkr Aug 18, 2023
bd10507
comment + desperate attempt to work out what's failing
dbkr Aug 18, 2023
b1dfdee
More test debugging
dbkr Aug 18, 2023
03868f8
Okay, so these ones are fine?
dbkr Aug 18, 2023
c9de1cb
Stop more timers and hopefully have happy tests
dbkr Aug 18, 2023
3de7b32
Test no rejoin
dbkr Aug 18, 2023
73dedc4
Test malformed m.call.member events
dbkr Aug 18, 2023
6aeeb7c
Test event emitting
dbkr Aug 21, 2023
989c4f0
Test getActiveFoci()
dbkr Aug 21, 2023
88d85b4
Test event emitting (and also fix it)
dbkr Aug 21, 2023
a07f93f
Test membership updating & pruning on join
dbkr Aug 22, 2023
86a25b5
Test getOldestMembership()
dbkr Aug 22, 2023
f0a37cb
Test member event renewal
dbkr Aug 22, 2023
0d46aeb
Merge remote-tracking branch 'origin/develop' into dbkr/matrixrtcsession
dbkr Aug 22, 2023
c8ae665
Don't start the rtc manager until the client has synced
dbkr Aug 23, 2023
770d16e
Fix type
dbkr Aug 23, 2023
5be2d7c
Remove listeners added in constructor
dbkr Aug 23, 2023
71ab476
Stop the client here too
dbkr Aug 23, 2023
2e1aaa8
Stop the client here also also
dbkr Aug 23, 2023
bcff039
ARGH. Disable tests to work out which one is causing the exception
dbkr Aug 23, 2023
2886455
Disable everything
dbkr Aug 23, 2023
b4c40ef
Re-jig to avoid setting listeners in the constructor
dbkr Aug 23, 2023
5b9051e
No need to rename this anymore
dbkr Aug 23, 2023
c271625
argh, remove the right listener
dbkr Aug 23, 2023
5e2a555
Is it this test???
dbkr Aug 23, 2023
591df95
Re-enable some tests
dbkr Aug 23, 2023
5145b44
Try mocking getRooms to return something valid
dbkr Aug 23, 2023
1c96fc8
Re-enable other tests
dbkr Aug 23, 2023
6811ba4
Give up trying to get the tests to work sensibly and deal with getRoo…
dbkr Aug 23, 2023
b18ae38
Oops, don't enable the ones that were skipped before
dbkr Aug 23, 2023
40fb4ab
One more try at the sensible way
dbkr Aug 23, 2023
50da896
Didn't work, go back to the hack way.
dbkr Aug 23, 2023
f612b76
Log when we manage to send the member event update
dbkr Aug 24, 2023
9cb1c20
Support `getOpenIdToken()` in embedded mode (#3676)
SimonBrandner Aug 25, 2023
2047c98
Call `sendContentLoaded()` (#3677)
SimonBrandner Aug 25, 2023
1a0718f
Start MatrixRTC in embedded mode (#3679)
SimonBrandner Aug 28, 2023
c444e37
Reschedule the membership event check
dbkr Aug 29, 2023
edc977b
Bump widget api version
dbkr Aug 29, 2023
e690b71
Add mock for sendContentLoaded()
dbkr Aug 29, 2023
a271369
Embeded mode pre-requisites
SimonBrandner Aug 21, 2023
c6c6559
Embeded mode E2EE
SimonBrandner Aug 22, 2023
3799c65
Encryption condition
SimonBrandner Aug 25, 2023
d2034ad
Revert "Embeded mode pre-requisites"
SimonBrandner Aug 30, 2023
72808b5
Get back event type
SimonBrandner Aug 30, 2023
ebcdd16
Change embedded E2EE implementation
SimonBrandner Aug 29, 2023
f2ce658
Merge branch 'develop' into dbkr/matrixrtcsession
dbkr Aug 30, 2023
4ea0754
More log detail
dbkr Aug 31, 2023
cb3f9ea
Fix tests
dbkr Aug 31, 2023
eb25a28
Simplify updateCallMembershipEvent a bit
dbkr Aug 31, 2023
861b1e9
Split up updateCallMembershipEvent some more
dbkr Aug 31, 2023
eb2b0ca
Use `crypto.getRandomValues()`
SimonBrandner Aug 31, 2023
86bd66d
Rename to `membershipToUserAndDeviceId()`
SimonBrandner Aug 31, 2023
32ee6f7
Better error
SimonBrandner Aug 31, 2023
38802a8
Add log line
SimonBrandner Aug 31, 2023
0a877e8
Add comment
SimonBrandner Aug 31, 2023
6877c0e
Send call ID in enc events
SimonBrandner Aug 31, 2023
c4cf319
Revert making `joinRoomSession()` async
SimonBrandner Aug 31, 2023
bffaff4
Make `client` `private` again
SimonBrandner Aug 31, 2023
ab05751
Merge remote-tracking branch 'upstream/dbkr/matrixrtcsession' into Si…
SimonBrandner Aug 31, 2023
2ac30f1
Just use `toString()`
SimonBrandner Sep 4, 2023
6ee456e
Fix `callId` check
SimonBrandner Sep 4, 2023
3498399
Fix map
SimonBrandner Sep 4, 2023
9586c82
Fix map compare
SimonBrandner Sep 4, 2023
36a2662
Fix emitting
SimonBrandner Sep 4, 2023
46fb357
Explicit logging
SimonBrandner Sep 6, 2023
451d26e
Refactor
SimonBrandner Sep 7, 2023
adeb567
Make `updateEncryptionKeyEvent()` public
SimonBrandner Sep 7, 2023
e44674b
Only update keys based on others
SimonBrandner Sep 7, 2023
f93f2f8
Fix call order
SimonBrandner Sep 7, 2023
94ab1dd
Improve logging
SimonBrandner Sep 7, 2023
f65ed72
Avoid races
SimonBrandner Sep 7, 2023
d85af17
Revert "Avoid races"
SimonBrandner Sep 7, 2023
838cf3a
Add try-catch
SimonBrandner Sep 7, 2023
38059fe
Make `updateEncryptionKeyEvent()` private
SimonBrandner Sep 7, 2023
72093c8
Handle indices and throttling
SimonBrandner Sep 10, 2023
d74bd52
Merge remote-tracking branch 'upstream/develop' into SimonBrandner/fe…
SimonBrandner Sep 12, 2023
e288a4e
Fix merge mistakes
SimonBrandner Sep 12, 2023
0b72baa
Mort post-merge fixes
SimonBrandner Sep 12, 2023
798953b
Merge remote-tracking branch 'origin/develop' into SimonBrandner/feat…
dbkr Oct 13, 2023
df62adc
Split out key generation from key sending
dbkr Oct 18, 2023
b389ef0
Merge remote-tracking branch 'origin/develop' into SimonBrandner/feat…
dbkr Oct 19, 2023
2b61352
Remember and clear the timeout for the send key event
dbkr Oct 19, 2023
9a42886
Make key event resends more robust
dbkr Oct 19, 2023
f8d2e5f
Attempt to make tests pass
dbkr Oct 19, 2023
19db44f
crypto wasn't defined at all
dbkr Oct 19, 2023
a8ea202
Hopefully get interface right
dbkr Oct 19, 2023
97808bc
Merge remote-tracking branch 'origin/develop' into SimonBrandner/feat…
dbkr Oct 20, 2023
4120641
Fix key format on the wire to base64
dbkr Oct 20, 2023
5dd37f1
Add comment
dbkr Oct 23, 2023
0ff9b37
More standard method order
dbkr Oct 23, 2023
0f03cab
Rename encryptMedia
dbkr Oct 23, 2023
0410368
Stop logging encryption keys now
dbkr Oct 23, 2023
93929f1
Use regular base64
dbkr Oct 23, 2023
fe59d2a
Re-add base64url
dbkr Oct 23, 2023
ab86c7c
Add tests for randomstring
dbkr Oct 23, 2023
89dafa1
Switch between either browser or node crypto
dbkr Oct 23, 2023
dbad920
Obviously crypto has already solved this
dbkr Oct 23, 2023
6e80cf8
Some tests for MatrixRTCSession key stuff
dbkr Oct 23, 2023
90b5c46
Test keys object contents
dbkr Oct 23, 2023
c48f1eb
Change keys event format
dbkr Oct 23, 2023
4805138
Test key event retries
dbkr Oct 23, 2023
be35396
Test onCallEncryption
dbkr Oct 23, 2023
faa2e4f
Merge remote-tracking branch 'origin/develop' into SimonBrandner/feat…
dbkr Oct 23, 2023
a361243
Test event sending & spam prevention
dbkr Oct 24, 2023
bd279ee
Test event cancelation
dbkr Oct 24, 2023
3dd4ffb
Test onCallEncryption called
dbkr Oct 24, 2023
e7f2da3
Merge remote-tracking branch 'origin/develop' into SimonBrandner/feat…
dbkr Oct 24, 2023
8b544b6
Some errors didn't have data
dbkr Oct 25, 2023
8f4edc7
Fix binary key comparison
dbkr Oct 25, 2023
e38530e
Fix compare function with undefined values
dbkr Oct 25, 2023
858f67f
Remove more key logging
dbkr Oct 26, 2023
583e114
Check content.keys is an array
dbkr Oct 30, 2023
2fe0828
Check key index & key
dbkr Oct 30, 2023
bf40656
Better function name
dbkr Oct 30, 2023
207ff68
Tests too
dbkr Oct 30, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions spec/unit/base64.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ limitations under the License.
import { TextEncoder, TextDecoder } from "util";
import NodeBuffer from "node:buffer";

import { decodeBase64, encodeBase64, encodeUnpaddedBase64 } from "../../src/base64";
import { decodeBase64, encodeBase64, encodeUnpaddedBase64, encodeUnpaddedBase64Url } from "../../src/base64";

describe.each(["browser", "node"])("Base64 encoding (%s)", (env) => {
let origBuffer = Buffer;
Expand All @@ -43,19 +43,27 @@ describe.each(["browser", "node"])("Base64 encoding (%s)", (env) => {
global.btoa = undefined;
});

it("Should decode properly encoded data", async () => {
it("Should decode properly encoded data", () => {
const decoded = new TextDecoder().decode(decodeBase64("ZW5jb2RpbmcgaGVsbG8gd29ybGQ="));

expect(decoded).toStrictEqual("encoding hello world");
});

it("Should decode URL-safe base64", async () => {
it("Should encode unpadded URL-safe base64", () => {
const toEncode = "?????";
const data = new TextEncoder().encode(toEncode);

const encoded = encodeUnpaddedBase64Url(data);
expect(encoded).toEqual("Pz8_Pz8");
});

it("Should decode URL-safe base64", () => {
const decoded = new TextDecoder().decode(decodeBase64("Pz8_Pz8="));

expect(decoded).toStrictEqual("?????");
});

it("Encode unpadded should not have padding", async () => {
it("Encode unpadded should not have padding", () => {
const toEncode = "encoding hello world";
const data = new TextEncoder().encode(toEncode);

Expand All @@ -68,7 +76,7 @@ describe.each(["browser", "node"])("Base64 encoding (%s)", (env) => {
expect(padding).toStrictEqual("=");
});

it("Decode should be indifferent to padding", async () => {
it("Decode should be indifferent to padding", () => {
const withPadding = "ZW5jb2RpbmcgaGVsbG8gd29ybGQ=";
const withoutPadding = "ZW5jb2RpbmcgaGVsbG8gd29ybGQ";

Expand Down
248 changes: 241 additions & 7 deletions spec/unit/matrixrtc/MatrixRTCSession.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/

import { EventTimeline, EventType, MatrixClient, Room } from "../../../src";
import { EventTimeline, EventType, MatrixClient, MatrixError, MatrixEvent, Room } from "../../../src";
import { CallMembershipData } from "../../../src/matrixrtc/CallMembership";
import { MatrixRTCSession, MatrixRTCSessionEvent } from "../../../src/matrixrtc/MatrixRTCSession";
import { randomString } from "../../../src/randomstring";
import { makeMockRoom, mockRTCEvent } from "./mocks";
import { makeMockRoom, makeMockRoomState, mockRTCEvent } from "./mocks";

const membershipTemplate: CallMembershipData = {
call_id: "",
Expand Down Expand Up @@ -184,8 +184,15 @@ describe("MatrixRTCSession", () => {

describe("joining", () => {
let mockRoom: Room;
let sendStateEventMock: jest.Mock;
let sendEventMock: jest.Mock;

beforeEach(() => {
sendStateEventMock = jest.fn();
sendEventMock = jest.fn();
client.sendStateEvent = sendStateEventMock;
client.sendEvent = sendEventMock;

mockRoom = makeMockRoom([]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
});
Expand All @@ -205,8 +212,6 @@ describe("MatrixRTCSession", () => {
});

it("sends a membership event when joining a call", () => {
client.sendStateEvent = jest.fn();

sess!.joinRoomSession([mockFocus]);

expect(client.sendStateEvent).toHaveBeenCalledWith(
Expand All @@ -230,9 +235,6 @@ describe("MatrixRTCSession", () => {
});

it("does nothing if join called when already joined", () => {
const sendStateEventMock = jest.fn();
client.sendStateEvent = sendStateEventMock;

sess!.joinRoomSession([mockFocus]);

expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
Expand Down Expand Up @@ -299,6 +301,188 @@ describe("MatrixRTCSession", () => {
jest.useRealTimers();
}
});

it("creates a key when joining", () => {
sess!.joinRoomSession([mockFocus], true);
const keys = sess?.getKeysForParticipant("@alice:example.org", "AAAAAAA");
expect(keys).toHaveLength(1);

const allKeys = sess!.getEncryptionKeys();
expect(allKeys).toBeTruthy();
expect(Array.from(allKeys)).toHaveLength(1);
});

it("sends keys when joining", async () => {
const eventSentPromise = new Promise((resolve) => {
sendEventMock.mockImplementation(resolve);
});

sess!.joinRoomSession([mockFocus], true);

await eventSentPromise;

expect(sendEventMock).toHaveBeenCalledWith(expect.stringMatching(".*"), "io.element.call.encryption_keys", {
call_id: "",
device_id: "AAAAAAA",
keys: [
{
index: 0,
key: expect.stringMatching(".*"),
},
],
});
});

it("retries key sends", async () => {
jest.useFakeTimers();
let firstEventSent = false;

try {
const eventSentPromise = new Promise<void>((resolve) => {
sendEventMock.mockImplementation(() => {
if (!firstEventSent) {
jest.advanceTimersByTime(10000);

firstEventSent = true;
const e = new Error() as MatrixError;
e.data = {};
throw e;
} else {
resolve();
}
});
});

sess!.joinRoomSession([mockFocus], true);
jest.advanceTimersByTime(10000);

await eventSentPromise;

expect(sendEventMock).toHaveBeenCalledTimes(2);
} finally {
jest.useRealTimers();
}
});

it("cancels key send event that fail", async () => {
const eventSentinel = {} as unknown as MatrixEvent;

client.cancelPendingEvent = jest.fn();
sendEventMock.mockImplementation(() => {
const e = new Error() as MatrixError;
e.data = {};
e.event = eventSentinel;
throw e;
});

sess!.joinRoomSession([mockFocus], true);

expect(client.cancelPendingEvent).toHaveBeenCalledWith(eventSentinel);
});

it("Re-sends key if a new member joins", async () => {
jest.useFakeTimers();
try {
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);

const keysSentPromise1 = new Promise((resolve) => {
sendEventMock.mockImplementation(resolve);
});

sess.joinRoomSession([mockFocus], true);
await keysSentPromise1;

sendEventMock.mockClear();
jest.advanceTimersByTime(10000);

const keysSentPromise2 = new Promise((resolve) => {
sendEventMock.mockImplementation(resolve);
});

const onMembershipsChanged = jest.fn();
sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged);

const member2 = Object.assign({}, membershipTemplate, {
device_id: "BBBBBBB",
});

mockRoom.getLiveTimeline().getState = jest
.fn()
.mockReturnValue(makeMockRoomState([membershipTemplate, member2], mockRoom.roomId, undefined));
sess.onMembershipUpdate();

await keysSentPromise2;

expect(sendEventMock).toHaveBeenCalled();
} finally {
jest.useRealTimers();
}
});

it("Doesn't re-send key immediately", async () => {
const realSetImmediate = setImmediate;
jest.useFakeTimers();
try {
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);

const keysSentPromise1 = new Promise((resolve) => {
sendEventMock.mockImplementation(resolve);
});

sess.joinRoomSession([mockFocus], true);
await keysSentPromise1;

sendEventMock.mockClear();

const onMembershipsChanged = jest.fn();
sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged);

const member2 = Object.assign({}, membershipTemplate, {
device_id: "BBBBBBB",
});

mockRoom.getLiveTimeline().getState = jest
.fn()
.mockReturnValue(makeMockRoomState([membershipTemplate, member2], mockRoom.roomId, undefined));
sess.onMembershipUpdate();

await new Promise((resolve) => {
realSetImmediate(resolve);
});

expect(sendEventMock).not.toHaveBeenCalled();
} finally {
jest.useRealTimers();
}
});
});

it("Does not emits if no membership changes", () => {
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);

const onMembershipsChanged = jest.fn();
sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged);
sess.onMembershipUpdate();

expect(onMembershipsChanged).not.toHaveBeenCalled();
});

it("Emits on membership changes", () => {
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);

const onMembershipsChanged = jest.fn();
sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged);

mockRoom.getLiveTimeline().getState = jest
.fn()
.mockReturnValue(makeMockRoomState([], mockRoom.roomId, undefined));
sess.onMembershipUpdate();

expect(onMembershipsChanged).toHaveBeenCalled();
});

it("emits an event at the time a membership event expires", () => {
Expand Down Expand Up @@ -409,4 +593,54 @@ describe("MatrixRTCSession", () => {
"@alice:example.org",
);
});

it("collects keys from encryption events", () => {
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess.onCallEncryption({
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
getContent: jest.fn().mockReturnValue({
device_id: "bobsphone",
call_id: "",
keys: [
{
index: 0,
key: "dGhpcyBpcyB0aGUga2V5",
},
],
}),
getSender: jest.fn().mockReturnValue("@bob:example.org"),
} as unknown as MatrixEvent);

const bobKeys = sess.getKeysForParticipant("@bob:example.org", "bobsphone")!;
expect(bobKeys).toHaveLength(1);
expect(bobKeys[0]).toEqual(Buffer.from("this is the key", "utf-8"));
});

it("collects keys at non-zero indices", () => {
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess.onCallEncryption({
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
getContent: jest.fn().mockReturnValue({
device_id: "bobsphone",
call_id: "",
keys: [
{
index: 4,
key: "dGhpcyBpcyB0aGUga2V5",
},
],
}),
getSender: jest.fn().mockReturnValue("@bob:example.org"),
} as unknown as MatrixEvent);

const bobKeys = sess.getKeysForParticipant("@bob:example.org", "bobsphone")!;
expect(bobKeys).toHaveLength(5);
expect(bobKeys[0]).toBeFalsy();
expect(bobKeys[1]).toBeFalsy();
expect(bobKeys[2]).toBeFalsy();
expect(bobKeys[3]).toBeFalsy();
expect(bobKeys[4]).toEqual(Buffer.from("this is the key", "utf-8"));
});
});
32 changes: 31 additions & 1 deletion spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,15 @@ See the License for the specific language governing permissions and
limitations under the License.
*/

import { ClientEvent, EventTimeline, MatrixClient } from "../../../src";
import {
ClientEvent,
EventTimeline,
EventType,
IRoomTimelineData,
MatrixClient,
MatrixEvent,
RoomEvent,
} from "../../../src";
import { RoomStateEvent } from "../../../src/models/room-state";
import { CallMembershipData } from "../../../src/matrixrtc/CallMembership";
import { MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc/MatrixRTCSessionManager";
Expand Down Expand Up @@ -78,4 +86,26 @@ describe("MatrixRTCSessionManager", () => {

expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
});

it("Calls onCallEncryption on encryption keys event", () => {
const room1 = makeMockRoom([membershipTemplate]);
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
jest.spyOn(client, "getRoom").mockReturnValue(room1);

client.emit(ClientEvent.Room, room1);
const onCallEncryptionMock = jest.fn();
client.matrixRTC.getRoomSession(room1).onCallEncryption = onCallEncryptionMock;

const timelineEvent = {
getType: jest.fn().mockReturnValue(EventType.CallEncryptionKeysPrefix),
getContent: jest.fn().mockReturnValue({}),
getSender: jest.fn().mockReturnValue("@mock:user.example"),
getRoomId: jest.fn().mockReturnValue("!room:id"),
sender: {
userId: "@mock:user.example",
},
} as unknown as MatrixEvent;
client.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData);
expect(onCallEncryptionMock).toHaveBeenCalled();
});
});
6 changes: 5 additions & 1 deletion spec/unit/matrixrtc/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,11 @@ export function makeMockRoom(
} as unknown as Room;
}

function makeMockRoomState(memberships: CallMembershipData[], roomId: string, getLocalAge: (() => number) | undefined) {
export function makeMockRoomState(
memberships: CallMembershipData[],
roomId: string,
getLocalAge: (() => number) | undefined,
) {
return {
getStateEvents: (_: string, stateKey: string) => {
const event = mockRTCEvent(memberships, roomId, getLocalAge);
Expand Down
Loading