blob: 8d83e5da1359e8edb7cd3c092bb86e5eee658d92 [file] [log] [blame]
idillon989d6542023-01-30 15:49:58 -05001/*
2 * Copyright (C) 2022 Savoir-faire Linux Inc.
3 *
4 * This program is free software; you can redistribute it and/or modify
5 * it under the terms of the GNU Affero General Public License as
6 * published by the Free Software Foundation; either version 3 of the
7 * License, or (at your option) any later version.
8 *
9 * This program is distributed in the hope that it will be useful,
10 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 * GNU Affero General Public License for more details.
13 *
14 * You should have received a copy of the GNU Affero General Public
15 * License along with this program. If not, see
16 * <https://www.gnu.org/licenses/>.
17 */
18
19import { WebRtcIceCandidate, WebRtcSdp, WebSocketMessageType } from 'jami-web-common';
20
21import { CallData } from '../contexts/CallManagerProvider';
22import { IWebSocketContext } from '../contexts/WebSocketProvider';
23import { Account } from '../models/account';
24import { Listener } from '../utils/utils';
25
26export type RTCPeerConnectionInfos = {
27 remoteStreams: readonly MediaStream[];
28 iceConnectionState: RTCIceConnectionState;
29};
30
31export class RTCPeerConnectionHandler {
32 private readonly connection: RTCPeerConnection;
33 private remoteStreams: readonly MediaStream[] = [];
34 private iceConnectionState: RTCIceConnectionState = 'new';
35 private isReadyForIceCandidates = false;
36
37 private audioRtcRtpSenders: RTCRtpSender[] | null = null;
38 private videoRtcRtpSenders: RTCRtpSender[] | null = null;
39
40 // TODO: The ICE candidate queue is used to cache candidates that were received before `setRemoteDescription` was
41 // called. This is currently necessary, because the jami-daemon is unreliable as a WebRTC signaling channel,
42 // because messages can be received with a delay or out of order. This queue is a temporary workaround that
43 // should be replaced if there is a better way to send messages with the daemon.
44 // Relevant links:
45 // - https://github.com/w3c/webrtc-pc/issues/2519#issuecomment-622055440
46 // - https://stackoverflow.com/questions/57256828/how-to-fix-invalidstateerror-cannot-add-ice-candidate-when-there-is-no-remote-s
47 private iceCandidateQueue: RTCIceCandidate[] = [];
48
49 private cleaningFunctions: (() => void)[] = [];
50 private listener: Listener;
51
52 getInfos(): RTCPeerConnectionInfos {
53 return {
54 remoteStreams: this.remoteStreams,
55 iceConnectionState: this.iceConnectionState,
56 };
57 }
58
59 constructor(
60 webSocket: IWebSocketContext,
61 account: Account,
62 contactUri: string,
63 callData: CallData,
64 localStream: MediaStream | undefined,
65 screenShareLocalStream: MediaStream | undefined,
66 listener: Listener
67 ) {
68 this.listener = listener;
69 const iceServers = this.getIceServers(account);
70 this.connection = new RTCPeerConnection({ iceServers });
71 this.setConnectionListeners(webSocket, callData.conversationId, contactUri);
72 this.setWebSocketListeners(webSocket, callData.conversationId, contactUri);
73 this.updateLocalStreams(localStream, screenShareLocalStream);
74
75 if (callData.role === 'caller') {
76 this.startNegociation(webSocket, contactUri, callData.conversationId);
77 }
78 }
79
80 updateLocalStreams(localStream: MediaStream | undefined, screenShareLocalStream: MediaStream | undefined) {
81 if (this.connection.iceConnectionState === 'closed') {
82 return;
83 }
84
85 const updateTracks = async (stream: MediaStream, kind: 'audio' | 'video') => {
86 const senders = kind === 'audio' ? this.audioRtcRtpSenders : this.videoRtcRtpSenders;
87 const tracks = kind === 'audio' ? stream.getAudioTracks() : stream.getVideoTracks();
88 if (senders) {
89 const promises: Promise<void>[] = [];
90 for (let i = 0; i < senders.length; i++) {
91 // TODO: There is a bug where calling multiple times `addTrack` when changing an input device doesn't work.
92 // Calling `addTrack` doesn't trigger the `track` event listener for the other user.
93 // This workaround makes it possible to replace a track, but it could be improved by figuring out the
94 // proper way of changing a track.
95 promises.push(
96 senders[i].replaceTrack(tracks[i]).catch((e) => {
97 console.error('Error replacing track:', e);
98 })
99 );
100 }
101 return Promise.all(promises);
102 }
103
104 // TODO: Currently, we do not support adding new devices. To enable this feature, we would need to implement
105 // the "Perfect negotiation" pattern to renegotiate after `addTrack`.
106 // https://blog.mozilla.org/webrtc/perfect-negotiation-in-webrtc/
107 const newSenders = tracks.map((track) => this.connection.addTrack(track, stream));
108 if (kind === 'audio') {
109 this.audioRtcRtpSenders = newSenders;
110 } else {
111 this.videoRtcRtpSenders = newSenders;
112 }
113 };
114
115 if (localStream) {
116 updateTracks(localStream, 'audio');
117 updateTracks(localStream, 'video');
118 }
119
120 if (screenShareLocalStream) {
121 updateTracks(screenShareLocalStream, 'video');
122 }
123 }
124
125 disconnect() {
126 this.connection?.close();
127 this.cleaningFunctions.forEach((func) => func());
128 this.cleaningFunctions = [];
129 this.emitChange();
130 }
131
132 private getIceServers(account: Account) {
133 const iceServers: RTCIceServer[] = [];
134
135 if (account.details['TURN.enable'] === 'true') {
136 iceServers.push({
137 urls: 'turn:' + account.details['TURN.server'],
138 username: account.details['TURN.username'],
139 credential: account.details['TURN.password'],
140 });
141 }
142
143 if (account.details['STUN.enable'] === 'true') {
144 iceServers.push({
145 urls: 'stun:' + account.details['STUN.server'],
146 });
147 }
148
149 return iceServers;
150 }
151
152 private startNegociation(webSocket: IWebSocketContext, contactUri: string, conversationId: string) {
153 this.sendWebRtcOffer(webSocket, contactUri, conversationId);
154 }
155
156 private async sendWebRtcOffer(webSocket: IWebSocketContext, contactUri: string, conversationId: string) {
157 const sdp = await this.connection.createOffer({
158 offerToReceiveAudio: true,
159 offerToReceiveVideo: true,
160 });
161
162 const webRtcOffer: WebRtcSdp = {
163 contactId: contactUri,
164 conversationId: conversationId,
165 sdp,
166 };
167
168 await this.connection.setLocalDescription(new RTCSessionDescription(sdp));
169 console.info('Sending WebRtcOffer', webRtcOffer);
170 webSocket.send(WebSocketMessageType.WebRtcOffer, webRtcOffer);
171 }
172
173 private setWebSocketListeners(webSocket: IWebSocketContext, conversationId: string, contactUri: string) {
174 const sendWebRtcAnswer = async () => {
175 const sdp = await this.connection.createAnswer({
176 offerToReceiveAudio: true,
177 offerToReceiveVideo: true,
178 });
179
180 const webRtcAnswer: WebRtcSdp = {
181 contactId: contactUri,
182 conversationId: conversationId,
183 sdp,
184 };
185
186 await this.connection.setLocalDescription(new RTCSessionDescription(sdp));
187 console.info('Sending WebRtcAnswer', webRtcAnswer);
188 webSocket.send(WebSocketMessageType.WebRtcAnswer, webRtcAnswer);
189 };
190
191 const addQueuedIceCandidates = async () => {
192 console.info('WebRTC remote description has been set. Ready to receive ICE candidates');
193 this.isReadyForIceCandidates = true;
194 if (this.iceCandidateQueue.length !== 0) {
195 console.warn(
196 'Found queued ICE candidates that were added before `setRemoteDescription` was called. ' +
197 'Adding queued ICE candidates...',
198 this.iceCandidateQueue
199 );
200
201 await Promise.all(this.iceCandidateQueue.map((iceCandidate) => this.connection.addIceCandidate(iceCandidate)));
202 }
203 };
204
205 const webRtcOfferListener = async (data: WebRtcSdp) => {
206 console.debug('receive webrtcoffer');
207 console.info('Received event on WebRtcOffer', data);
208 if (data.conversationId !== conversationId) {
209 console.warn('Wrong incoming conversationId, ignoring action');
210 return;
211 }
212
213 await this.connection.setRemoteDescription(new RTCSessionDescription(data.sdp));
214 await sendWebRtcAnswer();
215 await addQueuedIceCandidates();
216 };
217
218 const webRtcAnswerListener = async (data: WebRtcSdp) => {
219 console.info('Received event on WebRtcAnswer', data);
220 if (data.conversationId !== conversationId) {
221 console.warn('Wrong incoming conversationId, ignoring action');
222 return;
223 }
224
225 await this.connection.setRemoteDescription(new RTCSessionDescription(data.sdp));
226 await addQueuedIceCandidates();
227 };
228
229 const webRtcIceCandidateListener = async (data: WebRtcIceCandidate) => {
230 if (data.conversationId !== conversationId) {
231 console.warn('Wrong incoming conversationId, ignoring action');
232 return;
233 }
234
235 if (!data.candidate) {
236 return;
237 }
238
239 if (this.isReadyForIceCandidates) {
240 await this.connection.addIceCandidate(data.candidate);
241 } else {
242 this.iceCandidateQueue.push(data.candidate);
243 }
244 };
245
246 webSocket.bind(WebSocketMessageType.WebRtcOffer, webRtcOfferListener);
247 webSocket.bind(WebSocketMessageType.WebRtcAnswer, webRtcAnswerListener);
248 webSocket.bind(WebSocketMessageType.WebRtcIceCandidate, webRtcIceCandidateListener);
249
250 this.cleaningFunctions.push(() => {
251 webSocket.unbind(WebSocketMessageType.WebRtcOffer, webRtcOfferListener);
252 webSocket.unbind(WebSocketMessageType.WebRtcAnswer, webRtcAnswerListener);
253 webSocket.unbind(WebSocketMessageType.WebRtcIceCandidate, webRtcIceCandidateListener);
254 });
255 }
256
257 private setConnectionListeners(webSocket: IWebSocketContext, conversationId: string, contactUri: string) {
258 this.connection.onicecandidate = (event: RTCPeerConnectionIceEvent) => {
259 if (event.candidate) {
260 const webRtcIceCandidate: WebRtcIceCandidate = {
261 contactId: contactUri,
262 conversationId: conversationId,
263 candidate: event.candidate,
264 };
265
266 // Send ice candidates as soon as they're found. This is called "trickle ice"
267 webSocket.send(WebSocketMessageType.WebRtcIceCandidate, webRtcIceCandidate);
268 }
269 };
270
271 this.connection.ontrack = (event: RTCTrackEvent) => {
272 console.info('Received WebRTC event on track', event);
273 this.remoteStreams = event.streams;
274 this.emitChange();
275 };
276
277 this.connection.oniceconnectionstatechange = (event: Event) => {
278 console.info(`Received WebRTC event on iceconnectionstatechange: ${this.connection.iceConnectionState}`, event);
279 this.iceConnectionState = this.connection.iceConnectionState;
280 this.emitChange();
281 };
282 }
283
284 private emitChange() {
285 this.listener();
286 }
287}