diff --git a/packages/camera-web/src/Camera/hooks/useUserMedia.ts b/packages/camera-web/src/Camera/hooks/useUserMedia.ts index 30d1e4f26..55e847b17 100644 --- a/packages/camera-web/src/Camera/hooks/useUserMedia.ts +++ b/packages/camera-web/src/Camera/hooks/useUserMedia.ts @@ -39,6 +39,17 @@ export enum UserMediaErrorType { /** * The camera stream couldn't be fetched because the web page does not have the permissions to access the camera. */ + WEBPAGE_NOT_ALLOWED = 'webpage_not_allowed', + /** + * The camera stream couldn't be fetched because the camera permissions are not granted to the browser in the device + * settings. + */ + BROWSER_NOT_ALLOWED = 'browser_not_allowed', + /** + * The camera stream couldn't be fetched, but the app is unable to know if it is because of the website or + * the browser not being allowed to have camera permission access. This error is usually returned on Firefox and + * other similar browsers where `navigator.permissions.query` is not supported for videoinput devices. + */ NOT_ALLOWED = 'not_allowed', /** * The camera stream was successfully fetched, but it could be processed. This error can happen for the following @@ -197,6 +208,7 @@ export function useUserMedia( const { handleError } = useMonitoring(); const isActive = useRef(true); + let cameraPermissionState: PermissionState | null = null; useEffect(() => { return () => { isActive.current = false; @@ -206,7 +218,16 @@ export function useUserMedia( const handleGetUserMediaError = (err: unknown) => { let type = UserMediaErrorType.OTHER; if (err instanceof Error && err.name === 'NotAllowedError') { - type = UserMediaErrorType.NOT_ALLOWED; + switch (cameraPermissionState) { + case 'denied': + type = UserMediaErrorType.WEBPAGE_NOT_ALLOWED; + break; + case 'granted': + type = UserMediaErrorType.BROWSER_NOT_ALLOWED; + break; + default: + type = UserMediaErrorType.NOT_ALLOWED; + } } else if ( err instanceof Error && Object.values(InvalidStreamErrorName).includes(err.name as InvalidStreamErrorName) @@ -242,37 +263,56 @@ export function useUserMedia( setLastConstraintsApplied(constraints); const getUserMedia = async () => { + setIsLoading(true); + if (stream) { + stream.removeEventListener('inactive', onStreamInactive); + stream.getTracks().forEach((track) => track.stop()); + } + const deviceDetails = await analyzeCameraDevices(constraints); + const updatedConstraints = { + ...constraints, + video: { + ...(constraints ? (constraints.video as MediaTrackConstraints) : {}), + deviceId: { exact: deviceDetails.validDeviceIds }, + }, + }; + const str = await navigator.mediaDevices.getUserMedia(updatedConstraints); + str?.addEventListener('inactive', onStreamInactive); + if (isActive.current) { + setStream(str); + setDimensions(getStreamDimensions(str, true)); + setIsLoading(false); + setAvailableCameraDevices(deviceDetails.availableDevices); + setSelectedCameraDeviceId(getStreamDeviceId(str)); + } + }; + const getCameraPermissionState = async () => { try { - setIsLoading(true); - if (stream) { - stream.removeEventListener('inactive', onStreamInactive); - stream.getTracks().forEach((track) => track.stop()); - } - const deviceDetails = await analyzeCameraDevices(constraints); - const updatedConstraints = { - ...constraints, - video: { - ...(constraints ? (constraints.video as MediaTrackConstraints) : {}), - deviceId: { exact: deviceDetails.validDeviceIds }, - }, - }; - const str = await navigator.mediaDevices.getUserMedia(updatedConstraints); - str?.addEventListener('inactive', onStreamInactive); - if (isActive.current) { - setStream(str); - setDimensions(getStreamDimensions(str, true)); - setIsLoading(false); - setAvailableCameraDevices(deviceDetails.availableDevices); - setSelectedCameraDeviceId(getStreamDeviceId(str)); - } + return await navigator.permissions.query({ + name: 'camera' as PermissionName, + }); } catch (err) { - if (isActive.current) { + return null; + } + }; + getUserMedia() + .catch((err) => { + return Promise.all([err, getCameraPermissionState()]); + }) + .then((result) => { + if (!result) { + return Promise.all([null, getCameraPermissionState()]); + } + return result; + }) + .then(([err, cameraPermission]) => { + cameraPermissionState = cameraPermission?.state ?? null; + if (err && isActive.current) { handleGetUserMediaError(err); throw err; } - } - }; - getUserMedia().catch(handleError); + }) + .catch(handleError); }, [constraints, stream, error, isLoading, lastConstraintsApplied]); useEffect(() => { diff --git a/packages/camera-web/src/utils/errors.utils.ts b/packages/camera-web/src/utils/errors.utils.ts index d9d03e792..3295a2b78 100644 --- a/packages/camera-web/src/utils/errors.utils.ts +++ b/packages/camera-web/src/utils/errors.utils.ts @@ -14,6 +14,20 @@ export function getCameraErrorLabel(error?: UserMediaErrorType): TranslationObje de: 'Die Kameravorschau ist nicht verfügbar, da für die Seite kein Kamerazugriff gewährt wurde.', nl: 'De cameravoorbeeld is niet beschikbaar omdat er geen toegang tot de camera is verleend aan de pagina.', }; + case UserMediaErrorType.WEBPAGE_NOT_ALLOWED: + return { + en: 'Unable to get camera access. Make sure to press “Allow” when asked to grant camera permission for this web page.', + fr: "Impossible d'accéder à la caméra. Veuillez vous assurer d'appuyer sur “Autoriser” lorsqu'on vous propose d'autoriser l'accès à la caméra pour cette page web.", + de: 'Die Kamera kann nicht zugelassen werden. Stellen Sie sicher, dass Sie auf „Zulassen“ drücken, wenn Sie aufgefordert werden, die Kamera für diese Webseite zuzulassen.', + nl: 'Kan geen toestemming krijgen voor de camera. Zorg ervoor dat u op “Toestaan” drukt wanneer u wordt gevraagd om toestemming te geven voor het gebruik van de camera op deze webpagina.', + }; + case UserMediaErrorType.BROWSER_NOT_ALLOWED: + return { + en: "Unable to get camera access. Make sure to grant camera access to your current internet browser in your device's settings.", + fr: "Impossible d'accéder à la caméra. Veuillez vous assurer d'autoriser l'accès à la caméra pour ce navigateur internet dans les paramètres de votre téléphone.", + de: 'Der Zugriff auf die Kamera ist nicht möglich. Stellen Sie sicher, dass Sie in den Einstellungen Ihres Geräts den Kamerazugriff für Ihren aktuellen Internetbrowser zulassen.', + nl: 'Kan geen cameratoegang krijgen. Zorg ervoor dat u de camera toegang verleent tot uw huidige internet browser in de instellingen van uw apparaat.', + }; case UserMediaErrorType.STREAM_INACTIVE: return { en: 'The camera video stream was closed unexpectedly.', diff --git a/packages/camera-web/test/Camera/hooks/useUserMedia.test.ts b/packages/camera-web/test/Camera/hooks/useUserMedia.test.ts index d868f24dd..d18c6e1c0 100644 --- a/packages/camera-web/test/Camera/hooks/useUserMedia.test.ts +++ b/packages/camera-web/test/Camera/hooks/useUserMedia.test.ts @@ -32,6 +32,13 @@ describe('useUserMedia hook', () => { beforeEach(() => { gumMock = mockGetUserMedia(); + Object.defineProperty(global.navigator, 'permissions', { + value: { + query: jest.fn(() => Promise.reject()), + }, + configurable: true, + writable: true, + }); }); afterEach(() => { @@ -125,6 +132,56 @@ describe('useUserMedia hook', () => { unmount(); }); + it('should return a NotAllowed for webpage error in case of camera permission error', async () => { + const videoRef = { current: {} } as RefObject; + const nativeError = new Error(); + nativeError.name = 'NotAllowedError'; + mockGetUserMedia({ createMock: () => jest.fn(() => Promise.reject(nativeError)) }); + navigator.permissions.query = jest.fn(() => + Promise.resolve({ state: 'granted' } as PermissionStatus), + ); + const { result } = renderUseUserMedia({ constraints: {}, videoRef }); + await waitFor(() => { + expect(result.current).toEqual({ + stream: null, + dimensions: null, + error: { + type: UserMediaErrorType.BROWSER_NOT_ALLOWED, + nativeError, + }, + isLoading: false, + retry: expect.any(Function), + availableCameraDevices: [], + selectedCameraDeviceId: null, + }); + }); + }); + + it('should return a NotAllowed for browser error in case of camera permission error', async () => { + const videoRef = { current: {} } as RefObject; + const nativeError = new Error(); + nativeError.name = 'NotAllowedError'; + mockGetUserMedia({ createMock: () => jest.fn(() => Promise.reject(nativeError)) }); + navigator.permissions.query = jest.fn(() => + Promise.resolve({ state: 'denied' } as PermissionStatus), + ); + const { result } = renderUseUserMedia({ constraints: {}, videoRef }); + await waitFor(() => { + expect(result.current).toEqual({ + stream: null, + dimensions: null, + error: { + type: UserMediaErrorType.WEBPAGE_NOT_ALLOWED, + nativeError, + }, + isLoading: false, + retry: expect.any(Function), + availableCameraDevices: [], + selectedCameraDeviceId: null, + }); + }); + }); + it('should return an InvalidStream error if the stream has no tracks', async () => { const videoRef = { current: {} } as RefObject; mockGetUserMedia({ tracks: [] });