Improve permission handling in call flow

Improve permission handling by asking the user to give mic and camera permissions before sending `CallBegin` or `CallAccept` for the caller and receiver respectively.
Followed the flow described here: https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Connectivity#session_descriptions

CallProvider:

- Change functions order to place listeners under the function that sends the corresponding WebSocket message.
- Replace `Default` CallStatus with `Loading` for when asking user permissions before sending the `CallBegin`/`CallAccept` message.
- Remove `localStream` and `remoteStream` from `CallContext`. They are now available only in `WebRtcContext`.
- Replace `setAudioStatus` and `setVideoStatus` with `setIsAudioOn` and `setIsVideoOn`. A `useEffect` is now used to disable the tracks when the audio/video status changes.

WebRtcProvider:

- Move WebRTC connection close logic to WebRtcProvider
- Remove `webRtcConnection` from `WebRtcContext`. `WebRtcProvider` is now in charge of setting everything related to the WebRTC Connection.

UI:

- Add `CallPermissionDenied` page for when permissions are denied.
- Rework `CallPending` to display `Loading...` when waiting for user permissions

Change-Id: I48153577cca4c73cdb9b81d2fa78cfdfe2e06d69
diff --git a/client/src/contexts/WebRtcProvider.tsx b/client/src/contexts/WebRtcProvider.tsx
index 63e2ca2..42ac308 100644
--- a/client/src/contexts/WebRtcProvider.tsx
+++ b/client/src/contexts/WebRtcProvider.tsx
@@ -28,17 +28,27 @@
 interface IWebRtcContext {
   iceConnectionState: RTCIceConnectionState | undefined;
 
+  mediaDevices: Record<MediaDeviceKind, MediaDeviceInfo[]>;
+  localStream: MediaStream | undefined;
   remoteStreams: readonly MediaStream[] | undefined;
-  webRtcConnection: RTCPeerConnection | undefined;
+  getUserMedia: () => Promise<void>;
 
-  sendWebRtcOffer: (sdp: RTCSessionDescriptionInit) => Promise<void>;
+  sendWebRtcOffer: () => Promise<void>;
+  closeConnection: () => void;
 }
 
 const defaultWebRtcContext: IWebRtcContext = {
   iceConnectionState: undefined,
+  mediaDevices: {
+    audioinput: [],
+    audiooutput: [],
+    videoinput: [],
+  },
+  localStream: undefined,
   remoteStreams: undefined,
-  webRtcConnection: undefined,
+  getUserMedia: async () => {},
   sendWebRtcOffer: async () => {},
+  closeConnection: () => {},
 };
 
 export const WebRtcContext = createContext<IWebRtcContext>(defaultWebRtcContext);
@@ -66,7 +76,7 @@
         });
       }
 
-      setWebRtcConnection(new RTCPeerConnection({ iceServers: iceServers }));
+      setWebRtcConnection(new RTCPeerConnection({ iceServers }));
     }
   }, [account, webRtcConnection]);
 
@@ -90,40 +100,117 @@
   webSocket: IWebSocketContext;
 }) => {
   const { conversation, conversationId } = useContext(ConversationContext);
+  const [localStream, setLocalStream] = useState<MediaStream>();
   const [remoteStreams, setRemoteStreams] = useState<readonly MediaStream[]>();
   const [iceConnectionState, setIceConnectionState] = useState<RTCIceConnectionState | undefined>();
+  const [mediaDevices, setMediaDevices] = useState<Record<MediaDeviceKind, MediaDeviceInfo[]>>(
+    defaultWebRtcContext.mediaDevices
+  );
 
   // TODO: This logic will have to change to support multiple people in a call
   const contactUri = useMemo(() => conversation.getFirstMember().contact.getUri(), [conversation]);
 
-  const sendWebRtcOffer = useCallback(
-    async (sdp: RTCSessionDescriptionInit) => {
-      const webRtcOffer: WebRtcSdp = {
-        contactId: contactUri,
-        conversationId: conversationId,
-        sdp,
+  const getMediaDevices = useCallback(async () => {
+    try {
+      const devices = await navigator.mediaDevices.enumerateDevices();
+      const newMediaDevices: Record<MediaDeviceKind, MediaDeviceInfo[]> = {
+        audioinput: [],
+        audiooutput: [],
+        videoinput: [],
       };
 
-      await webRtcConnection.setLocalDescription(new RTCSessionDescription(sdp));
-      console.info('Sending WebRtcOffer', webRtcOffer);
-      webSocket.send(WebSocketMessageType.WebRtcOffer, webRtcOffer);
-    },
-    [webRtcConnection, webSocket, conversationId, contactUri]
-  );
+      for (const device of devices) {
+        newMediaDevices[device.kind].push(device);
+      }
 
-  const sendWebRtcAnswer = useCallback(
-    (sdp: RTCSessionDescriptionInit) => {
-      const webRtcAnswer: WebRtcSdp = {
-        contactId: contactUri,
-        conversationId: conversationId,
-        sdp,
-      };
+      return newMediaDevices;
+    } catch (e) {
+      throw new Error('Could not get media devices', { cause: e });
+    }
+  }, []);
 
-      console.info('Sending WebRtcAnswer', webRtcAnswer);
-      webSocket.send(WebSocketMessageType.WebRtcAnswer, webRtcAnswer);
-    },
-    [contactUri, conversationId, webSocket]
-  );
+  useEffect(() => {
+    if (iceConnectionState !== 'connected' && iceConnectionState !== 'completed') {
+      return;
+    }
+
+    const updateMediaDevices = async () => {
+      try {
+        const newMediaDevices = await getMediaDevices();
+        setMediaDevices(newMediaDevices);
+      } catch (e) {
+        console.error('Could not update media devices:', e);
+      }
+    };
+
+    navigator.mediaDevices.addEventListener('devicechange', updateMediaDevices);
+    updateMediaDevices();
+
+    return () => {
+      navigator.mediaDevices.removeEventListener('devicechange', updateMediaDevices);
+    };
+  }, [getMediaDevices, iceConnectionState]);
+
+  const getUserMedia = useCallback(async () => {
+    const devices = await getMediaDevices();
+
+    const shouldGetAudio = devices.audioinput.length !== 0;
+    const shouldGetVideo = devices.videoinput.length !== 0;
+
+    if (!shouldGetAudio && !shouldGetVideo) {
+      return;
+    }
+
+    try {
+      const stream = await navigator.mediaDevices.getUserMedia({
+        audio: shouldGetAudio,
+        video: shouldGetVideo,
+      });
+
+      for (const track of stream.getTracks()) {
+        track.enabled = false;
+        webRtcConnection.addTrack(track, stream);
+      }
+
+      setLocalStream(stream);
+    } catch (e) {
+      throw new Error('Could not get media devices', { cause: e });
+    }
+  }, [webRtcConnection, getMediaDevices]);
+
+  const sendWebRtcOffer = useCallback(async () => {
+    const sdp = await webRtcConnection.createOffer({
+      offerToReceiveAudio: true,
+      offerToReceiveVideo: true,
+    });
+
+    const webRtcOffer: WebRtcSdp = {
+      contactId: contactUri,
+      conversationId: conversationId,
+      sdp,
+    };
+
+    await webRtcConnection.setLocalDescription(new RTCSessionDescription(sdp));
+    console.info('Sending WebRtcOffer', webRtcOffer);
+    webSocket.send(WebSocketMessageType.WebRtcOffer, webRtcOffer);
+  }, [webRtcConnection, webSocket, conversationId, contactUri]);
+
+  const sendWebRtcAnswer = useCallback(async () => {
+    const sdp = await webRtcConnection.createAnswer({
+      offerToReceiveAudio: true,
+      offerToReceiveVideo: true,
+    });
+
+    const webRtcAnswer: WebRtcSdp = {
+      contactId: contactUri,
+      conversationId: conversationId,
+      sdp,
+    };
+
+    await webRtcConnection.setLocalDescription(new RTCSessionDescription(sdp));
+    console.info('Sending WebRtcAnswer', webRtcAnswer);
+    webSocket.send(WebSocketMessageType.WebRtcAnswer, webRtcAnswer);
+  }, [contactUri, conversationId, webRtcConnection, webSocket]);
 
   /* WebSocket Listeners */
 
@@ -136,13 +223,7 @@
       }
 
       await webRtcConnection.setRemoteDescription(new RTCSessionDescription(data.sdp));
-
-      const sdp = await webRtcConnection.createAnswer({
-        offerToReceiveAudio: true,
-        offerToReceiveVideo: true,
-      });
-      await webRtcConnection.setLocalDescription(new RTCSessionDescription(sdp));
-      sendWebRtcAnswer(sdp);
+      await sendWebRtcAnswer();
     };
 
     const webRtcAnswerListener = async (data: WebRtcSdp) => {
@@ -154,6 +235,7 @@
 
       await webRtcConnection.setRemoteDescription(new RTCSessionDescription(data.sdp));
     };
+
     webSocket.bind(WebSocketMessageType.WebRtcOffer, webRtcOfferListener);
     webSocket.bind(WebSocketMessageType.WebRtcAnswer, webRtcAnswerListener);
 
@@ -194,7 +276,6 @@
         webSocket.send(WebSocketMessageType.WebRtcIceCandidate, webRtcIceCandidate);
       }
     };
-
     webRtcConnection.addEventListener('icecandidate', iceCandidateEventListener);
 
     return () => {
@@ -208,8 +289,8 @@
       setRemoteStreams(event.streams);
     };
 
-    const iceConnectionStateChangeEventListener = () => {
-      console.info('ICE connection state changed:', webRtcConnection.iceConnectionState);
+    const iceConnectionStateChangeEventListener = (event: Event) => {
+      console.info(`Received WebRTC event on iceconnectionstatechange: ${webRtcConnection.iceConnectionState}`, event);
       setIceConnectionState(webRtcConnection.iceConnectionState);
     };
 
@@ -222,13 +303,27 @@
     };
   }, [webRtcConnection]);
 
+  const closeConnection = useCallback(() => {
+    const localTracks = localStream?.getTracks();
+    if (localTracks) {
+      for (const track of localTracks) {
+        track.stop();
+      }
+    }
+
+    webRtcConnection.close();
+  }, [webRtcConnection, localStream]);
+
   return (
     <WebRtcContext.Provider
       value={{
         iceConnectionState,
+        mediaDevices,
+        localStream,
         remoteStreams,
-        webRtcConnection,
+        getUserMedia,
         sendWebRtcOffer,
+        closeConnection,
       }}
     >
       {children}