diff --git a/src/components/PreJoinScreens/MediaErrorSnackbar/MediaErrorSnackbar.test.tsx b/src/components/PreJoinScreens/MediaErrorSnackbar/MediaErrorSnackbar.test.tsx index be7b66938..745f5f9bf 100644 --- a/src/components/PreJoinScreens/MediaErrorSnackbar/MediaErrorSnackbar.test.tsx +++ b/src/components/PreJoinScreens/MediaErrorSnackbar/MediaErrorSnackbar.test.tsx @@ -140,4 +140,26 @@ describe('the getSnackbarContent function', () => { } `); }); + + it('should return the correct content when there is a CameraPermissionsDenied error', () => { + const error = new Error('CameraPermissionsDenied'); + const results = getSnackbarContent(true, true, error); + expect(results).toMatchInlineSnapshot(` + Object { + "headline": "Unable to Access Media:", + "message": "The user has denied permission to use video. Please grant permission to the browser to access the camera.", + } + `); + }); + + it('should return the correct content when there is a MicrophonePermissionsDenied error', () => { + const error = new Error('MicrophonePermissionsDenied'); + const results = getSnackbarContent(true, true, error); + expect(results).toMatchInlineSnapshot(` + Object { + "headline": "Unable to Access Media:", + "message": "The user has denied permission to use audio. Please grant permission to the browser to access the microphone.", + } + `); + }); }); diff --git a/src/components/PreJoinScreens/MediaErrorSnackbar/MediaErrorSnackbar.tsx b/src/components/PreJoinScreens/MediaErrorSnackbar/MediaErrorSnackbar.tsx index 55e29e17b..baa44b971 100644 --- a/src/components/PreJoinScreens/MediaErrorSnackbar/MediaErrorSnackbar.tsx +++ b/src/components/PreJoinScreens/MediaErrorSnackbar/MediaErrorSnackbar.tsx @@ -8,6 +8,19 @@ export function getSnackbarContent(hasAudio: boolean, hasVideo: boolean, error?: let message = ''; switch (true) { + // These custom errors are thrown by the useLocalTracks hook. They are thrown when the user explicitly denies + // permission to only their camera, or only their microphone. + case error?.message === 'CameraPermissionsDenied': + headline = 'Unable to Access Media:'; + message = + 'The user has denied permission to use video. Please grant permission to the browser to access the camera.'; + break; + case error?.message === 'MicrophonePermissionsDenied': + headline = 'Unable to Access Media:'; + message = + 'The user has denied permission to use audio. Please grant permission to the browser to access the microphone.'; + break; + // This error is emitted when the user or the user's system has denied permission to use the media devices case error?.name === 'NotAllowedError': headline = 'Unable to Access Media:'; diff --git a/src/components/VideoProvider/useLocalTracks/useLocalTracks.test.tsx b/src/components/VideoProvider/useLocalTracks/useLocalTracks.test.tsx index f3656eb3d..4b2bb2b1c 100644 --- a/src/components/VideoProvider/useLocalTracks/useLocalTracks.test.tsx +++ b/src/components/VideoProvider/useLocalTracks/useLocalTracks.test.tsx @@ -1,11 +1,12 @@ import { act, renderHook } from '@testing-library/react-hooks'; -import { getDeviceInfo } from '../../../utils'; +import { getDeviceInfo, isPermissionDenied } from '../../../utils'; import { SELECTED_AUDIO_INPUT_KEY, SELECTED_VIDEO_INPUT_KEY, DEFAULT_VIDEO_CONSTRAINTS } from '../../../constants'; import useLocalTracks from './useLocalTracks'; import Video from 'twilio-video'; jest.mock('../../../utils'); const mockGetDeviceInfo = getDeviceInfo as jest.Mock; +const mockIsPermissionDenied = isPermissionDenied as jest.Mock>; describe('the useLocalTracks hook', () => { beforeEach(() => { @@ -18,6 +19,7 @@ describe('the useLocalTracks hook', () => { hasVideoInputDevices: true, }) ); + mockIsPermissionDenied.mockImplementation(() => Promise.resolve(false)); }); afterEach(jest.clearAllMocks); afterEach(() => window.localStorage.clear()); @@ -41,6 +43,56 @@ describe('the useLocalTracks hook', () => { }); }); + it('should not create a local video track when camera permission has been denied', async () => { + mockIsPermissionDenied.mockImplementation(name => Promise.resolve(name === 'camera')); + const { result } = renderHook(useLocalTracks); + + await act(async () => { + await expect(result.current.getAudioAndVideoTracks()).rejects.toThrow('CameraPermissionsDenied'); + }); + + expect(Video.createLocalTracks).toHaveBeenCalledWith({ + audio: true, + video: false, + }); + }); + + it('should not create a local audio track when microphone permission has been denied', async () => { + mockIsPermissionDenied.mockImplementation(name => Promise.resolve(name === 'microphone')); + const { result } = renderHook(useLocalTracks); + + await act(async () => { + await expect(result.current.getAudioAndVideoTracks()).rejects.toThrow('MicrophonePermissionsDenied'); + }); + + expect(Video.createLocalTracks).toHaveBeenCalledWith({ + audio: false, + video: { + frameRate: 24, + width: 1280, + height: 720, + name: 'camera-123456', + }, + }); + }); + + it('should not create any tracks when microphone and camera permissions have been denied', async () => { + mockIsPermissionDenied.mockImplementation(() => Promise.resolve(true)); + const { result } = renderHook(useLocalTracks); + + const expectedError = new Error(); + expectedError.name = 'NotAllowedError'; + + await act(async () => { + await expect(result.current.getAudioAndVideoTracks()).rejects.toThrow(expectedError); + }); + + expect(Video.createLocalTracks).toHaveBeenCalledWith({ + audio: false, + video: false, + }); + }); + it('should correctly create local audio and video tracks when selected device IDs are available in localStorage', async () => { window.localStorage.setItem(SELECTED_VIDEO_INPUT_KEY, 'mockVideoDeviceId'); window.localStorage.setItem(SELECTED_AUDIO_INPUT_KEY, 'mockAudioDeviceId'); diff --git a/src/components/VideoProvider/useLocalTracks/useLocalTracks.ts b/src/components/VideoProvider/useLocalTracks/useLocalTracks.ts index 30090a919..42bc14fd0 100644 --- a/src/components/VideoProvider/useLocalTracks/useLocalTracks.ts +++ b/src/components/VideoProvider/useLocalTracks/useLocalTracks.ts @@ -1,5 +1,5 @@ import { DEFAULT_VIDEO_CONSTRAINTS, SELECTED_AUDIO_INPUT_KEY, SELECTED_VIDEO_INPUT_KEY } from '../../../constants'; -import { getDeviceInfo } from '../../../utils'; +import { getDeviceInfo, isPermissionDenied } from '../../../utils'; import { useCallback, useState } from 'react'; import Video, { LocalVideoTrack, LocalAudioTrack, CreateLocalTrackOptions } from 'twilio-video'; @@ -74,13 +74,23 @@ export default function useLocalTracks() { device => selectedVideoDeviceId && device.deviceId === selectedVideoDeviceId ); + // In Chrome, it is possible to deny permissions to only audio or only video. + // If that has happened, then we don't want to attempt to acquire the device. + const isCameraPermissionDenied = await isPermissionDenied('camera'); + const isMicrophonePermissionDenied = await isPermissionDenied('microphone'); + + const shouldAcquireVideo = hasVideoInputDevices && !isCameraPermissionDenied; + const shouldAcquireAudio = hasAudioInputDevices && !isMicrophonePermissionDenied; + const localTrackConstraints = { - video: hasVideoInputDevices && { + video: shouldAcquireVideo && { ...(DEFAULT_VIDEO_CONSTRAINTS as {}), name: `camera-${Date.now()}`, ...(hasSelectedVideoDevice && { deviceId: { exact: selectedVideoDeviceId! } }), }, - audio: hasSelectedAudioDevice ? { deviceId: { exact: selectedAudioDeviceId! } } : hasAudioInputDevices, + audio: + shouldAcquireAudio && + (hasSelectedAudioDevice ? { deviceId: { exact: selectedAudioDeviceId! } } : hasAudioInputDevices), }; return Video.createLocalTracks(localTrackConstraints) @@ -99,6 +109,21 @@ export default function useLocalTracks() { if (newAudioTrack) { setAudioTrack(newAudioTrack); } + + // These custom errors will be picked up by the MediaErrorSnackbar component. + if (isCameraPermissionDenied && isMicrophonePermissionDenied) { + const error = new Error(); + error.name = 'NotAllowedError'; + throw error; + } + + if (isCameraPermissionDenied) { + throw new Error('CameraPermissionsDenied'); + } + + if (isMicrophonePermissionDenied) { + throw new Error('MicrophonePermissionsDenied'); + } }) .finally(() => setIsAcquiringLocalTracks(false)); }, [audioTrack, videoTrack, isAcquiringLocalTracks]); diff --git a/src/utils/index.test.ts b/src/utils/index.test.ts index 32ec6da17..d3fbff70c 100644 --- a/src/utils/index.test.ts +++ b/src/utils/index.test.ts @@ -1,4 +1,4 @@ -import { getDeviceInfo, removeUndefineds } from '.'; +import { getDeviceInfo, isPermissionDenied, removeUndefineds } from '.'; describe('the removeUndefineds function', () => { it('should recursively remove any object keys with a value of undefined', () => { @@ -95,3 +95,33 @@ describe('the getDeviceInfo function', () => { expect(result.hasVideoInputDevices).toBe(false); }); }); + +describe('the isPermissionsDenied function', () => { + it('should return false when navigator.permissions does not exist', () => { + // @ts-ignore + navigator.permissions = undefined; + + expect(isPermissionDenied('camera')).resolves.toBe(false); + }); + + it('should return false when navigator.permissions.query throws an error', () => { + // @ts-ignore + navigator.permissions = { query: () => Promise.reject() }; + + expect(isPermissionDenied('camera')).resolves.toBe(false); + }); + + it('should return false when navigator.permissions.query returns "granted"', () => { + // @ts-ignore + navigator.permissions = { query: () => Promise.resolve({ state: 'granted' }) }; + + expect(isPermissionDenied('camera')).resolves.toBe(false); + }); + + it('should return true when navigator.permissions.query returns "denied"', () => { + // @ts-ignore + navigator.permissions = { query: () => Promise.resolve({ state: 'denied' }) }; + + expect(isPermissionDenied('camera')).resolves.toBe(true); + }); +}); diff --git a/src/utils/index.ts b/src/utils/index.ts index 4bfc826c3..4e951891e 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -34,3 +34,18 @@ export async function getDeviceInfo() { hasVideoInputDevices: devices.some(device => device.kind === 'videoinput'), }; } + +// This function will return 'true' when the specified permission has been denied by the user. +// If the API doesn't exist, or the query function returns an error, 'false' will be returned. +export async function isPermissionDenied(name: PermissionName) { + if (navigator.permissions) { + try { + const result = await navigator.permissions.query({ name }); + return result.state === 'denied'; + } catch { + return false; + } + } else { + return false; + } +}