Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve device errors #530

Merged
merged 7 commits into from
May 28, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
}
`);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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:';
Expand Down
Original file line number Diff line number Diff line change
@@ -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<any>;
const mockIsPermissionDenied = isPermissionDenied as jest.Mock<Promise<boolean>>;

describe('the useLocalTracks hook', () => {
beforeEach(() => {
Expand All @@ -18,6 +19,7 @@ describe('the useLocalTracks hook', () => {
hasVideoInputDevices: true,
})
);
mockIsPermissionDenied.mockImplementation(() => Promise.resolve(false));
});
afterEach(jest.clearAllMocks);
afterEach(() => window.localStorage.clear());
Expand All @@ -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');
Expand Down
31 changes: 28 additions & 3 deletions src/components/VideoProvider/useLocalTracks/useLocalTracks.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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)
Expand All @@ -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]);
Expand Down
32 changes: 31 additions & 1 deletion src/utils/index.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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);
});
});
15 changes: 15 additions & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}