Move WebRtc logic to WebRtcManager

- This is intended to reduce the quantity of useEffect, which causes "cascading" updates. The end goal is to reintroduce StrictMode (soon), and hopefully to make the code easier to understand.
- Some logic to support calls with more than one peer has been added. This is intended to test the new architecture will be scalable with React hooks.

Change-Id: Id76c4061b06a759f55957a48a1283d46afc3f73a
diff --git a/client/src/webrtc/RtcPeerConnectionHandler.ts b/client/src/webrtc/RtcPeerConnectionHandler.ts
new file mode 100644
index 0000000..8d83e5d
--- /dev/null
+++ b/client/src/webrtc/RtcPeerConnectionHandler.ts
@@ -0,0 +1,287 @@
+/*
+ * Copyright (C) 2022 Savoir-faire Linux Inc.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public
+ * License along with this program.  If not, see
+ * <https://www.gnu.org/licenses/>.
+ */
+
+import { WebRtcIceCandidate, WebRtcSdp, WebSocketMessageType } from 'jami-web-common';
+
+import { CallData } from '../contexts/CallManagerProvider';
+import { IWebSocketContext } from '../contexts/WebSocketProvider';
+import { Account } from '../models/account';
+import { Listener } from '../utils/utils';
+
+export type RTCPeerConnectionInfos = {
+  remoteStreams: readonly MediaStream[];
+  iceConnectionState: RTCIceConnectionState;
+};
+
+export class RTCPeerConnectionHandler {
+  private readonly connection: RTCPeerConnection;
+  private remoteStreams: readonly MediaStream[] = [];
+  private iceConnectionState: RTCIceConnectionState = 'new';
+  private isReadyForIceCandidates = false;
+
+  private audioRtcRtpSenders: RTCRtpSender[] | null = null;
+  private videoRtcRtpSenders: RTCRtpSender[] | null = null;
+
+  // TODO: The ICE candidate queue is used to cache candidates that were received before `setRemoteDescription` was
+  //       called. This is currently necessary, because the jami-daemon is unreliable as a WebRTC signaling channel,
+  //       because messages can be received with a delay or out of order. This queue is a temporary workaround that
+  //       should be replaced if there is a better way to send messages with the daemon.
+  //       Relevant links:
+  //       - https://github.com/w3c/webrtc-pc/issues/2519#issuecomment-622055440
+  //       - https://stackoverflow.com/questions/57256828/how-to-fix-invalidstateerror-cannot-add-ice-candidate-when-there-is-no-remote-s
+  private iceCandidateQueue: RTCIceCandidate[] = [];
+
+  private cleaningFunctions: (() => void)[] = [];
+  private listener: Listener;
+
+  getInfos(): RTCPeerConnectionInfos {
+    return {
+      remoteStreams: this.remoteStreams,
+      iceConnectionState: this.iceConnectionState,
+    };
+  }
+
+  constructor(
+    webSocket: IWebSocketContext,
+    account: Account,
+    contactUri: string,
+    callData: CallData,
+    localStream: MediaStream | undefined,
+    screenShareLocalStream: MediaStream | undefined,
+    listener: Listener
+  ) {
+    this.listener = listener;
+    const iceServers = this.getIceServers(account);
+    this.connection = new RTCPeerConnection({ iceServers });
+    this.setConnectionListeners(webSocket, callData.conversationId, contactUri);
+    this.setWebSocketListeners(webSocket, callData.conversationId, contactUri);
+    this.updateLocalStreams(localStream, screenShareLocalStream);
+
+    if (callData.role === 'caller') {
+      this.startNegociation(webSocket, contactUri, callData.conversationId);
+    }
+  }
+
+  updateLocalStreams(localStream: MediaStream | undefined, screenShareLocalStream: MediaStream | undefined) {
+    if (this.connection.iceConnectionState === 'closed') {
+      return;
+    }
+
+    const updateTracks = async (stream: MediaStream, kind: 'audio' | 'video') => {
+      const senders = kind === 'audio' ? this.audioRtcRtpSenders : this.videoRtcRtpSenders;
+      const tracks = kind === 'audio' ? stream.getAudioTracks() : stream.getVideoTracks();
+      if (senders) {
+        const promises: Promise<void>[] = [];
+        for (let i = 0; i < senders.length; i++) {
+          // TODO: There is a bug where calling multiple times `addTrack` when changing an input device doesn't work.
+          //       Calling `addTrack` doesn't trigger the `track` event listener for the other user.
+          //       This workaround makes it possible to replace a track, but it could be improved by figuring out the
+          //       proper way of changing a track.
+          promises.push(
+            senders[i].replaceTrack(tracks[i]).catch((e) => {
+              console.error('Error replacing track:', e);
+            })
+          );
+        }
+        return Promise.all(promises);
+      }
+
+      // TODO: Currently, we do not support adding new devices. To enable this feature, we would need to implement
+      //       the "Perfect negotiation" pattern to renegotiate after `addTrack`.
+      //       https://blog.mozilla.org/webrtc/perfect-negotiation-in-webrtc/
+      const newSenders = tracks.map((track) => this.connection.addTrack(track, stream));
+      if (kind === 'audio') {
+        this.audioRtcRtpSenders = newSenders;
+      } else {
+        this.videoRtcRtpSenders = newSenders;
+      }
+    };
+
+    if (localStream) {
+      updateTracks(localStream, 'audio');
+      updateTracks(localStream, 'video');
+    }
+
+    if (screenShareLocalStream) {
+      updateTracks(screenShareLocalStream, 'video');
+    }
+  }
+
+  disconnect() {
+    this.connection?.close();
+    this.cleaningFunctions.forEach((func) => func());
+    this.cleaningFunctions = [];
+    this.emitChange();
+  }
+
+  private getIceServers(account: Account) {
+    const iceServers: RTCIceServer[] = [];
+
+    if (account.details['TURN.enable'] === 'true') {
+      iceServers.push({
+        urls: 'turn:' + account.details['TURN.server'],
+        username: account.details['TURN.username'],
+        credential: account.details['TURN.password'],
+      });
+    }
+
+    if (account.details['STUN.enable'] === 'true') {
+      iceServers.push({
+        urls: 'stun:' + account.details['STUN.server'],
+      });
+    }
+
+    return iceServers;
+  }
+
+  private startNegociation(webSocket: IWebSocketContext, contactUri: string, conversationId: string) {
+    this.sendWebRtcOffer(webSocket, contactUri, conversationId);
+  }
+
+  private async sendWebRtcOffer(webSocket: IWebSocketContext, contactUri: string, conversationId: string) {
+    const sdp = await this.connection.createOffer({
+      offerToReceiveAudio: true,
+      offerToReceiveVideo: true,
+    });
+
+    const webRtcOffer: WebRtcSdp = {
+      contactId: contactUri,
+      conversationId: conversationId,
+      sdp,
+    };
+
+    await this.connection.setLocalDescription(new RTCSessionDescription(sdp));
+    console.info('Sending WebRtcOffer', webRtcOffer);
+    webSocket.send(WebSocketMessageType.WebRtcOffer, webRtcOffer);
+  }
+
+  private setWebSocketListeners(webSocket: IWebSocketContext, conversationId: string, contactUri: string) {
+    const sendWebRtcAnswer = async () => {
+      const sdp = await this.connection.createAnswer({
+        offerToReceiveAudio: true,
+        offerToReceiveVideo: true,
+      });
+
+      const webRtcAnswer: WebRtcSdp = {
+        contactId: contactUri,
+        conversationId: conversationId,
+        sdp,
+      };
+
+      await this.connection.setLocalDescription(new RTCSessionDescription(sdp));
+      console.info('Sending WebRtcAnswer', webRtcAnswer);
+      webSocket.send(WebSocketMessageType.WebRtcAnswer, webRtcAnswer);
+    };
+
+    const addQueuedIceCandidates = async () => {
+      console.info('WebRTC remote description has been set. Ready to receive ICE candidates');
+      this.isReadyForIceCandidates = true;
+      if (this.iceCandidateQueue.length !== 0) {
+        console.warn(
+          'Found queued ICE candidates that were added before `setRemoteDescription` was called. ' +
+            'Adding queued ICE candidates...',
+          this.iceCandidateQueue
+        );
+
+        await Promise.all(this.iceCandidateQueue.map((iceCandidate) => this.connection.addIceCandidate(iceCandidate)));
+      }
+    };
+
+    const webRtcOfferListener = async (data: WebRtcSdp) => {
+      console.debug('receive webrtcoffer');
+      console.info('Received event on WebRtcOffer', data);
+      if (data.conversationId !== conversationId) {
+        console.warn('Wrong incoming conversationId, ignoring action');
+        return;
+      }
+
+      await this.connection.setRemoteDescription(new RTCSessionDescription(data.sdp));
+      await sendWebRtcAnswer();
+      await addQueuedIceCandidates();
+    };
+
+    const webRtcAnswerListener = async (data: WebRtcSdp) => {
+      console.info('Received event on WebRtcAnswer', data);
+      if (data.conversationId !== conversationId) {
+        console.warn('Wrong incoming conversationId, ignoring action');
+        return;
+      }
+
+      await this.connection.setRemoteDescription(new RTCSessionDescription(data.sdp));
+      await addQueuedIceCandidates();
+    };
+
+    const webRtcIceCandidateListener = async (data: WebRtcIceCandidate) => {
+      if (data.conversationId !== conversationId) {
+        console.warn('Wrong incoming conversationId, ignoring action');
+        return;
+      }
+
+      if (!data.candidate) {
+        return;
+      }
+
+      if (this.isReadyForIceCandidates) {
+        await this.connection.addIceCandidate(data.candidate);
+      } else {
+        this.iceCandidateQueue.push(data.candidate);
+      }
+    };
+
+    webSocket.bind(WebSocketMessageType.WebRtcOffer, webRtcOfferListener);
+    webSocket.bind(WebSocketMessageType.WebRtcAnswer, webRtcAnswerListener);
+    webSocket.bind(WebSocketMessageType.WebRtcIceCandidate, webRtcIceCandidateListener);
+
+    this.cleaningFunctions.push(() => {
+      webSocket.unbind(WebSocketMessageType.WebRtcOffer, webRtcOfferListener);
+      webSocket.unbind(WebSocketMessageType.WebRtcAnswer, webRtcAnswerListener);
+      webSocket.unbind(WebSocketMessageType.WebRtcIceCandidate, webRtcIceCandidateListener);
+    });
+  }
+
+  private setConnectionListeners(webSocket: IWebSocketContext, conversationId: string, contactUri: string) {
+    this.connection.onicecandidate = (event: RTCPeerConnectionIceEvent) => {
+      if (event.candidate) {
+        const webRtcIceCandidate: WebRtcIceCandidate = {
+          contactId: contactUri,
+          conversationId: conversationId,
+          candidate: event.candidate,
+        };
+
+        // Send ice candidates as soon as they're found. This is called "trickle ice"
+        webSocket.send(WebSocketMessageType.WebRtcIceCandidate, webRtcIceCandidate);
+      }
+    };
+
+    this.connection.ontrack = (event: RTCTrackEvent) => {
+      console.info('Received WebRTC event on track', event);
+      this.remoteStreams = event.streams;
+      this.emitChange();
+    };
+
+    this.connection.oniceconnectionstatechange = (event: Event) => {
+      console.info(`Received WebRTC event on iceconnectionstatechange: ${this.connection.iceConnectionState}`, event);
+      this.iceConnectionState = this.connection.iceConnectionState;
+      this.emitChange();
+    };
+  }
+
+  private emitChange() {
+    this.listener();
+  }
+}