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/CallProvider.tsx b/client/src/contexts/CallProvider.tsx
index 8db38e0..31b6673 100644
--- a/client/src/contexts/CallProvider.tsx
+++ b/client/src/contexts/CallProvider.tsx
@@ -21,6 +21,7 @@
import LoadingPage from '../components/Loading';
import { useUrlParams } from '../hooks/useUrlParams';
+import CallPermissionDenied from '../pages/CallPermissionDenied';
import { CallRouteParams } from '../router';
import { callTimeoutMs } from '../utils/constants';
import { SetState, WithChildren } from '../utils/utils';
@@ -32,21 +33,18 @@
export enum CallStatus {
Default,
+ Loading,
Ringing,
Connecting,
InCall,
+ PermissionsDenied,
}
export interface ICallContext {
- mediaDevices: Record<MediaDeviceKind, MediaDeviceInfo[]>;
-
- localStream: MediaStream | undefined;
- remoteStream: MediaStream | undefined; // TODO: should be an array of participants. find a way to map MediaStream id to contactid https://stackoverflow.com/a/68663155/6592293
-
isAudioOn: boolean;
- setAudioStatus: (isOn: boolean) => void;
+ setIsAudioOn: SetState<boolean>;
isVideoOn: boolean;
- setVideoStatus: (isOn: boolean) => void;
+ setIsVideoOn: SetState<boolean>;
isChatShown: boolean;
setIsChatShown: SetState<boolean>;
isFullscreen: boolean;
@@ -60,19 +58,10 @@
}
const defaultCallContext: ICallContext = {
- mediaDevices: {
- audioinput: [],
- audiooutput: [],
- videoinput: [],
- },
-
- localStream: undefined,
- remoteStream: undefined,
-
isAudioOn: false,
- setAudioStatus: () => {},
+ setIsAudioOn: () => {},
isVideoOn: false,
- setVideoStatus: () => {},
+ setIsVideoOn: () => {},
isChatShown: false,
setIsChatShown: () => {},
isFullscreen: false,
@@ -89,37 +78,25 @@
export default ({ children }: WithChildren) => {
const webSocket = useContext(WebSocketContext);
- const { webRtcConnection } = useContext(WebRtcContext);
- if (!webSocket || !webRtcConnection) {
+ if (!webSocket) {
return <LoadingPage />;
}
- return (
- <CallProvider webSocket={webSocket} webRtcConnection={webRtcConnection}>
- {children}
- </CallProvider>
- );
+ return <CallProvider webSocket={webSocket}>{children}</CallProvider>;
};
const CallProvider = ({
children,
webSocket,
- webRtcConnection,
}: WithChildren & {
webSocket: IWebSocketContext;
- webRtcConnection: RTCPeerConnection;
}) => {
const { state: routeState } = useUrlParams<CallRouteParams>();
- const { remoteStreams, sendWebRtcOffer, iceConnectionState } = useContext(WebRtcContext);
+ const { localStream, sendWebRtcOffer, iceConnectionState, closeConnection, getUserMedia } = useContext(WebRtcContext);
const { conversationId, conversation } = useContext(ConversationContext);
const navigate = useNavigate();
- const [mediaDevices, setMediaDevices] = useState<Record<MediaDeviceKind, MediaDeviceInfo[]>>(
- defaultCallContext.mediaDevices
- );
- const [localStream, setLocalStream] = useState<MediaStream>();
-
const [isAudioOn, setIsAudioOn] = useState(false);
const [isVideoOn, setIsVideoOn] = useState(false);
const [isChatShown, setIsChatShown] = useState(false);
@@ -134,96 +111,20 @@
const contactUri = useMemo(() => conversation.getFirstMember().contact.getUri(), [conversation]);
useEffect(() => {
- try {
- // TODO: Wait until status is `InCall` before getting devices
- navigator.mediaDevices.enumerateDevices().then((devices) => {
- const newMediaDevices: Record<MediaDeviceKind, MediaDeviceInfo[]> = {
- audioinput: [],
- audiooutput: [],
- videoinput: [],
- };
-
- for (const device of devices) {
- newMediaDevices[device.kind].push(device);
- }
-
- setMediaDevices(newMediaDevices);
- });
- } catch (e) {
- console.error('Could not get media devices:', e);
+ if (localStream) {
+ for (const track of localStream.getAudioTracks()) {
+ track.enabled = isAudioOn;
+ }
}
-
- try {
- navigator.mediaDevices
- .getUserMedia({
- audio: true, // TODO: Set both to false by default
- video: true,
- })
- .then((stream) => {
- for (const track of stream.getTracks()) {
- // TODO: Set default from isVideoOn and isMicOn values
- track.enabled = false;
- }
- setLocalStream(stream);
- });
- } catch (e) {
- // TODO: Better handle user denial
- console.error('Could not get media devices:', e);
- }
- }, []);
+ }, [isAudioOn, localStream]);
useEffect(() => {
if (localStream) {
- for (const track of localStream.getTracks()) {
- webRtcConnection.addTrack(track, localStream);
- }
- }
- }, [localStream, webRtcConnection]);
-
- const setAudioStatus = useCallback(
- (isOn: boolean) => {
- if (!localStream) {
- return;
- }
-
- for (const track of localStream.getAudioTracks()) {
- track.enabled = isOn;
- }
-
- setIsAudioOn(isOn);
- },
- [localStream]
- );
-
- const setVideoStatus = useCallback(
- (isOn: boolean) => {
- if (!localStream) {
- return;
- }
-
for (const track of localStream.getVideoTracks()) {
- track.enabled = isOn;
+ track.enabled = isVideoOn;
}
-
- setIsVideoOn(isOn);
- },
- [localStream]
- );
-
- useEffect(() => {
- if (callRole === 'caller' && callStatus === CallStatus.Default) {
- const callBegin: CallBegin = {
- contactId: contactUri,
- conversationId,
- withVideoOn: routeState?.isVideoOn ?? false,
- };
-
- console.info('Sending CallBegin', callBegin);
- webSocket.send(WebSocketMessageType.CallBegin, callBegin);
- setCallStatus(CallStatus.Ringing);
- setIsVideoOn(routeState?.isVideoOn ?? false);
}
- }, [webSocket, callRole, callStatus, contactUri, conversationId, routeState]);
+ }, [isVideoOn, localStream]);
useEffect(() => {
const onFullscreenChange = () => {
@@ -237,6 +138,53 @@
}, []);
useEffect(() => {
+ if (callRole === 'caller' && callStatus === CallStatus.Default) {
+ setCallStatus(CallStatus.Loading);
+ getUserMedia()
+ .then(() => {
+ const callBegin: CallBegin = {
+ contactId: contactUri,
+ conversationId,
+ withVideoOn: routeState?.isVideoOn ?? false,
+ };
+
+ setCallStatus(CallStatus.Ringing);
+ setIsVideoOn(routeState?.isVideoOn ?? false);
+ console.info('Sending CallBegin', callBegin);
+ webSocket.send(WebSocketMessageType.CallBegin, callBegin);
+ })
+ .catch((e) => {
+ console.error(e);
+ setCallStatus(CallStatus.PermissionsDenied);
+ });
+ }
+ }, [webSocket, getUserMedia, callRole, callStatus, contactUri, conversationId, routeState]);
+
+ const acceptCall = useCallback(
+ (withVideoOn: boolean) => {
+ setCallStatus(CallStatus.Loading);
+
+ getUserMedia()
+ .then(() => {
+ const callAccept: CallAction = {
+ contactId: contactUri,
+ conversationId,
+ };
+
+ setIsVideoOn(withVideoOn);
+ setCallStatus(CallStatus.Connecting);
+ console.info('Sending CallAccept', callAccept);
+ webSocket.send(WebSocketMessageType.CallAccept, callAccept);
+ })
+ .catch((e) => {
+ console.error(e);
+ setCallStatus(CallStatus.PermissionsDenied);
+ });
+ },
+ [webSocket, getUserMedia, contactUri, conversationId]
+ );
+
+ useEffect(() => {
if (callRole === 'caller' && callStatus === CallStatus.Ringing) {
const callAcceptListener = (data: CallAction) => {
console.info('Received event on CallAccept', data);
@@ -247,14 +195,7 @@
setCallStatus(CallStatus.Connecting);
- webRtcConnection
- .createOffer({
- offerToReceiveAudio: true,
- offerToReceiveVideo: true,
- })
- .then((sdp) => {
- sendWebRtcOffer(sdp);
- });
+ sendWebRtcOffer();
};
webSocket.bind(WebSocketMessageType.CallAccept, callAcceptListener);
@@ -263,19 +204,20 @@
webSocket.unbind(WebSocketMessageType.CallAccept, callAcceptListener);
};
}
- }, [callRole, webSocket, webRtcConnection, sendWebRtcOffer, callStatus, conversationId]);
+ }, [callRole, webSocket, sendWebRtcOffer, callStatus, conversationId]);
- const quitCall = useCallback(() => {
- const localTracks = localStream?.getTracks();
- if (localTracks) {
- for (const track of localTracks) {
- track.stop();
- }
- }
+ const endCall = useCallback(() => {
+ const callEnd: CallAction = {
+ contactId: contactUri,
+ conversationId,
+ };
- webRtcConnection.close();
+ console.info('Sending CallEnd', callEnd);
+ closeConnection();
+ webSocket.send(WebSocketMessageType.CallEnd, callEnd);
navigate(`/conversation/${conversationId}`);
- }, [webRtcConnection, localStream, navigate, conversationId]);
+ // TODO: write in chat that the call ended
+ }, [webSocket, contactUri, conversationId, closeConnection, navigate]);
useEffect(() => {
const callEndListener = (data: CallAction) => {
@@ -285,7 +227,8 @@
return;
}
- quitCall();
+ closeConnection();
+ navigate(`/conversation/${conversationId}`);
// TODO: write in chat that the call ended
};
@@ -293,7 +236,7 @@
return () => {
webSocket.unbind(WebSocketMessageType.CallEnd, callEndListener);
};
- }, [webSocket, navigate, conversationId, quitCall]);
+ }, [webSocket, navigate, conversationId, closeConnection]);
useEffect(() => {
if (
@@ -302,44 +245,16 @@
) {
console.info('Changing call status to InCall');
setCallStatus(CallStatus.InCall);
- setVideoStatus(isVideoOn);
setCallStartTime(new Date());
}
- }, [iceConnectionState, callStatus, setVideoStatus, isVideoOn]);
-
- const acceptCall = useCallback(
- (withVideoOn: boolean) => {
- const callAccept: CallAction = {
- contactId: contactUri,
- conversationId,
- };
-
- console.info('Sending CallAccept', callAccept);
- webSocket.send(WebSocketMessageType.CallAccept, callAccept);
- setIsVideoOn(withVideoOn);
- setCallStatus(CallStatus.Connecting);
- },
- [webSocket, contactUri, conversationId]
- );
-
- const endCall = useCallback(() => {
- const callEnd: CallAction = {
- contactId: contactUri,
- conversationId,
- };
-
- console.info('Sending CallEnd', callEnd);
- webSocket.send(WebSocketMessageType.CallEnd, callEnd);
- quitCall();
- // TODO: write in chat that the call ended
- }, [webSocket, contactUri, conversationId, quitCall]);
+ }, [iceConnectionState, callStatus]);
useEffect(() => {
if (iceConnectionState === 'disconnected' || iceConnectionState === 'failed') {
console.info('ICE connection disconnected or failed, ending call');
endCall();
}
- }, [iceConnectionState, callStatus, setVideoStatus, isVideoOn, endCall]);
+ }, [iceConnectionState, callStatus, isVideoOn, endCall]);
useEffect(() => {
const checkStatusTimeout = () => {
@@ -369,13 +284,10 @@
return (
<CallContext.Provider
value={{
- mediaDevices,
- localStream,
- remoteStream: remoteStreams?.at(-1),
isAudioOn,
- setAudioStatus,
+ setIsAudioOn,
isVideoOn,
- setVideoStatus,
+ setIsVideoOn,
isChatShown,
setIsChatShown,
isFullscreen,
@@ -387,7 +299,7 @@
endCall,
}}
>
- {children}
+ {callStatus === CallStatus.PermissionsDenied ? <CallPermissionDenied /> : children}
</CallContext.Provider>
);
};