From c71b28a6a8a4d20b5de7887197ed1346d5e83003 Mon Sep 17 00:00:00 2001 From: Claudio Costa Date: Wed, 10 Aug 2022 18:33:02 +0200 Subject: [PATCH] [MM-45749] Support for advanced ICE server configs and TURN credentials (#6544) * Support for advanced ICE server configs and TURN credentials * PR comments * merge conflicts Co-authored-by: Christopher Poile --- app/products/calls/client/rest.ts | 10 ++- app/products/calls/connection/connection.ts | 17 ++++- app/products/calls/connection/simple-peer.ts | 25 ++---- app/products/calls/state/actions.test.ts | 11 ++- app/products/calls/types/calls.ts | 16 ++-- app/products/calls/utils.test.ts | 80 ++++++++++++++++++++ app/products/calls/utils.ts | 24 +++++- types/modules/react-native-webrtc.d.ts | 2 +- 8 files changed, 153 insertions(+), 32 deletions(-) create mode 100644 app/products/calls/utils.test.ts diff --git a/app/products/calls/client/rest.ts b/app/products/calls/client/rest.ts index 401f4c4e9..f86a00823 100644 --- a/app/products/calls/client/rest.ts +++ b/app/products/calls/client/rest.ts @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import type {ServerChannelState, ServerCallsConfig, ApiResp} from '@calls/types/calls'; +import type {ServerChannelState, ServerCallsConfig, ApiResp, ICEServersConfigs} from '@calls/types/calls'; export interface ClientCallsMix { getEnabled: () => Promise; @@ -9,6 +9,7 @@ export interface ClientCallsMix { getCallsConfig: () => Promise; enableChannelCalls: (channelId: string, enable: boolean) => Promise; endCall: (channelId: string) => Promise; + genTURNCredentials: () => Promise; } const ClientCalls = (superclass: any) => class extends superclass { @@ -51,6 +52,13 @@ const ClientCalls = (superclass: any) => class extends superclass { {method: 'post'}, ); }; + + genTURNCredentials = async () => { + return this.doFetch( + `${this.getCallsRoute()}/turn-credentials`, + {method: 'get'}, + ); + }; }; export default ClientCalls; diff --git a/app/products/calls/connection/connection.ts b/app/products/calls/connection/connection.ts index 33fee60dc..31a7f4d85 100644 --- a/app/products/calls/connection/connection.ts +++ b/app/products/calls/connection/connection.ts @@ -12,15 +12,17 @@ import { mediaDevices, } from 'react-native-webrtc'; -import {CallsConnection} from '@calls/types/calls'; +import {getICEServersConfigs} from '@calls/utils'; import {WebsocketEvents} from '@constants'; import {getServerCredentials} from '@init/credentials'; import NetworkManager from '@managers/network_manager'; -import {logError, logDebug} from '@utils/log'; +import {logError, logDebug, logWarning} from '@utils/log'; import Peer from './simple-peer'; import {WebSocketClient, wsReconnectionTimeoutErr} from './websocket_client'; +import type {CallsConnection} from '@calls/types/calls'; + const websocketConnectTimeout = 3000; export async function newConnection(serverUrl: string, channelID: string, closeCb: () => void, setScreenShareURL: (url: string) => void) { @@ -169,9 +171,18 @@ export async function newConnection(serverUrl: string, channelID: string, closeC return; } + const iceConfigs = getICEServersConfigs(config); + if (config.NeedsTURNCredentials) { + try { + iceConfigs.push(...await client.genTURNCredentials()); + } catch (err) { + logWarning('failed to fetch TURN credentials:', err); + } + } + InCallManager.start({media: 'audio'}); InCallManager.stopProximitySensor(); - peer = new Peer(null, config.ICEServers); + peer = new Peer(null, iceConfigs); peer.on('signal', (data: any) => { if (data.type === 'offer' || data.type === 'answer') { ws.send('sdp', { diff --git a/app/products/calls/connection/simple-peer.ts b/app/products/calls/connection/simple-peer.ts index ed2c38ec1..8a7f51cb2 100644 --- a/app/products/calls/connection/simple-peer.ts +++ b/app/products/calls/connection/simple-peer.ts @@ -21,6 +21,8 @@ import { } from 'react-native-webrtc'; import stream from 'readable-stream'; +import type {ICEServersConfigs} from '@calls/types/calls'; + const queueMicrotask = (callback: any) => { Promise.resolve().then(callback).catch((e) => setTimeout(() => { throw e; @@ -94,7 +96,7 @@ export default class Peer extends stream.Duplex { private pc: RTCPeerConnection|null = null; private onFinishBound?: () => void; - constructor(localStream: MediaStream | null, iceServers?: string[]) { + constructor(localStream: MediaStream | null, iceServers: ICEServersConfigs) { super({allowHalfOpen: false}); this.streams = localStream ? [localStream] : []; @@ -104,21 +106,10 @@ export default class Peer extends stream.Duplex { }; const connConfig = { - iceServers: [ - { - urls: [ - 'stun:stun.l.google.com:19302', - 'stun:global.stun.twilio.com:3478', - ], - }, - ], + iceServers, sdpSemantics: 'unified-plan', }; - if (iceServers && iceServers.length > 0) { - connConfig.iceServers[0].urls = iceServers; - } - try { this.pc = new RTCPeerConnection(connConfig); } catch (err) { @@ -539,11 +530,9 @@ export default class Peer extends stream.Duplex { }; this.channel.onerror = (e: any) => { const err = - e.error instanceof Error ? - e.error : - new Error( - `Datachannel error: ${e.message} ${e.filename}:${e.lineno}:${e.colno}`, - ); + e.error instanceof Error ? e.error : new Error( + `Datachannel error: ${e.message} ${e.filename}:${e.lineno}:${e.colno}`, + ); this.destroy(errCode(err, 'ERR_DATA_CHANNEL')); }; diff --git a/app/products/calls/state/actions.test.ts b/app/products/calls/state/actions.test.ts index 1a3f7bbb5..6f2c15f85 100644 --- a/app/products/calls/state/actions.test.ts +++ b/app/products/calls/state/actions.test.ts @@ -589,9 +589,18 @@ describe('useCallsState', () => { it('config', () => { const newConfig = { - ICEServers: ['google.com'], + ICEServers: [], + ICEServersConfigs: [ + { + urls: ['stun:stun.example.com:3478'], + }, + { + urls: ['turn:turn.example.com:3478'], + }, + ], AllowEnableCalls: true, DefaultEnabled: true, + NeedsTURNCredentials: false, last_retrieved_at: 123, }; diff --git a/app/products/calls/types/calls.ts b/app/products/calls/types/calls.ts index facd5fce0..b8d738d29 100644 --- a/app/products/calls/types/calls.ts +++ b/app/products/calls/types/calls.ts @@ -2,6 +2,7 @@ // See LICENSE.txt for license information. import type UserModel from '@typings/database/models/servers/user'; +import type {ConfigurationParamWithUrls, ConfigurationParamWithUrl} from 'react-native-webrtc'; export type CallsState = { serverUrl: string; @@ -91,27 +92,30 @@ export type CallsConnection = { } export type ServerCallsConfig = { - ICEServers: string[]; + ICEServers?: string[]; // deprecated + ICEServersConfigs?: ICEServersConfigs; AllowEnableCalls: boolean; DefaultEnabled: boolean; + NeedsTURNCredentials: boolean; } -export type CallsConfig = { +export type CallsConfig = ServerCallsConfig & { pluginEnabled: boolean; - ICEServers: string[]; - AllowEnableCalls: boolean; - DefaultEnabled: boolean; last_retrieved_at: number; } export const DefaultCallsConfig = { pluginEnabled: false, - ICEServers: [], + ICEServers: [], // deprecated + ICEServersConfigs: [], AllowEnableCalls: false, DefaultEnabled: false, + NeedsTURNCredentials: false, last_retrieved_at: 0, } as CallsConfig; +export type ICEServersConfigs = Array; + export type ApiResp = { message?: string; detailed_error?: string; diff --git a/app/products/calls/utils.test.ts b/app/products/calls/utils.test.ts new file mode 100644 index 000000000..af0dae32a --- /dev/null +++ b/app/products/calls/utils.test.ts @@ -0,0 +1,80 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import assert from 'assert'; + +import {getICEServersConfigs} from './utils'; + +describe('getICEServersConfigs', () => { + it('backwards compatible case, no ICEServersConfigs present', () => { + const config = { + ICEServers: ['stun:stun.example.com:3478'], + AllowEnableCalls: true, + DefaultEnabled: true, + NeedsTURNCredentials: false, + last_retrieved_at: 0, + }; + const iceConfigs = getICEServersConfigs(config); + + assert.deepEqual(iceConfigs, [ + { + urls: ['stun:stun.example.com:3478'], + }, + ]); + }); + + it('ICEServersConfigs set', () => { + const config = { + ICEServersConfigs: [ + { + urls: ['stun:stun.example.com:3478'], + }, + { + urls: ['turn:turn.example.com:3478'], + }, + ], + AllowEnableCalls: true, + DefaultEnabled: true, + NeedsTURNCredentials: false, + last_retrieved_at: 0, + }; + const iceConfigs = getICEServersConfigs(config); + + assert.deepEqual(iceConfigs, [ + { + urls: ['stun:stun.example.com:3478'], + }, + { + urls: ['turn:turn.example.com:3478'], + }, + ]); + }); + + it('Both ICEServers and ICEServersConfigs set', () => { + const config = { + ICEServers: ['stun:stuna.example.com:3478'], + ICEServersConfigs: [ + { + urls: ['stun:stunb.example.com:3478'], + }, + { + urls: ['turn:turn.example.com:3478'], + }, + ], + AllowEnableCalls: true, + DefaultEnabled: true, + NeedsTURNCredentials: false, + last_retrieved_at: 0, + }; + const iceConfigs = getICEServersConfigs(config); + + assert.deepEqual(iceConfigs, [ + { + urls: ['stun:stunb.example.com:3478'], + }, + { + urls: ['turn:turn.example.com:3478'], + }, + ]); + }); +}); diff --git a/app/products/calls/utils.ts b/app/products/calls/utils.ts index 077b2da14..2f94eefd7 100644 --- a/app/products/calls/utils.ts +++ b/app/products/calls/utils.ts @@ -3,11 +3,12 @@ import {Alert} from 'react-native'; -import {CallParticipant} from '@calls/types/calls'; -import {Post, Calls} from '@constants'; +import {Post} from '@constants'; +import Calls from '@constants/calls'; import {isMinimumServerVersion} from '@utils/helpers'; import {displayUsername} from '@utils/user'; +import type {CallParticipant, ServerCallsConfig} from '@calls/types/calls'; import type PostModel from '@typings/database/models/servers/post'; import type {IntlShape} from 'react-intl'; @@ -104,3 +105,22 @@ export function errorAlert(error: string, intl: IntlShape) { }, {error}), ); } + +export function getICEServersConfigs(config: ServerCallsConfig) { + // if ICEServersConfigs is set, we can trust this to be complete and + // coming from an updated API. + if (config.ICEServersConfigs && config.ICEServersConfigs.length > 0) { + return config.ICEServersConfigs; + } + + // otherwise we revert to using the now deprecated field. + if (config.ICEServers && config.ICEServers.length > 0) { + return [ + { + urls: config.ICEServers, + }, + ]; + } + + return []; +} diff --git a/types/modules/react-native-webrtc.d.ts b/types/modules/react-native-webrtc.d.ts index 1e0f68827..0369d34ec 100644 --- a/types/modules/react-native-webrtc.d.ts +++ b/types/modules/react-native-webrtc.d.ts @@ -168,7 +168,7 @@ declare module 'react-native-webrtc' { } export interface RTCPeerConnectionConfiguration { - iceServers: ConfigurationParamWithUrls[] | ConfigurationParamWithUrl[]; + iceServers: Array; iceTransportPolicy?: 'all' | 'relay' | 'nohost' | 'none' | undefined; bundlePolicy?: 'balanced' | 'max-compat' | 'max-bundle' | undefined; rtcpMuxPolicy?: 'negotiate' | 'require' | undefined;