WIP: work on group call state transitions

This commit is contained in:
Bruno Windels 2022-03-11 14:40:37 +01:00
parent b2ac4bc291
commit b213a45c5c
5 changed files with 119 additions and 50 deletions

View file

@ -21,6 +21,7 @@ import {handlesEventType} from "./PeerCall";
import {EventType} from "./callEventTypes"; import {EventType} from "./callEventTypes";
import {GroupCall} from "./group/GroupCall"; import {GroupCall} from "./group/GroupCall";
import type {LocalMedia} from "./LocalMedia";
import type {Room} from "../room/Room"; import type {Room} from "../room/Room";
import type {MemberChange} from "../room/members/RoomMember"; import type {MemberChange} from "../room/members/RoomMember";
import type {StateEvent} from "../storage/types"; import type {StateEvent} from "../storage/types";
@ -49,6 +50,22 @@ export class GroupCallHandler {
}); });
} }
async createCall(roomId: string, localMedia: LocalMedia, name: string): Promise<GroupCall> {
const call = new GroupCall(undefined, undefined, roomId, this.groupCallOptions);
this._calls.set(call.id, call);
try {
await call.create(localMedia, name);
} catch (err) {
if (err.name === "ConnectionError") {
// if we're offline, give up and remove the call again
this._calls.remove(call.id);
}
throw err;
}
await call.join(localMedia);
return call;
}
get calls(): BaseObservableMap<string, GroupCall> { return this._calls; } get calls(): BaseObservableMap<string, GroupCall> { return this._calls; }
// TODO: check and poll turn server credentials here // TODO: check and poll turn server credentials here
@ -58,7 +75,7 @@ export class GroupCallHandler {
// first update call events // first update call events
for (const event of events) { for (const event of events) {
if (event.type === EventType.GroupCall) { if (event.type === EventType.GroupCall) {
this.handleCallEvent(event, room); this.handleCallEvent(event, room.id);
} }
} }
// then update members // then update members
@ -71,7 +88,8 @@ export class GroupCallHandler {
/** @internal */ /** @internal */
updateRoomMembers(room: Room, memberChanges: Map<string, MemberChange>) { updateRoomMembers(room: Room, memberChanges: Map<string, MemberChange>) {
// TODO: also have map for roomId to calls, so we can easily update members
// we will also need this to get the call for a room
} }
/** @internal */ /** @internal */
@ -86,7 +104,7 @@ export class GroupCallHandler {
call?.handleDeviceMessage(message, userId, deviceId, log); call?.handleDeviceMessage(message, userId, deviceId, log);
} }
private handleCallEvent(event: StateEvent, room: Room) { private handleCallEvent(event: StateEvent, roomId: string) {
const callId = event.state_key; const callId = event.state_key;
let call = this._calls.get(callId); let call = this._calls.get(callId);
if (call) { if (call) {
@ -95,7 +113,7 @@ export class GroupCallHandler {
this._calls.remove(call.id); this._calls.remove(call.id);
} }
} else { } else {
call = new GroupCall(event, room, this.groupCallOptions); call = new GroupCall(event.state_key, event.content, roomId, this.groupCallOptions);
this._calls.set(call.id, call); this._calls.set(call.id, call);
} }
} }

View file

@ -127,12 +127,11 @@ export class PeerCall implements IDisposable {
return; return;
} }
this.setState(CallState.CreateOffer); this.setState(CallState.CreateOffer);
// add the local tracks, and wait for onNegotiationNeeded and handleNegotiation to be called
for (const t of this.localMedia.tracks) { for (const t of this.localMedia.tracks) {
this.peerConnection.addTrack(t); this.peerConnection.addTrack(t);
} }
// TODO: in case of glare, we would not go to InviteSent if we haven't started sending yet // after adding the local tracks, and wait for handleNegotiation to be called,
// but we would go straight to CreateAnswer, so also need to wait for that state // or invite glare where we give up our invite and answer instead
await this.waitForState([CallState.InviteSent, CallState.CreateAnswer]); await this.waitForState([CallState.InviteSent, CallState.CreateAnswer]);
} }

View file

@ -97,12 +97,12 @@ party identification
Build basic version of PeerCall Build basic version of PeerCall
- add candidates code - add candidates code
Build basic version of GroupCall DONE: Build basic version of GroupCall
- add state, block invalid actions - DONE: add state, block invalid actions
DONE: Make it possible to olm encrypt the messages DONE: Make it possible to olm encrypt the messages
Do work needed for state events Do work needed for state events
- receiving (almost done?) - DONEish: receiving (almost done?)
- sending - DONEish: sending
Expose call objects Expose call objects
expose volume events from audiotrack to group call expose volume events from audiotrack to group call
Write view model Write view model

View file

@ -18,6 +18,8 @@ import {ObservableMap} from "../../../observable/map/ObservableMap";
import {Member} from "./Member"; import {Member} from "./Member";
import {LocalMedia} from "../LocalMedia"; import {LocalMedia} from "../LocalMedia";
import {RoomMember} from "../../room/members/RoomMember"; import {RoomMember} from "../../room/members/RoomMember";
import {makeId} from "../../common";
import type {Options as MemberOptions} from "./Member"; import type {Options as MemberOptions} from "./Member";
import type {BaseObservableMap} from "../../../observable/map/BaseObservableMap"; import type {BaseObservableMap} from "../../../observable/map/BaseObservableMap";
import type {Track} from "../../../platform/types/MediaDevices"; import type {Track} from "../../../platform/types/MediaDevices";
@ -30,12 +32,11 @@ import type {ILogItem} from "../../../logging/types";
import type {Storage} from "../../storage/idb/Storage"; import type {Storage} from "../../storage/idb/Storage";
export enum GroupCallState { export enum GroupCallState {
LocalCallFeedUninitialized = "local_call_feed_uninitialized", Fledgling = "fledgling",
InitializingLocalCallFeed = "initializing_local_call_feed", Creating = "creating",
LocalCallFeedInitialized = "local_call_feed_initialized", Created = "created",
Joining = "entering", Joining = "joining",
Joined = "entered", Joined = "joined",
Ended = "ended",
} }
export type Options = Omit<MemberOptions, "emitUpdate" | "confId" | "encryptDeviceMessage"> & { export type Options = Omit<MemberOptions, "emitUpdate" | "confId" | "encryptDeviceMessage"> & {
@ -46,70 +47,112 @@ export type Options = Omit<MemberOptions, "emitUpdate" | "confId" | "encryptDevi
}; };
export class GroupCall { export class GroupCall {
public readonly id: string;
private readonly _members: ObservableMap<string, Member> = new ObservableMap(); private readonly _members: ObservableMap<string, Member> = new ObservableMap();
private localMedia?: Promise<LocalMedia>; private _localMedia?: LocalMedia;
private _memberOptions: MemberOptions; private _memberOptions: MemberOptions;
private _state: GroupCallState = GroupCallState.LocalCallFeedInitialized; private _state: GroupCallState;
// TODO: keep connected state and deal
constructor( constructor(
private callEvent: StateEvent, id: string | undefined,
private readonly room: Room, private callContent: Record<string, any> | undefined,
private readonly roomId: string,
private readonly options: Options private readonly options: Options
) { ) {
this.id = id ?? makeId("conf-");
this._state = id ? GroupCallState.Created : GroupCallState.Fledgling;
this._memberOptions = Object.assign({ this._memberOptions = Object.assign({
confId: this.id, confId: this.id,
emitUpdate: member => this._members.update(member.member.userId, member), emitUpdate: member => this._members.update(member.member.userId, member),
encryptDeviceMessage: (message: SignallingMessage<MGroupCallBase>, log) => { encryptDeviceMessage: (message: SignallingMessage<MGroupCallBase>, log) => {
return this.options.encryptDeviceMessage(this.room.id, message, log); return this.options.encryptDeviceMessage(this.roomId, message, log);
} }
}, options); }, options);
} }
static async create(roomId: string, options: Options): Promise<GroupCall> { get localMedia(): LocalMedia | undefined { return this._localMedia; }
}
get members(): BaseObservableMap<string, Member> { return this._members; } get members(): BaseObservableMap<string, Member> { return this._members; }
get id(): string { return this.callEvent.state_key; }
get isTerminated(): boolean { get isTerminated(): boolean {
return this.callEvent.content["m.terminated"] === true; return this.callContent?.["m.terminated"] === true;
} }
async join(localMedia: Promise<LocalMedia>) { async join(localMedia: LocalMedia) {
this.localMedia = localMedia; if (this._state !== GroupCallState.Created) {
const memberContent = await this._createOrUpdateOwnMemberStateContent(); return;
}
this._state = GroupCallState.Joining;
this._localMedia = localMedia;
const memberContent = await this._joinCallMemberContent();
// send m.call.member state event // send m.call.member state event
const request = this.options.hsApi.sendState(this.room.id, "m.call.member", this.options.ownUserId, memberContent); const request = this.options.hsApi.sendState(this.roomId, "m.call.member", this.options.ownUserId, memberContent);
await request.response(); await request.response();
// send invite to all members that are < my userId // send invite to all members that are < my userId
for (const [,member] of this._members) { for (const [,member] of this._members) {
member.connect(this.localMedia); member.connect(this._localMedia);
}
}
async leave() {
const memberContent = await this._leaveCallMemberContent();
// send m.call.member state event
if (memberContent) {
const request = this.options.hsApi.sendState(this.roomId, "m.call.member", this.options.ownUserId, memberContent);
await request.response();
} }
} }
/** @internal */ /** @internal */
updateCallEvent(callEvent: StateEvent) { async create(localMedia: LocalMedia, name: string) {
this.callEvent = callEvent; if (this._state !== GroupCallState.Fledgling) {
// TODO: emit update return;
}
this._state = GroupCallState.Creating;
this.callContent = {
"m.type": localMedia.cameraTrack ? "m.video" : "m.voice",
"m.name": name,
"m.intent": "m.ring"
};
const request = this.options.hsApi.sendState(this.roomId, "m.call", this.id, this.callContent);
await request.response();
}
/** @internal */
updateCallEvent(callContent: Record<string, any>) {
this.callContent = callContent;
if (this._state === GroupCallState.Creating) {
this._state = GroupCallState.Created;
}
} }
/** @internal */ /** @internal */
addMember(userId, memberCallInfo) { addMember(userId, memberCallInfo) {
if (userId === this.options.ownUserId) {
if (this._state === GroupCallState.Joining) {
this._state = GroupCallState.Joined;
}
return;
}
let member = this._members.get(userId); let member = this._members.get(userId);
if (member) { if (member) {
member.updateCallInfo(memberCallInfo); member.updateCallInfo(memberCallInfo);
} else { } else {
member = new Member(RoomMember.fromUserId(this.room.id, userId, "join"), this._memberOptions); member = new Member(RoomMember.fromUserId(this.roomId, userId, "join"), memberCallInfo, this._memberOptions);
member.updateCallInfo(memberCallInfo);
this._members.add(userId, member); this._members.add(userId, member);
if (this._state === GroupCallState.Joining || this._state === GroupCallState.Joined) {
member.connect(this._localMedia!);
}
} }
} }
/** @internal */ /** @internal */
removeMember(userId) { removeMember(userId) {
if (userId === this.options.ownUserId) {
if (this._state === GroupCallState.Joined) {
this._state = GroupCallState.Created;
}
return;
}
this._members.remove(userId); this._members.remove(userId);
} }
@ -124,10 +167,10 @@ export class GroupCall {
} }
} }
private async _createOrUpdateOwnMemberStateContent() { private async _joinCallMemberContent() {
const {storage} = this.options; const {storage} = this.options;
const txn = await storage.readTxn([storage.storeNames.roomState]); const txn = await storage.readTxn([storage.storeNames.roomState]);
const stateEvent = await txn.roomState.get(this.room.id, "m.call.member", this.options.ownUserId); const stateEvent = await txn.roomState.get(this.roomId, "m.call.member", this.options.ownUserId);
const stateContent = stateEvent?.event?.content ?? { const stateContent = stateEvent?.event?.content ?? {
["m.calls"]: [] ["m.calls"]: []
}; };
@ -150,4 +193,13 @@ export class GroupCall {
} }
return stateContent; return stateContent;
} }
private async _leaveCallMemberContent(): Promise<Record<string, any> | undefined> {
const {storage} = this.options;
const txn = await storage.readTxn([storage.storeNames.roomState]);
const stateEvent = await txn.roomState.get(this.roomId, "m.call.member", this.options.ownUserId);
const callsInfo = stateEvent?.event?.content?.["m.calls"];
callsInfo?.filter(c => c["m.call_id"] === this.id);
return stateEvent?.event.content;
}
} }

View file

@ -38,10 +38,11 @@ export type Options = Omit<PeerCallOptions, "emitUpdate" | "sendSignallingMessag
export class Member { export class Member {
private peerCall?: PeerCall; private peerCall?: PeerCall;
private localMedia?: Promise<LocalMedia>; private localMedia?: LocalMedia;
constructor( constructor(
public readonly member: RoomMember, public readonly member: RoomMember,
private memberCallInfo: Record<string, any>,
private readonly options: Options private readonly options: Options
) {} ) {}
@ -53,13 +54,13 @@ export class Member {
return this.peerCall?.state === CallState.Connected; return this.peerCall?.state === CallState.Connected;
} }
/* @internal */ /** @internal */
connect(localMedia: Promise<LocalMedia>) { connect(localMedia: LocalMedia) {
this.localMedia = localMedia; this.localMedia = localMedia;
// otherwise wait for it to connect // otherwise wait for it to connect
if (this.member.userId < this.options.ownUserId) { if (this.member.userId < this.options.ownUserId) {
this.peerCall = this._createPeerCall(makeId("c")); this.peerCall = this._createPeerCall(makeId("c"));
this.peerCall.call(localMedia); this.peerCall.call(Promise.resolve(localMedia.clone()));
} }
} }
@ -71,13 +72,12 @@ export class Member {
/** @internal */ /** @internal */
emitUpdate = (peerCall: PeerCall, params: any) => { emitUpdate = (peerCall: PeerCall, params: any) => {
if (peerCall.state === CallState.Ringing) { if (peerCall.state === CallState.Ringing) {
peerCall.answer(this.localMedia!); peerCall.answer(Promise.resolve(this.localMedia!));
} }
this.options.emitUpdate(this, params); this.options.emitUpdate(this, params);
} }
/** From PeerCallHandler /** @internal */
* @internal */
sendSignallingMessage = async (message: SignallingMessage<MCallBase>, log: ILogItem) => { sendSignallingMessage = async (message: SignallingMessage<MCallBase>, log: ILogItem) => {
const groupMessage = message as SignallingMessage<MGroupCallBase>; const groupMessage = message as SignallingMessage<MGroupCallBase>;
groupMessage.content.conf_id = this.options.confId; groupMessage.content.conf_id = this.options.confId;