From ae849fdd46cd556e9916fadaff625b961e8c1403 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Thu, 8 Dec 2022 19:51:05 +0100 Subject: [PATCH] Minor VoIP stack improvements (#2946) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add `IGroupCallRoomState` Signed-off-by: Šimon Brandner * Export values into `const`s Signed-off-by: Šimon Brandner * Add `should correctly emit LengthChanged` Signed-off-by: Šimon Brandner * Add `ICE disconnected timeout` Signed-off-by: Šimon Brandner * Improve typing Signed-off-by: Šimon Brandner * Don't cast `getContent()` Signed-off-by: Šimon Brandner * Use `Date.now()` for call length Signed-off-by: Šimon Brandner * Type fix Signed-off-by: Šimon Brandner Signed-off-by: Šimon Brandner --- spec/test-utils/webrtc.ts | 4 +++ spec/unit/webrtc/call.spec.ts | 46 +++++++++++++++++++++++++++++++++++ src/webrtc/call.ts | 19 +++++++++------ src/webrtc/groupCall.ts | 28 ++++++++++++++------- 4 files changed, 81 insertions(+), 16 deletions(-) diff --git a/spec/test-utils/webrtc.ts b/spec/test-utils/webrtc.ts index c2f1a0b25..84d1f4868 100644 --- a/spec/test-utils/webrtc.ts +++ b/spec/test-utils/webrtc.ts @@ -115,12 +115,14 @@ export class MockRTCPeerConnection { private negotiationNeededListener?: () => void; public iceCandidateListener?: (e: RTCPeerConnectionIceEvent) => void; + public iceConnectionStateChangeListener?: () => void; public onTrackListener?: (e: RTCTrackEvent) => void; public needsNegotiation = false; public readyToNegotiate: Promise; private onReadyToNegotiate?: () => void; public localDescription: RTCSessionDescription; public signalingState: RTCSignalingState = "stable"; + public iceConnectionState: RTCIceConnectionState = "connected"; public transceivers: MockRTCRtpTransceiver[] = []; public static triggerAllNegotiations(): void { @@ -156,6 +158,8 @@ export class MockRTCPeerConnection { this.negotiationNeededListener = listener; } else if (type == 'icecandidate') { this.iceCandidateListener = listener; + } else if (type === 'iceconnectionstatechange') { + this.iceConnectionStateChangeListener = listener; } else if (type == 'track') { this.onTrackListener = listener; } diff --git a/spec/unit/webrtc/call.spec.ts b/spec/unit/webrtc/call.spec.ts index 1d4f9f034..eb5d4c18e 100644 --- a/spec/unit/webrtc/call.spec.ts +++ b/spec/unit/webrtc/call.spec.ts @@ -1458,4 +1458,50 @@ describe('Call', function() { expect(call.hasPeerConnection).toBe(true); }); }); + + it("should correctly emit LengthChanged", async () => { + const advanceByArray = [2, 3, 5]; + const lengthChangedListener = jest.fn(); + + jest.useFakeTimers(); + call.addListener(CallEvent.LengthChanged, lengthChangedListener); + await fakeIncomingCall(client, call, "1"); + (call.peerConn as unknown as MockRTCPeerConnection).iceConnectionStateChangeListener!(); + + let hasAdvancedBy = 0; + for (const advanceBy of advanceByArray) { + jest.advanceTimersByTime(advanceBy * 1000); + hasAdvancedBy += advanceBy; + + expect(lengthChangedListener).toHaveBeenCalledTimes(hasAdvancedBy); + expect(lengthChangedListener).toBeCalledWith(hasAdvancedBy); + } + }); + + describe("ICE disconnected timeout", () => { + let mockPeerConn: MockRTCPeerConnection; + + beforeEach(async () => { + jest.useFakeTimers(); + jest.spyOn(call, "hangup"); + + await fakeIncomingCall(client, call, "1"); + + mockPeerConn = (call.peerConn as unknown as MockRTCPeerConnection); + mockPeerConn.iceConnectionState = "disconnected"; + mockPeerConn.iceConnectionStateChangeListener!(); + }); + + it("should hang up after being disconnected for 30 seconds", () => { + jest.advanceTimersByTime(31 * 1000); + expect(call.hangup).toHaveBeenCalledWith(CallErrorCode.IceFailed, false); + }); + + it("should not hangup if we've managed to re-connect", () => { + mockPeerConn.iceConnectionState = "connected"; + mockPeerConn.iceConnectionStateChangeListener!(); + jest.advanceTimersByTime(31 * 1000); + expect(call.hangup).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/webrtc/call.ts b/src/webrtc/call.ts index c9bcac112..0d1af81a3 100644 --- a/src/webrtc/call.ts +++ b/src/webrtc/call.ts @@ -253,7 +253,11 @@ const VOIP_PROTO_VERSION = "1"; const FALLBACK_ICE_SERVER = 'stun:turn.matrix.org'; /** The length of time a call can be ringing for. */ -const CALL_TIMEOUT_MS = 60000; +const CALL_TIMEOUT_MS = 60 * 1000; // ms +/** The time after which we increment callLength */ +const CALL_LENGTH_INTERVAL = 1000; // ms +/** The time after which we end the call, if ICE got disconnected */ +const ICE_DISCONNECTED_TIMEOUT = 30 * 1000; // ms export class CallError extends Error { public readonly code: string; @@ -376,7 +380,7 @@ export class MatrixCall extends TypedEventEmitter; - private callLength = 0; + private callStartTime?: number; private opponentDeviceId?: string; private opponentDeviceInfo?: DeviceInfo; @@ -2083,11 +2087,12 @@ export class MatrixCall extends TypedEventEmitter { - this.callLength++; - this.emit(CallEvent.LengthChanged, this.callLength); - }, 1000); + this.emit(CallEvent.LengthChanged, Math.round((Date.now() - this.callStartTime!) / 1000)); + }, CALL_LENGTH_INTERVAL); } } else if (this.peerConn?.iceConnectionState == 'failed') { // Firefox for Android does not yet have support for restartIce() @@ -2104,7 +2109,7 @@ export class MatrixCall extends TypedEventEmitter { logger.info(`Hanging up call ${this.callId} (ICE disconnected for too long)`); this.hangup(CallErrorCode.IceFailed, false); - }, 30 * 1000); + }, ICE_DISCONNECTED_TIMEOUT); this.state = CallState.Connecting; } diff --git a/src/webrtc/groupCall.ts b/src/webrtc/groupCall.ts index 51e1a0a78..58281b0e9 100644 --- a/src/webrtc/groupCall.ts +++ b/src/webrtc/groupCall.ts @@ -121,9 +121,17 @@ export interface IGroupCallDataChannelOptions { protocol: string; } +export interface IGroupCallRoomState { + "m.intent": GroupCallIntent; + "m.type": GroupCallType; + "io.element.ptt"?: boolean; + // TODO: Specify data-channels + "dataChannelsEnabled"?: boolean; + "dataChannelOptions"?: IGroupCallDataChannelOptions; +} + export interface IGroupCallRoomMemberFeed { purpose: SDPStreamMetadataPurpose; - // TODO: Sources for adaptive bitrate } export interface IGroupCallRoomMemberDevice { @@ -228,17 +236,19 @@ export class GroupCall extends TypedEventEmitter< this.client.groupCallEventHandler!.groupCalls.set(this.room.roomId, this); this.client.emit(GroupCallEventHandlerEvent.Outgoing, this); + const groupCallState: IGroupCallRoomState = { + "m.intent": this.intent, + "m.type": this.type, + "io.element.ptt": this.isPtt, + // TODO: Specify data-channels better + "dataChannelsEnabled": this.dataChannelsEnabled, + "dataChannelOptions": this.dataChannelsEnabled ? this.dataChannelOptions : undefined, + }; + await this.client.sendStateEvent( this.room.roomId, EventType.GroupCallPrefix, - { - "m.intent": this.intent, - "m.type": this.type, - "io.element.ptt": this.isPtt, - // TODO: Specify datachannels - "dataChannelsEnabled": this.dataChannelsEnabled, - "dataChannelOptions": this.dataChannelOptions, - }, + groupCallState, this.groupCallId, );