From ec80cbc704d3c0c92a360916f4d8729f74a828db Mon Sep 17 00:00:00 2001 From: Tim Mendoza Date: Tue, 25 May 2021 09:05:46 -0600 Subject: [PATCH 1/7] Add isPermissionDenied function --- src/utils/index.test.ts | 32 +++++++++++++++++++++++++++++++- src/utils/index.ts | 15 +++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) 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; + } +} From c3533c353d62409546ef9803f0a128d369363eed Mon Sep 17 00:00:00 2001 From: Tim Mendoza Date: Tue, 25 May 2021 09:14:06 -0600 Subject: [PATCH 2/7] Add isPermissionDenied to useLocalTracks --- .../useLocalTracks/useLocalTracks.ts | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/components/VideoProvider/useLocalTracks/useLocalTracks.ts b/src/components/VideoProvider/useLocalTracks/useLocalTracks.ts index 30090a919..ecc71e65d 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,15 @@ export default function useLocalTracks() { if (newAudioTrack) { setAudioTrack(newAudioTrack); } + + // These custom errors will be picked up by the MediaErrorSnackbar component. + if (isCameraPermissionDenied) { + throw new Error('CameraPermissionsDenied'); + } + + if (isMicrophonePermissionDenied) { + throw new Error('MicrophonePermissionsDenied'); + } }) .finally(() => setIsAcquiringLocalTracks(false)); }, [audioTrack, videoTrack, isAcquiringLocalTracks]); From 799b24995fbb38472ca8f5200c48e78fd47190ec Mon Sep 17 00:00:00 2001 From: Tim Mendoza Date: Tue, 25 May 2021 09:14:23 -0600 Subject: [PATCH 3/7] Add new errors to MediaErrorSnackbar --- .../MediaErrorSnackbar.test.tsx | 22 +++++++++++++++++++ .../MediaErrorSnackbar/MediaErrorSnackbar.tsx | 11 ++++++++++ 2 files changed, 33 insertions(+) diff --git a/src/components/PreJoinScreens/MediaErrorSnackbar/MediaErrorSnackbar.test.tsx b/src/components/PreJoinScreens/MediaErrorSnackbar/MediaErrorSnackbar.test.tsx index be7b66938..6894906ec 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": "", + "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": "", + "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..6cd775fb5 100644 --- a/src/components/PreJoinScreens/MediaErrorSnackbar/MediaErrorSnackbar.tsx +++ b/src/components/PreJoinScreens/MediaErrorSnackbar/MediaErrorSnackbar.tsx @@ -8,6 +8,17 @@ 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': + message = + 'The user has denied permission to use video. Please grant permission to the browser to access the camera.'; + break; + case error?.message === 'MicrophonePermissionsDenied': + 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:'; From 4d73ab0285b2aaa313de4db1f23a2894bb5fb57a Mon Sep 17 00:00:00 2001 From: Tim Mendoza Date: Tue, 25 May 2021 09:51:08 -0600 Subject: [PATCH 4/7] Add tests to useLocalTracks --- .../useLocalTracks/useLocalTracks.test.tsx | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/src/components/VideoProvider/useLocalTracks/useLocalTracks.test.tsx b/src/components/VideoProvider/useLocalTracks/useLocalTracks.test.tsx index f3656eb3d..503f01f91 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,39 @@ 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 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'); From 4d8b1c5aaa77c43d7b1ab740803f769f06803cae Mon Sep 17 00:00:00 2001 From: Tim Mendoza Date: Tue, 25 May 2021 10:17:57 -0600 Subject: [PATCH 5/7] Improve mediaError headlines --- .../MediaErrorSnackbar/MediaErrorSnackbar.test.tsx | 4 ++-- .../PreJoinScreens/MediaErrorSnackbar/MediaErrorSnackbar.tsx | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/PreJoinScreens/MediaErrorSnackbar/MediaErrorSnackbar.test.tsx b/src/components/PreJoinScreens/MediaErrorSnackbar/MediaErrorSnackbar.test.tsx index 6894906ec..745f5f9bf 100644 --- a/src/components/PreJoinScreens/MediaErrorSnackbar/MediaErrorSnackbar.test.tsx +++ b/src/components/PreJoinScreens/MediaErrorSnackbar/MediaErrorSnackbar.test.tsx @@ -146,7 +146,7 @@ describe('the getSnackbarContent function', () => { const results = getSnackbarContent(true, true, error); expect(results).toMatchInlineSnapshot(` Object { - "headline": "", + "headline": "Unable to Access Media:", "message": "The user has denied permission to use video. Please grant permission to the browser to access the camera.", } `); @@ -157,7 +157,7 @@ describe('the getSnackbarContent function', () => { const results = getSnackbarContent(true, true, error); expect(results).toMatchInlineSnapshot(` Object { - "headline": "", + "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 6cd775fb5..baa44b971 100644 --- a/src/components/PreJoinScreens/MediaErrorSnackbar/MediaErrorSnackbar.tsx +++ b/src/components/PreJoinScreens/MediaErrorSnackbar/MediaErrorSnackbar.tsx @@ -11,10 +11,12 @@ export function getSnackbarContent(hasAudio: boolean, hasVideo: boolean, error?: // 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; From ebc59c7dff6a0736ca7445fd6c5b7d56763d91f4 Mon Sep 17 00:00:00 2001 From: Tim Mendoza Date: Thu, 27 May 2021 12:18:40 -0600 Subject: [PATCH 6/7] Handle case where both audio and video have been explicitly denied --- .../useLocalTracks/useLocalTracks.test.tsx | 17 +++++++++++++++++ .../useLocalTracks/useLocalTracks.ts | 6 ++++++ 2 files changed, 23 insertions(+) diff --git a/src/components/VideoProvider/useLocalTracks/useLocalTracks.test.tsx b/src/components/VideoProvider/useLocalTracks/useLocalTracks.test.tsx index 503f01f91..5ebb77979 100644 --- a/src/components/VideoProvider/useLocalTracks/useLocalTracks.test.tsx +++ b/src/components/VideoProvider/useLocalTracks/useLocalTracks.test.tsx @@ -76,6 +76,23 @@ describe('the useLocalTracks hook', () => { }); }); + it('should not create any tracks when microphone and camera permissions have been denied', async () => { + mockIsPermissionDenied.mockImplementation(name => 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 ecc71e65d..42bc14fd0 100644 --- a/src/components/VideoProvider/useLocalTracks/useLocalTracks.ts +++ b/src/components/VideoProvider/useLocalTracks/useLocalTracks.ts @@ -111,6 +111,12 @@ export default function useLocalTracks() { } // 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'); } From 400f003e58ab14dab41ba34813c664c9c7bd6bd8 Mon Sep 17 00:00:00 2001 From: Tim Mendoza Date: Thu, 27 May 2021 14:22:53 -0600 Subject: [PATCH 7/7] Remove unused variable in test --- .../VideoProvider/useLocalTracks/useLocalTracks.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/VideoProvider/useLocalTracks/useLocalTracks.test.tsx b/src/components/VideoProvider/useLocalTracks/useLocalTracks.test.tsx index 5ebb77979..4b2bb2b1c 100644 --- a/src/components/VideoProvider/useLocalTracks/useLocalTracks.test.tsx +++ b/src/components/VideoProvider/useLocalTracks/useLocalTracks.test.tsx @@ -77,7 +77,7 @@ describe('the useLocalTracks hook', () => { }); it('should not create any tracks when microphone and camera permissions have been denied', async () => { - mockIsPermissionDenied.mockImplementation(name => Promise.resolve(true)); + mockIsPermissionDenied.mockImplementation(() => Promise.resolve(true)); const { result } = renderHook(useLocalTracks); const expectedError = new Error();