Implement perfect negotiation

Change-Id: Ic768ffb86407a7078554488b45e44a1b2ecfbede
diff --git a/client/src/services/CallManager.tsx b/client/src/services/CallManager.tsx
index b0837b0..8522976 100644
--- a/client/src/services/CallManager.tsx
+++ b/client/src/services/CallManager.tsx
@@ -249,7 +249,7 @@
           setCallStatus(CallStatus.Connecting);
           console.info('Sending CallJoin', callAccept);
           webSocket.send(WebSocketMessageType.sendCallJoin, callAccept);
-          // TODO: move this to "onWebRtcOffer" listener so we don't add connections for non-connected members
+          // TODO: move this to "onWebRtcDescription" listener so we don't add connections for non-connected members
           callMembers.forEach((member) =>
             webRtcManager.addConnection(
               webSocket,
diff --git a/client/src/webrtc/RtcPeerConnectionHandler.ts b/client/src/webrtc/RtcPeerConnectionHandler.ts
index d8d9d3c..1145e83 100644
--- a/client/src/webrtc/RtcPeerConnectionHandler.ts
+++ b/client/src/webrtc/RtcPeerConnectionHandler.ts
@@ -34,6 +34,10 @@
   private iceConnectionState: RTCIceConnectionState = 'new';
   private isReadyForIceCandidates = false;
 
+  private isPolite = false; // Implementing "perfect negotiation" pattern
+  private makingOffer = false;
+  private ignoreOffer = false;
+
   private audioRtcRtpSenders: RTCRtpSender[] | null = null;
   private videoRtcRtpSenders: RTCRtpSender[] | null = null;
 
@@ -65,17 +69,13 @@
     screenShareLocalStream: MediaStream | undefined,
     listener: Listener
   ) {
-    console.log('constructor', callData);
+    this.isPolite = account.getUri() < contactUri;
     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) {
@@ -150,45 +150,21 @@
     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: WebSocketMessageTable['sendWebRtcOffer'] = {
-      receiverId: contactUri,
-      conversationId: conversationId,
-      sdp,
-    };
-
-    await this.connection.setLocalDescription(new RTCSessionDescription(sdp));
-    console.info('Sending WebRtcOffer', webRtcOffer);
-    webSocket.send(WebSocketMessageType.sendWebRtcOffer, webRtcOffer);
-  }
-
-  private setWebSocketListeners(webSocket: IWebSocketContext, conversationId: string, contactUri: string) {
-    const sendWebRtcAnswer = async () => {
-      const sdp = await this.connection.createAnswer({
-        offerToReceiveAudio: true,
-        offerToReceiveVideo: true,
-      });
-
-      const webRtcAnswer: WebSocketMessageTable['sendWebRtcAnswer'] = {
+  private async sendLocalDescription(webSocket: IWebSocketContext, conversationId: string, contactUri: string) {
+    await this.connection.setLocalDescription();
+    const sdp = this.connection.localDescription;
+    if (sdp) {
+      const webRtcDescription: WebSocketMessageTable['sendWebRtcDescription'] = {
         receiverId: contactUri,
         conversationId: conversationId,
         sdp,
       };
+      webSocket.send(WebSocketMessageType.sendWebRtcDescription, webRtcDescription);
+      console.info('Sending webRtcDescription', webRtcDescription);
+    }
+  }
 
-      await this.connection.setLocalDescription(new RTCSessionDescription(sdp));
-      console.info('Sending WebRtcAnswer', webRtcAnswer);
-      webSocket.send(WebSocketMessageType.sendWebRtcAnswer, webRtcAnswer);
-    };
-
+  private setWebSocketListeners(webSocket: IWebSocketContext, conversationId: string, contactUri: string) {
     const addQueuedIceCandidates = async () => {
       console.info('WebRTC remote description has been set. Ready to receive ICE candidates');
       this.isReadyForIceCandidates = true;
@@ -199,31 +175,36 @@
           this.iceCandidateQueue
         );
 
-        await Promise.all(this.iceCandidateQueue.map((iceCandidate) => this.connection.addIceCandidate(iceCandidate)));
+        await Promise.all(
+          this.iceCandidateQueue.map((iceCandidate) => this.connection.addIceCandidate(iceCandidate))
+        ).catch((err) => {
+          if (!this.ignoreOffer) {
+            console.error(err);
+          }
+        });
       }
     };
 
-    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) => {
+    const webRtcDescriptionListener = 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));
+      const remoteDescription = data.sdp;
+      const offerCollision =
+        remoteDescription.type === 'offer' && (this.makingOffer || this.connection.signalingState !== 'stable');
+
+      this.ignoreOffer = !this.isPolite && offerCollision;
+      if (this.ignoreOffer) {
+        return;
+      }
+
+      await this.connection.setRemoteDescription(remoteDescription);
+      if (remoteDescription.type === 'offer') {
+        await this.sendLocalDescription(webSocket, conversationId, contactUri);
+      }
       await addQueuedIceCandidates();
     };
 
@@ -238,24 +219,48 @@
       }
 
       if (this.isReadyForIceCandidates) {
-        await this.connection.addIceCandidate(data.candidate);
+        try {
+          await this.connection.addIceCandidate(data.candidate);
+        } catch (err) {
+          if (!this.ignoreOffer) {
+            console.error(err);
+          }
+        }
       } else {
         this.iceCandidateQueue.push(data.candidate);
       }
     };
 
-    webSocket.bind(WebSocketMessageType.onWebRtcOffer, webRtcOfferListener);
-    webSocket.bind(WebSocketMessageType.onWebRtcAnswer, webRtcAnswerListener);
+    webSocket.bind(WebSocketMessageType.onWebRtcDescription, webRtcDescriptionListener);
     webSocket.bind(WebSocketMessageType.onWebRtcIceCandidate, webRtcIceCandidateListener);
 
     this.cleaningFunctions.push(() => {
-      webSocket.unbind(WebSocketMessageType.onWebRtcOffer, webRtcOfferListener);
-      webSocket.unbind(WebSocketMessageType.onWebRtcAnswer, webRtcAnswerListener);
+      webSocket.unbind(WebSocketMessageType.onWebRtcDescription, webRtcDescriptionListener);
       webSocket.unbind(WebSocketMessageType.onWebRtcIceCandidate, webRtcIceCandidateListener);
     });
   }
 
   private setConnectionListeners(webSocket: IWebSocketContext, conversationId: string, contactUri: string) {
+    this.connection.onnegotiationneeded = async () => {
+      try {
+        this.makingOffer = true;
+        await this.sendLocalDescription(webSocket, conversationId, contactUri);
+      } catch (err) {
+        // This is not an error if there was a collision
+        console.warn(err);
+      } finally {
+        this.makingOffer = false;
+      }
+    };
+
+    this.connection.oniceconnectionstatechange = () => {
+      if (this.connection.iceConnectionState === 'failed') {
+        // This is not an error if there was a collision
+        console.warn('ICE connection failed, restarting ICE');
+        this.connection.restartIce();
+      }
+    };
+
     this.connection.onicecandidate = (event: RTCPeerConnectionIceEvent) => {
       if (event.candidate) {
         const webRtcIceCandidate: WebSocketMessageTable['sendWebRtcIceCandidate'] = {
diff --git a/common/src/enums/websocket-message-type.ts b/common/src/enums/websocket-message-type.ts
index b2d729c..28977fb 100644
--- a/common/src/enums/websocket-message-type.ts
+++ b/common/src/enums/websocket-message-type.ts
@@ -28,10 +28,8 @@
   onCallExit = 'onCallExit',
   sendCallJoin = 'sendCallJoin',
   onCallJoin = 'onCallJoin',
-  sendWebRtcOffer = 'sendWebRtcOffer',
-  onWebRtcOffer = 'onWebRtcOffer',
-  sendWebRtcAnswer = 'sendWebRtcAnswer',
-  onWebRtcAnswer = 'onWebRtcAnswer',
+  sendWebRtcDescription = 'sendWebRtcDescription',
+  onWebRtcDescription = 'onWebRtcDescription',
   sendWebRtcIceCandidate = 'sendWebRtcIceCandidate',
   onWebRtcIceCandidate = 'onWebRtcIceCandidate',
 }
diff --git a/common/src/interfaces/websocket-message.ts b/common/src/interfaces/websocket-message.ts
index f85ea5e..b1c1524 100644
--- a/common/src/interfaces/websocket-message.ts
+++ b/common/src/interfaces/websocket-message.ts
@@ -43,10 +43,8 @@
   [WebSocketMessageType.onCallExit]: CallExit & WithSender;
   [WebSocketMessageType.sendCallJoin]: CallJoin;
   [WebSocketMessageType.onCallJoin]: CallJoin & WithSender;
-  [WebSocketMessageType.sendWebRtcOffer]: WebRtcSdp & WithReceiver;
-  [WebSocketMessageType.onWebRtcOffer]: WebRtcSdp & WithSender;
-  [WebSocketMessageType.sendWebRtcAnswer]: WebRtcSdp & WithReceiver;
-  [WebSocketMessageType.onWebRtcAnswer]: WebRtcSdp & WithSender;
+  [WebSocketMessageType.sendWebRtcDescription]: WebRtcSdp & WithReceiver;
+  [WebSocketMessageType.onWebRtcDescription]: WebRtcSdp & WithSender;
   [WebSocketMessageType.sendWebRtcIceCandidate]: WebRtcIceCandidate & WithReceiver;
   [WebSocketMessageType.onWebRtcIceCandidate]: WebRtcIceCandidate & WithSender;
 }
diff --git a/server/src/websocket/webrtc-handler.ts b/server/src/websocket/webrtc-handler.ts
index 9d2c6e7..ce3fef5 100644
--- a/server/src/websocket/webrtc-handler.ts
+++ b/server/src/websocket/webrtc-handler.ts
@@ -46,14 +46,9 @@
     });
   });
 
-  webSocketServer.bind(WebSocketMessageType.sendWebRtcOffer, (accountId, data) => {
+  webSocketServer.bind(WebSocketMessageType.sendWebRtcDescription, (accountId, data) => {
     const { receiverId, ...partialData } = data;
-    sendWebRtcData(WebSocketMessageType.onWebRtcOffer, partialData, accountId, receiverId);
-  });
-
-  webSocketServer.bind(WebSocketMessageType.sendWebRtcAnswer, (accountId, data) => {
-    const { receiverId, ...partialData } = data;
-    sendWebRtcData(WebSocketMessageType.onWebRtcAnswer, partialData, accountId, receiverId);
+    sendWebRtcData(WebSocketMessageType.onWebRtcDescription, partialData, accountId, receiverId);
   });
 
   webSocketServer.bind(WebSocketMessageType.sendWebRtcIceCandidate, (accountId, data) => {