diff --git a/__mocks__/react-native-vision-camera.ts b/__mocks__/react-native-vision-camera.ts new file mode 100644 index 000000000000..e50233a6c540 --- /dev/null +++ b/__mocks__/react-native-vision-camera.ts @@ -0,0 +1,14 @@ +const useCameraDevice = jest.fn(() => null); +const useCameraDevices = jest.fn(() => []); +const useCameraFormat = jest.fn(() => null); +const useCameraPermission = jest.fn(() => ({hasPermission: false, requestPermission: jest.fn(() => Promise.resolve(false))})); + +const Camera = Object.assign( + jest.fn(() => null), + { + getCameraPermissionStatus: jest.fn(() => 'not-determined'), + requestCameraPermission: jest.fn(() => Promise.resolve('granted')), + }, +); + +export {Camera, useCameraDevice, useCameraDevices, useCameraFormat, useCameraPermission}; diff --git a/assets/images/camera-flip.svg b/assets/images/camera-flip.svg new file mode 100644 index 000000000000..6d05251e0c77 --- /dev/null +++ b/assets/images/camera-flip.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index 277191eee328..73d9bd5c5e15 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -495,7 +495,6 @@ "../../src/hooks/useListKeyboardNav.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/hooks/useMarkdownStyle.ts" "rulesdir/no-raw-typography" 3 "../../src/hooks/useMoneyReportHeaderStatusBar.ts" "rulesdir/no-direct-personal-details-list" 1 -"../../src/hooks/useNativeCamera.ts" "react-hooks/refs" 1 "../../src/hooks/useNewTransactions.ts" "react-hooks/refs" 2 "../../src/hooks/useOnyx.ts" "@typescript-eslint/no-unsafe-type-assertion" 10 "../../src/hooks/useOptimisticDraftTransactions.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 diff --git a/src/components/AttachmentPicker/AttachmentCamera.tsx b/src/components/AttachmentPicker/AttachmentCamera.tsx new file mode 100644 index 000000000000..b73512da5177 --- /dev/null +++ b/src/components/AttachmentPicker/AttachmentCamera.tsx @@ -0,0 +1,338 @@ +/** + * In-app VisionCamera modal used by the native AttachmentPicker. + */ +import ActivityIndicator from '@components/ActivityIndicator'; +import Button from '@components/Button'; +import Icon from '@components/Icon'; +import ImageSVG from '@components/ImageSVG'; +import Modal from '@components/Modal'; +import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; +import Text from '@components/Text'; + +import useIsPlatformMuted from '@hooks/useIsPlatformMuted'; +import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import {requestCameraPermission, useTapToFocusGesture} from '@hooks/useNativeCamera'; +import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; + +import {getFileName} from '@libs/fileDownload/FileUtils'; +import getPhotoSource from '@libs/fileDownload/getPhotoSource'; +import getVideoResolutionFormatFilter from '@libs/getVideoResolutionFormatFilter'; +import isInLandscapeMode from '@libs/isInLandscapeMode'; +import {logCameraCaptureFailed, logCameraRuntimeError} from '@libs/telemetry/ReceiptObservability'; + +import CameraPermission from '@pages/iou/request/step/IOURequestStepScan/CameraPermission'; +import getCameraAspectRatio from '@pages/iou/request/step/IOURequestStepScan/getCameraAspectRatio'; + +import variables from '@styles/variables'; + +import CONST from '@src/CONST'; + +import type {Camera, CameraRuntimeError, PhotoFile} from 'react-native-vision-camera'; + +import React, {useEffect, useRef, useState} from 'react'; +import {Alert, AppState, View} from 'react-native'; +import {GestureDetector} from 'react-native-gesture-handler'; +import {RESULTS} from 'react-native-permissions'; +import Animated from 'react-native-reanimated'; +import {useCameraDevice, useCameraDevices, useCameraFormat, Camera as VisionCamera} from 'react-native-vision-camera'; + +type CapturedPhoto = { + uri: string; + fileName: string; + type: string; + width: number; + height: number; +}; + +type AttachmentCameraProps = { + /** Whether the camera modal is visible */ + isVisible: boolean; + + /** Callback when a photo is captured */ + onCapture: (photos: CapturedPhoto[]) => void; + + /** Callback when the camera is closed */ + onClose: () => void; + + /** Callback fired once the modal has finished its hide animation */ + onModalHide: () => void; +}; + +function AttachmentCamera({isVisible, onCapture, onClose, onModalHide}: AttachmentCameraProps) { + const theme = useTheme(); + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const insets = useSafeAreaInsets(); + const StyleUtils = useStyleUtils(); + const {windowWidth, windowHeight} = useWindowDimensions(); + const isLandscape = isInLandscapeMode(windowWidth, windowHeight); + const lazyIcons = useMemoizedLazyExpensifyIcons(['Bolt', 'boltSlash', 'CameraFlip', 'Close']); + const lazyIllustrations = useMemoizedLazyIllustrations(['Shutter', 'Hand']); + const isPlatformMuted = useIsPlatformMuted(); + + const [cameraPosition, setCameraPosition] = useState<'back' | 'front'>('back'); + const [flash, setFlash] = useState(false); + const [cameraPermissionStatus, setCameraPermissionStatus] = useState(null); + const isCapturing = useRef(false); + const isActiveRef = useRef(false); + const cameraRef = useRef(null); + + const device = useCameraDevice(cameraPosition, { + physicalDevices: ['wide-angle-camera', 'ultra-wide-angle-camera'], + }); + + const cameraDevices = useCameraDevices(); + const canFlipCamera = cameraDevices.some((d) => d.position === 'front') && cameraDevices.some((d) => d.position === 'back'); + + const format = useCameraFormat(device, [ + {photoAspectRatio: CONST.RECEIPT_CAMERA.PHOTO_ASPECT_RATIO}, + {photoResolution: {width: CONST.RECEIPT_CAMERA.PHOTO_WIDTH, height: CONST.RECEIPT_CAMERA.PHOTO_HEIGHT}}, + getVideoResolutionFormatFilter(windowWidth, windowHeight), + ]); + const hasFlash = !!device?.hasFlash; + const cameraAspectRatio = getCameraAspectRatio(format, isLandscape); + + const {tapGesture, cameraFocusIndicatorAnimatedStyle} = useTapToFocusGesture(cameraRef, device?.supportsFocus ?? false); + + const askForPermissions = () => requestCameraPermission(translate, setCameraPermissionStatus); + + useEffect(() => { + isActiveRef.current = isVisible; + }, [isVisible]); + + // Refresh permissions when modal becomes visible or when returning from app settings + useEffect(() => { + if (!isVisible) { + return; + } + + let ignore = false; + const refreshCameraPermissionStatus = (autoRequest = false) => { + CameraPermission?.getCameraPermissionStatus?.() + .then((status: string) => { + if (ignore) { + return; + } + setCameraPermissionStatus(status); + if (autoRequest && status === RESULTS.DENIED) { + requestCameraPermission(translate, setCameraPermissionStatus); + } + }) + .catch(() => { + if (ignore) { + return; + } + setCameraPermissionStatus(RESULTS.UNAVAILABLE); + }); + }; + + refreshCameraPermissionStatus(true); + + const subscription = AppState.addEventListener('change', (appState) => { + if (appState !== 'active') { + return; + } + refreshCameraPermissionStatus(); + }); + + return () => { + ignore = true; + subscription.remove(); + }; + }, [isVisible, translate]); + + const capturePhoto = () => { + if (cameraPermissionStatus !== RESULTS.GRANTED) { + askForPermissions(); + return; + } + + if (!cameraRef.current || isCapturing.current) { + return; + } + + isCapturing.current = true; + + cameraRef.current + .takePhoto({ + flash: flash && hasFlash ? 'on' : 'off', + enableShutterSound: !isPlatformMuted, + }) + .then((photo: PhotoFile) => { + // Discard capture if the camera was closed while takePhoto was in-flight + if (!isActiveRef.current) { + return; + } + const uri = getPhotoSource(photo.path); + const fileName = getFileName(photo.path) || `photo_${Date.now()}.jpg`; + + onCapture([ + { + uri, + fileName, + type: 'image/jpeg', + width: photo.width, + height: photo.height, + }, + ]); + }) + .catch((error: Error) => { + Alert.alert(translate('receipt.cameraErrorTitle'), translate('receipt.cameraErrorMessage')); + logCameraCaptureFailed(error); + }) + .finally(() => { + isCapturing.current = false; + }); + }; + + const handleCameraError = (error: CameraRuntimeError) => { + Alert.alert(translate('receipt.cameraErrorTitle'), translate('receipt.cameraErrorMessage')); + logCameraRuntimeError({code: error.code, message: error.message}); + }; + + const handleClose = () => { + isCapturing.current = false; + setFlash(false); + setCameraPosition('back'); + onClose(); + }; + + return ( + + + + + + + + + + {cameraPermissionStatus !== RESULTS.GRANTED && ( + + + {translate('receipt.takePhoto')} + {translate('receipt.cameraAccess')} + + + )} + {cameraPermissionStatus === RESULTS.GRANTED && device == null && ( + + + + )} + {cameraPermissionStatus === RESULTS.GRANTED && device != null && ( + + + + + + + + + )} + + + + setFlash((prevFlash) => !prevFlash)} + sentryLabel="AttachmentCamera-Flash" + > + + + + + + + + setCameraPosition((prev) => (prev === 'back' ? 'front' : 'back'))} + sentryLabel="AttachmentCamera-FlipCamera" + > + + + + + + ); +} + +export default AttachmentCamera; +export type {CapturedPhoto}; diff --git a/src/components/AttachmentPicker/index.native.tsx b/src/components/AttachmentPicker/index.native.tsx index ca2437f5e61d..4b3e7b1dd6c6 100644 --- a/src/components/AttachmentPicker/index.native.tsx +++ b/src/components/AttachmentPicker/index.native.tsx @@ -27,15 +27,19 @@ import type {Asset, Callback, CameraOptions, ImageLibraryOptions, ImagePickerRes import {keepLocalCopy, pick, types} from '@react-native-documents/picker'; import {Str} from 'expensify-common'; -import React, {useCallback, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {Alert, View} from 'react-native'; import RNFetchBlob from 'react-native-blob-util'; import {launchImageLibrary} from 'react-native-image-picker'; import ImageSize from 'react-native-image-size'; +import type {CapturedPhoto} from './AttachmentCamera'; import type AttachmentPickerProps from './types'; -import launchCamera from './launchCamera/launchCamera'; +import AttachmentCamera from './AttachmentCamera'; + +/** Gives the popover a frame to finish dismissing on iOS. Launching immediately would close the gallery/camera along with it. */ +const MODAL_DISMISS_DELAY_MS = 200; const EXTENSION_TO_NATIVE_TYPE: Record = { pdf: String(types.pdf), @@ -67,8 +71,15 @@ type Item = { icon: IconAsset; /** The key in the translations file to use for the title */ textTranslationKey: TranslationPaths; - pickAttachment: () => Promise; -}; +} & ( + | { + pickAttachment: () => Promise; + } + | { + /** Direct action that doesn't go through the promise-based selectItem flow */ + onPress: () => void; + } +); /** * Return imagePickerOptions based on the type */ @@ -147,6 +158,10 @@ function AttachmentPicker({ const icons = useMemoizedLazyExpensifyIcons(['Camera', 'Gallery', 'Paperclip']); const styles = useThemeStyles(); const [isVisible, setIsVisible] = useState(false); + // Mount and visibility are tracked separately so the camera stays mounted through its hide + // animation. Unmounting on close cuts the animation off midway and the modal vanishes abruptly. + const [isAttachmentCameraMounted, setIsAttachmentCameraMounted] = useState(false); + const [isAttachmentCameraVisible, setIsAttachmentCameraVisible] = useState(false); const StyleUtils = useStyleUtils(); const theme = useTheme(); @@ -155,6 +170,16 @@ function AttachmentPicker({ const onCanceled = useRef<() => void>(() => {}); const onClosed = useRef<() => void>(() => {}); const popoverRef = useRef(null); + const modalDismissTimeoutRef = useRef(null); + + useEffect(() => { + return () => { + if (!modalDismissTimeoutRef.current) { + return; + } + clearTimeout(modalDismissTimeoutRef.current); + }; + }, []); const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); @@ -170,10 +195,15 @@ function AttachmentPicker({ [translate], ); + const launchInAppCamera = useCallback(() => { + setIsAttachmentCameraMounted(true); + setIsAttachmentCameraVisible(true); + }, []); + /** * Common image picker handling * - * @param {function} imagePickerFunc - RNImagePicker.launchCamera or RNImagePicker.launchImageLibrary + * @param {function} imagePickerFunc - RNImagePicker.launchImageLibrary */ const showImagePicker = useCallback( (imagePickerFunc: (options: CameraOptions, callback: Callback) => Promise): Promise => @@ -277,12 +307,12 @@ function AttachmentPicker({ data.unshift({ icon: icons.Camera, textTranslationKey: 'attachmentPicker.takePhoto', - pickAttachment: () => showImagePicker(launchCamera), + onPress: launchInAppCamera, }); } return data; - }, [icons.Camera, icons.Paperclip, icons.Gallery, showDocumentPicker, shouldHideGalleryOption, shouldHideCameraOption, showImagePicker]); + }, [icons.Camera, icons.Paperclip, icons.Gallery, showDocumentPicker, shouldHideGalleryOption, shouldHideCameraOption, launchInAppCamera, showImagePicker]); const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({initialFocusedIndex: -1, maxIndex: menuItemData.length - 1, isActive: isVisible}); @@ -405,6 +435,39 @@ function AttachmentPicker({ [handleImageProcessingError, shouldValidateImage, showGeneralAlert, showImageCorruptionAlert], ); + const handleCameraCapture = useCallback( + (photos: CapturedPhoto[]) => { + setIsAttachmentCameraVisible(false); + if (modalDismissTimeoutRef.current) { + clearTimeout(modalDismissTimeoutRef.current); + modalDismissTimeoutRef.current = null; + } + const assets: Asset[] = photos.map((photo) => ({ + uri: photo.uri, + fileName: photo.fileName, + type: photo.type, + width: photo.width, + height: photo.height, + })); + Promise.resolve(pickAttachment(assets)).finally(() => { + onClosed.current(); + delete onModalHide.current; + }); + }, + [pickAttachment], + ); + + const handleCameraClose = useCallback(() => { + setIsAttachmentCameraVisible(false); + if (modalDismissTimeoutRef.current) { + clearTimeout(modalDismissTimeoutRef.current); + modalDismissTimeoutRef.current = null; + } + onCanceled.current(); + onClosed.current(); + delete onModalHide.current; + }, []); + /** * Opens the attachment modal, or directly launches the document picker when shouldSkipAttachmentTypeModal is true. */ @@ -440,11 +503,32 @@ function AttachmentPicker({ */ const selectItem = useCallback( (item: Item) => { + if (modalDismissTimeoutRef.current) { + clearTimeout(modalDismissTimeoutRef.current); + modalDismissTimeoutRef.current = null; + } + + /* Presenting a second modal while the first is still dismissing fails silently on iOS, so + * defer the camera launch to onModalHide. onPress items report completion themselves and + * skip the promise-based pickAttachment chain below. */ + if ('onPress' in item) { + onModalHide.current = () => { + modalDismissTimeoutRef.current = setTimeout(() => { + modalDismissTimeoutRef.current = null; + item.onPress(); + delete onModalHide.current; + }, MODAL_DISMISS_DELAY_MS); + }; + close(); + return; + } + onOpenPicker?.(); /* setTimeout delays execution to the frame after the modal closes * without this on iOS closing the modal closes the gallery/camera as well */ onModalHide.current = () => { - setTimeout(() => { + modalDismissTimeoutRef.current = setTimeout(() => { + modalDismissTimeoutRef.current = null; item.pickAttachment() .catch((error: Error) => { if (JSON.stringify(error).includes('OPERATION_CANCELED')) { @@ -460,7 +544,7 @@ function AttachmentPicker({ onClosed.current(); delete onModalHide.current; }); - }, 200); + }, MODAL_DISMISS_DELAY_MS); }; close(); }, @@ -496,6 +580,10 @@ function AttachmentPicker({ <> { + if (modalDismissTimeoutRef.current) { + clearTimeout(modalDismissTimeoutRef.current); + modalDismissTimeoutRef.current = null; + } close(); onCanceled.current(); }} @@ -517,6 +605,14 @@ function AttachmentPicker({ ))} + {isAttachmentCameraMounted && ( + setIsAttachmentCameraMounted(false)} + /> + )} {renderChildren()} ); diff --git a/src/components/AttachmentPicker/launchCamera/launchCamera.android.ts b/src/components/AttachmentPicker/launchCamera/launchCamera.android.ts deleted file mode 100644 index 9a20f6918208..000000000000 --- a/src/components/AttachmentPicker/launchCamera/launchCamera.android.ts +++ /dev/null @@ -1,34 +0,0 @@ -import {PermissionsAndroid} from 'react-native'; -import {launchCamera as launchCameraImagePicker} from 'react-native-image-picker'; - -import type {LaunchCamera} from './types'; - -import {ErrorLaunchCamera} from './types'; - -/** - * Launching the camera for Android involves checking for permissions - * And only then starting the camera - * If the user deny permission the callback will be called with an error response - * in the same format as the error returned by react-native-image-picker - */ -const launchCamera: LaunchCamera = (options, callback) => { - // Checks current camera permissions and prompts the user in case they aren't granted - PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.CAMERA) - .then((permission) => { - if (permission !== PermissionsAndroid.RESULTS.GRANTED) { - throw new ErrorLaunchCamera('User did not grant permissions', 'permission'); - } - - launchCameraImagePicker(options, callback); - }) - .catch((error: ErrorLaunchCamera) => { - /* Intercept the permission error as well as any other errors and call the callback - * follow the same pattern expected for image picker results */ - callback({ - errorMessage: error.message, - errorCode: error.errorCode || 'others', - }); - }); -}; - -export default launchCamera; diff --git a/src/components/AttachmentPicker/launchCamera/launchCamera.ios.ts b/src/components/AttachmentPicker/launchCamera/launchCamera.ios.ts deleted file mode 100644 index b56d77ca61c7..000000000000 --- a/src/components/AttachmentPicker/launchCamera/launchCamera.ios.ts +++ /dev/null @@ -1,34 +0,0 @@ -import {launchCamera as launchCameraImagePicker} from 'react-native-image-picker'; -import {PERMISSIONS, request, RESULTS} from 'react-native-permissions'; - -import type {LaunchCamera} from './types'; - -import {ErrorLaunchCamera} from './types'; - -/** - * Launching the camera for iOS involves checking for permissions - * And only then starting the camera - * If the user deny permission the callback will be called with an error response - * in the same format as the error returned by react-native-image-picker - */ -const launchCamera: LaunchCamera = (options, callback) => { - // Checks current camera permissions and prompts the user in case they aren't granted - request(PERMISSIONS.IOS.CAMERA) - .then((permission) => { - if (permission !== RESULTS.GRANTED) { - throw new ErrorLaunchCamera('User did not grant permissions', 'permission'); - } - - launchCameraImagePicker(options, callback); - }) - .catch((error: ErrorLaunchCamera) => { - /* Intercept the permission error as well as any other errors and call the callback - * follow the same pattern expected for image picker results */ - callback({ - errorMessage: error.message, - errorCode: error.errorCode || 'others', - }); - }); -}; - -export default launchCamera; diff --git a/src/components/AttachmentPicker/launchCamera/launchCamera.ts b/src/components/AttachmentPicker/launchCamera/launchCamera.ts deleted file mode 100644 index dc1f921086de..000000000000 --- a/src/components/AttachmentPicker/launchCamera/launchCamera.ts +++ /dev/null @@ -1,3 +0,0 @@ -import {launchCamera} from 'react-native-image-picker'; - -export default launchCamera; diff --git a/src/components/AttachmentPicker/launchCamera/types.ts b/src/components/AttachmentPicker/launchCamera/types.ts deleted file mode 100644 index fee9268c2f98..000000000000 --- a/src/components/AttachmentPicker/launchCamera/types.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * A callback function used to handle the response from the image picker. - * - * @param response - The response object containing information about the picked images or any errors encountered. - */ -type Callback = (response: ImagePickerResponse) => void; - -type OptionsCommon = { - /** Specifies the type of media to be captured. */ - mediaType: MediaType; - /** Specifies the maximum width of the media to be captured. */ - maxWidth?: number; - /** Specifies the maximum height of the media to be captured. */ - maxHeight?: number; - /** Specifies the quality of the photo to be captured. */ - quality?: PhotoQuality; - /** Specifies the video quality for video capture. */ - videoQuality?: AndroidVideoOptions | IOSVideoOptions; - /** Specifies whether to include the media in base64 format. */ - includeBase64?: boolean; - /** Specifies whether to include extra information about the captured media. */ - includeExtra?: boolean; - /** Specifies the presentation style for the media picker. */ - presentationStyle?: 'currentContext' | 'fullScreen' | 'pageSheet' | 'formSheet' | 'popover' | 'overFullScreen' | 'overCurrentContext'; -}; - -type CameraOptions = OptionsCommon & { - /** Specifies the maximum duration limit. */ - durationLimit?: number; - /** Specifies whether to save captured media. */ - saveToPhotos?: boolean; - /** Specifies the type of camera to be used. */ - cameraType?: CameraType; -}; - -type Asset = { - /** Base64 representation of the asset. */ - base64?: string; - /** URI pointing to the asset. */ - uri?: string; - /** Width of the asset. */ - width?: number; - /** Height of the asset. */ - height?: number; - /** Size of the asset file in bytes. */ - fileSize?: number; - /** Type of the asset. */ - type?: string; - /** Name of the asset file. */ - fileName?: string; - /** Duration of the asset. */ - duration?: number; - /** Bitrate of the asset. */ - bitrate?: number; - /** Timestamp of when the asset was created or modified. */ - timestamp?: string; - /** ID of the asset. */ - id?: string; -}; - -type ImagePickerResponse = { - /** Indicates whether the image picker operation was canceled. */ - didCancel?: boolean; - /** The error code, if an error occurred during the image picking process. */ - errorCode?: ErrorCode; - /** A descriptive error message, if an error occurred during the image picking process. */ - errorMessage?: string; - /** An array of assets representing the picked images. */ - assets?: Asset[]; -}; - -/** Represents the quality options. */ -type PhotoQuality = 0 | 0.1 | 0.2 | 0.3 | 0.4 | 0.5 | 0.6 | 0.7 | 0.8 | 0.9 | 1; - -/** Represents the type of camera to be used. */ -type CameraType = 'back' | 'front'; - -/** Represents the type of media to be captured. */ -type MediaType = 'photo' | 'video' | 'mixed'; - -/** Represents the quality options for video capture on Android devices. */ -type AndroidVideoOptions = 'low' | 'high'; - -/** Represents the quality options for video capture on iOS devices. */ -type IOSVideoOptions = 'low' | 'medium' | 'high'; - -/** Represents various error codes that may occur during camera operations. */ -type ErrorCode = 'camera_unavailable' | 'permission' | 'others'; - -class ErrorLaunchCamera extends Error { - /** The error code associated with the error. */ - errorCode: ErrorCode; - - constructor(message: string, errorCode: ErrorCode) { - super(message); - this.errorCode = errorCode; - } -} - -/** - * A function used to launch the camera with specified options and handle the callback. - * - * @param options - The options for the camera, specifying various settings. - * @param callback - The callback function to handle the response from the camera operation. - */ -type LaunchCamera = (options: CameraOptions, callback: Callback) => void; - -export {ErrorLaunchCamera}; -export type {LaunchCamera}; diff --git a/src/components/Icon/chunks/expensify-icons.chunk.ts b/src/components/Icon/chunks/expensify-icons.chunk.ts index daafb8764fc9..ccdc300a66e2 100644 --- a/src/components/Icon/chunks/expensify-icons.chunk.ts +++ b/src/components/Icon/chunks/expensify-icons.chunk.ts @@ -37,6 +37,7 @@ import Building from '@assets/images/building.svg'; import Buildings from '@assets/images/buildings.svg'; import CalendarSolid from '@assets/images/calendar-solid.svg'; import Calendar from '@assets/images/calendar.svg'; +import CameraFlip from '@assets/images/camera-flip.svg'; import Camera from '@assets/images/camera.svg'; import CarCircleSlash from '@assets/images/car-circle-slash.svg'; import CarPlus from '@assets/images/car-plus.svg'; @@ -336,6 +337,7 @@ const Expensicons = { Buildings, Calendar, Camera, + CameraFlip, Car, CarPlus, Cash, diff --git a/src/hooks/useIsPlatformMuted.ts b/src/hooks/useIsPlatformMuted.ts new file mode 100644 index 000000000000..20497e6ff9b7 --- /dev/null +++ b/src/hooks/useIsPlatformMuted.ts @@ -0,0 +1,17 @@ +import getPlatform from '@libs/getPlatform'; +import type Platform from '@libs/getPlatform/types'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import {getEmptyObject} from '@src/types/utils/EmptyObject'; + +import useOnyx from './useOnyx'; + +/** Returns whether the user has muted sounds on the current platform. */ +function useIsPlatformMuted(): boolean { + const platform = getPlatform(true); + const [mutedPlatforms = getEmptyObject>>()] = useOnyx(ONYXKEYS.NVP_MUTED_PLATFORMS); + + return !!mutedPlatforms[platform]; +} + +export default useIsPlatformMuted; diff --git a/src/hooks/useNativeCamera.ts b/src/hooks/useNativeCamera.ts index a09d7743033e..cfe43f063ab0 100644 --- a/src/hooks/useNativeCamera.ts +++ b/src/hooks/useNativeCamera.ts @@ -1,15 +1,12 @@ import {useFullScreenLoaderActions, useFullScreenLoaderState} from '@components/FullScreenLoaderContext'; +import type {LocalizedTranslate} from '@components/LocaleContextProvider'; import {showCameraPermissionsAlert} from '@libs/fileDownload/FileUtils'; -import getPlatform from '@libs/getPlatform'; -import type Platform from '@libs/getPlatform/types'; import Log from '@libs/Log'; import CameraPermission from '@pages/iou/request/step/IOURequestStepScan/CameraPermission'; -import ONYXKEYS from '@src/ONYXKEYS'; -import {getEmptyObject} from '@src/types/utils/EmptyObject'; - +import type React from 'react'; import type {Camera, Point} from 'react-native-vision-camera'; import {useFocusEffect} from '@react-navigation/core'; @@ -21,8 +18,8 @@ import {useAnimatedStyle, useSharedValue, withDelay, withSequence, withSpring, w import {useCameraDevice} from 'react-native-vision-camera'; import {scheduleOnRN} from 'react-native-worklets'; +import useIsPlatformMuted from './useIsPlatformMuted'; import useLocalize from './useLocalize'; -import useOnyx from './useOnyx'; type UseNativeCameraOptions = { /** Additional logic to run when the screen gains focus */ @@ -32,6 +29,26 @@ type UseNativeCameraOptions = { onFocusCleanup?: () => void; }; +/** + * Requests camera permission and reports the resulting status back to the caller. Shared by every native camera + * surface so they all handle the BLOCKED case the same way. + */ +function requestCameraPermission(translate: LocalizedTranslate, setStatus: (status: string) => void) { + // There's no way we can check for the BLOCKED status without requesting the permission first + // https://github.com/zoontek/react-native-permissions/blob/a836e114ce3a180b2b23916292c79841a267d828/README.md?plain=1#L670 + CameraPermission.requestCameraPermission?.() + .then((status: string) => { + setStatus(status); + + if (status === RESULTS.BLOCKED) { + showCameraPermissionsAlert(translate); + } + }) + .catch(() => { + setStatus(RESULTS.UNAVAILABLE); + }); +} + function useNativeCamera({onFocusStart, onFocusCleanup}: UseNativeCameraOptions) { const {translate} = useLocalize(); const {isLoaderVisible} = useFullScreenLoaderState(); @@ -41,9 +58,7 @@ function useNativeCamera({onFocusStart, onFocusCleanup}: UseNativeCameraOptions) physicalDevices: ['wide-angle-camera', 'ultra-wide-angle-camera'], }); - const platform = getPlatform(true); - const [mutedPlatforms = getEmptyObject>>()] = useOnyx(ONYXKEYS.NVP_MUTED_PLATFORMS); - const isPlatformMuted = mutedPlatforms[platform]; + const isPlatformMuted = useIsPlatformMuted(); const [cameraPermissionStatus, setCameraPermissionStatus] = useState(null); const hasFlash = !!device?.hasFlash; @@ -52,57 +67,9 @@ function useNativeCamera({onFocusStart, onFocusCleanup}: UseNativeCameraOptions) const [isAttachmentPickerActive, setIsAttachmentPickerActive] = useState(false); const camera = useRef(null); - const askForPermissions = useCallback(() => { - // There's no way we can check for the BLOCKED status without requesting the permission first - // https://github.com/zoontek/react-native-permissions/blob/a836e114ce3a180b2b23916292c79841a267d828/README.md?plain=1#L670 - CameraPermission.requestCameraPermission?.() - .then((status: string) => { - setCameraPermissionStatus(status); - - if (status === RESULTS.BLOCKED) { - showCameraPermissionsAlert(translate); - } - }) - .catch(() => { - setCameraPermissionStatus(RESULTS.UNAVAILABLE); - }); - }, [translate]); - - // Focus indicator animations - const focusIndicatorOpacity = useSharedValue(0); - const focusIndicatorScale = useSharedValue(2); - const focusIndicatorPosition = useSharedValue({x: 0, y: 0}); - - const cameraFocusIndicatorAnimatedStyle = useAnimatedStyle(() => ({ - opacity: focusIndicatorOpacity.get(), - transform: [{translateX: focusIndicatorPosition.get().x}, {translateY: focusIndicatorPosition.get().y}, {scale: focusIndicatorScale.get()}], - })); - - const focusCamera = useCallback((point: Point) => { - if (!camera.current) { - return; - } - - camera.current.focus(point).catch((error: Record) => { - if (error.message === '[unknown/unknown] Cancelled by another startFocusAndMetering()') { - return; - } - Log.warn('Error focusing camera', error); - }); - }, []); - - const tapGesture = Gesture.Tap() - .enabled(device?.supportsFocus ?? false) - .onStart((ev: {x: number; y: number}) => { - const point = {x: ev.x, y: ev.y}; + const askForPermissions = useCallback(() => requestCameraPermission(translate, setCameraPermissionStatus), [translate]); - focusIndicatorOpacity.set(withSequence(withTiming(0.8, {duration: 250}), withDelay(1000, withTiming(0, {duration: 250})))); - focusIndicatorScale.set(2); - focusIndicatorScale.set(withSpring(1, {damping: 10, stiffness: 200})); - focusIndicatorPosition.set(point); - - scheduleOnRN(focusCamera, point); - }); + const {tapGesture, cameraFocusIndicatorAnimatedStyle} = useTapToFocusGesture(camera, device?.supportsFocus ?? false); // Refresh camera permission on screen focus and app state changes useFocusEffect( @@ -155,4 +122,51 @@ function useNativeCamera({onFocusStart, onFocusCleanup}: UseNativeCameraOptions) }; } +/** + * Module-level so React Compiler never sees the `cameraRef.current` read. Doing it inside a hook body + * trips the "no ref access during render" rule, making OXC bail on the file and diverge from Babel. + */ +function focusCameraAtPoint(cameraRef: React.RefObject, point: Point) { + if (!cameraRef.current) { + return; + } + + cameraRef.current.focus(point).catch((error: Record) => { + if (error.message === '[unknown/unknown] Cancelled by another startFocusAndMetering()') { + return; + } + Log.warn('Error focusing camera', error); + }); +} + +function useTapToFocusGesture(cameraRef: React.RefObject, supportsFocus: boolean) { + const focusIndicatorOpacity = useSharedValue(0); + const focusIndicatorScale = useSharedValue(2); + const focusIndicatorPosition = useSharedValue({x: 0, y: 0}); + + const cameraFocusIndicatorAnimatedStyle = useAnimatedStyle(() => ({ + opacity: focusIndicatorOpacity.get(), + transform: [{translateX: focusIndicatorPosition.get().x}, {translateY: focusIndicatorPosition.get().y}, {scale: focusIndicatorScale.get()}], + })); + + // React Compiler memoizes this closure, so no manual useCallback. + const focusCamera = (point: Point) => focusCameraAtPoint(cameraRef, point); + + const tapGesture = Gesture.Tap() + .enabled(supportsFocus) + .onStart((ev: {x: number; y: number}) => { + const point = {x: ev.x, y: ev.y}; + + focusIndicatorOpacity.set(withSequence(withTiming(0.8, {duration: 250}), withDelay(1000, withTiming(0, {duration: 250})))); + focusIndicatorScale.set(2); + focusIndicatorScale.set(withSpring(1, {damping: 10, stiffness: 200})); + focusIndicatorPosition.set(point); + + scheduleOnRN(focusCamera, point); + }); + + return {tapGesture, cameraFocusIndicatorAnimatedStyle}; +} + export default useNativeCamera; +export {useTapToFocusGesture, requestCameraPermission}; diff --git a/src/languages/de.ts b/src/languages/de.ts index 727915c764f4..09e30e3a32ea 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1279,6 +1279,7 @@ const translations: TranslationDeepObject = { dropTitle: 'Lass es los', dropMessage: 'Datei hierher ziehen', flash: 'Blitz', + flipCamera: 'Kamera wechseln', multiScan: 'Mehrfachscan', shutter: 'Verschluss', gallery: 'Galerie', diff --git a/src/languages/el.ts b/src/languages/el.ts index 6525d0b305e8..8d0c82b64e86 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -1328,6 +1328,7 @@ const translations: TranslationDeepObject = { dropTitle: 'Άφησέ το να πάει', dropMessage: 'Αποθέστε το αρχείο σας εδώ', flash: 'φλας', + flipCamera: 'περιστροφή κάμερας', multiScan: 'πολλαπλή σάρωση', shutter: 'κλείστρο', gallery: 'συλλογή', diff --git a/src/languages/en.ts b/src/languages/en.ts index 81600cdbecca..05968533ae58 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1365,6 +1365,7 @@ const translations = { dropTitle: 'Let it go', dropMessage: 'Drop your file here', flash: 'flash', + flipCamera: 'flip camera', multiScan: 'multi-scan', shutter: 'shutter', gallery: 'gallery', diff --git a/src/languages/es.ts b/src/languages/es.ts index 714eaf0725d3..ebd2b18b51f7 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1278,6 +1278,7 @@ const translations: TranslationDeepObject = { dropTitle: 'Suéltalo', dropMessage: 'Suelta tu archivo aquí', flash: 'flash', + flipCamera: 'cambiar de cámara', multiScan: 'escaneo múltiple', shutter: 'obturador', gallery: 'galería', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 5aeebe79771d..673e01a2638e 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1283,6 +1283,7 @@ const translations: TranslationDeepObject = { dropTitle: 'Laisse tomber', dropMessage: 'Déposez votre fichier ici', flash: 'flash', + flipCamera: 'inverser la caméra', multiScan: 'numérisation multiple', shutter: 'obturateur', gallery: 'galerie', diff --git a/src/languages/it.ts b/src/languages/it.ts index aee10e7ea046..7d716d3ba904 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1277,6 +1277,7 @@ const translations: TranslationDeepObject = { dropTitle: 'Lascia perdere', dropMessage: 'Rilascia qui il tuo file', flash: 'flash', + flipCamera: 'inverti fotocamera', multiScan: 'scansione multipla', shutter: 'otturatore', gallery: 'galleria', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 2690b7ebdbf0..b8e6de2f52e1 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1262,6 +1262,7 @@ const translations: TranslationDeepObject = { dropTitle: '手放して', dropMessage: 'ここにファイルをドロップしてください', flash: 'フラッシュ', + flipCamera: 'カメラを反転', multiScan: 'マルチスキャン', shutter: 'シャッター', gallery: 'ギャラリー', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index a1b9e0ca819f..48dea2bec8d1 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1277,6 +1277,7 @@ const translations: TranslationDeepObject = { dropTitle: 'Laat het los', dropMessage: 'Zet je bestand hier neer', flash: 'flits', + flipCamera: 'camera omdraaien', multiScan: 'meerscannen', shutter: 'sluiter', gallery: 'galerij', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 74c8ffca208d..55d06c2073c7 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1309,6 +1309,7 @@ const translations: TranslationDeepObject = { dropTitle: 'Odpuść to', dropMessage: 'Upuść tutaj plik', flash: 'błysk', + flipCamera: 'obróć kamerę', multiScan: 'wielokrotne skanowanie', shutter: 'migawka', gallery: 'galeria', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 82ffdec4dbc6..535bfe88f40b 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1277,6 +1277,7 @@ const translations: TranslationDeepObject = { dropTitle: 'Deixe pra lá', dropMessage: 'Solte seu arquivo aqui', flash: 'flash', + flipCamera: 'inverter câmera', multiScan: 'escaneamento múltiplo', shutter: 'obturador', gallery: 'galeria', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 2dab91373c51..71a8de0e4939 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1225,6 +1225,7 @@ const translations: TranslationDeepObject = { dropTitle: '随它去', dropMessage: '将文件拖放到此处', flash: '闪光', + flipCamera: '翻转摄像头', multiScan: '多重扫描', shutter: '快门', gallery: '图库', diff --git a/src/libs/getVideoResolutionFormatFilter/index.android.ts b/src/libs/getVideoResolutionFormatFilter/index.android.ts new file mode 100644 index 000000000000..38b9aaf488fe --- /dev/null +++ b/src/libs/getVideoResolutionFormatFilter/index.android.ts @@ -0,0 +1,9 @@ +import type GetVideoResolutionFormatFilter from './types'; + +// Size the preview to the screen to avoid an oversized preview surface. Format dimensions are +// landscape, so the window dimensions are swapped. +const getVideoResolutionFormatFilter: GetVideoResolutionFormatFilter = (windowWidth, windowHeight) => ({ + videoResolution: {width: windowHeight, height: windowWidth}, +}); + +export default getVideoResolutionFormatFilter; diff --git a/src/libs/getVideoResolutionFormatFilter/index.ios.ts b/src/libs/getVideoResolutionFormatFilter/index.ios.ts new file mode 100644 index 000000000000..496b243435aa --- /dev/null +++ b/src/libs/getVideoResolutionFormatFilter/index.ios.ts @@ -0,0 +1,11 @@ +import CONST from '@src/CONST'; + +import type GetVideoResolutionFormatFilter from './types'; + +// Match the photo target. Otherwise the format selector pairs that photo size with a low video +// resolution and the viewfinder looks grainy. +const getVideoResolutionFormatFilter: GetVideoResolutionFormatFilter = () => ({ + videoResolution: {width: CONST.RECEIPT_CAMERA.PHOTO_WIDTH, height: CONST.RECEIPT_CAMERA.PHOTO_HEIGHT}, +}); + +export default getVideoResolutionFormatFilter; diff --git a/src/libs/getVideoResolutionFormatFilter/index.ts b/src/libs/getVideoResolutionFormatFilter/index.ts new file mode 100644 index 000000000000..5016ae8c08e4 --- /dev/null +++ b/src/libs/getVideoResolutionFormatFilter/index.ts @@ -0,0 +1,9 @@ +import type GetVideoResolutionFormatFilter from './types'; + +// The in-app camera only mounts on iOS and Android. This default exists so the import resolves on +// other platforms, and it mirrors the Android sizing. +const getVideoResolutionFormatFilter: GetVideoResolutionFormatFilter = (windowWidth, windowHeight) => ({ + videoResolution: {width: windowHeight, height: windowWidth}, +}); + +export default getVideoResolutionFormatFilter; diff --git a/src/libs/getVideoResolutionFormatFilter/types.ts b/src/libs/getVideoResolutionFormatFilter/types.ts new file mode 100644 index 000000000000..86f37b49d634 --- /dev/null +++ b/src/libs/getVideoResolutionFormatFilter/types.ts @@ -0,0 +1,9 @@ +import type {FormatFilter} from 'react-native-vision-camera'; + +/** + * Builds the videoResolution filter for the in-app camera. The live viewfinder renders from the video + * pipeline, so this controls preview quality only. Capture always uses the photo resolution. + */ +type GetVideoResolutionFormatFilter = (windowWidth: number, windowHeight: number) => FormatFilter; + +export default GetVideoResolutionFormatFilter; diff --git a/src/libs/telemetry/ReceiptObservability.ts b/src/libs/telemetry/ReceiptObservability.ts index a00475406e8a..75e556d2ab92 100644 --- a/src/libs/telemetry/ReceiptObservability.ts +++ b/src/libs/telemetry/ReceiptObservability.ts @@ -246,6 +246,23 @@ function logReceiptAdoptFailed({error, captureSource}: {error: unknown; captureS }); } +/** The in-app camera failed to produce a photo, so the user tapped the shutter and got nothing back. */ +function logCameraCaptureFailed(error: unknown) { + Log.alert(`${RECEIPT_LOG_PREFIX} camera capture failed`, { + event: 'cameraCaptureFailed', + error: error instanceof Error ? error.message : String(error), + }); +} + +/** VisionCamera reported a runtime error, which usually means the preview never became usable. */ +function logCameraRuntimeError({code, message}: {code: string; message: string}) { + Log.alert(`${RECEIPT_LOG_PREFIX} camera runtime error`, { + event: 'cameraRuntimeError', + code, + error: message, + }); +} + function getQueuedReceiptPath(receipt: QueuedReceipt): ReceiptSource | undefined { return receipt.localSource ?? receipt.source ?? receipt.uri; } @@ -371,6 +388,8 @@ export { logReceiptGaveUp, logReceiptStatFailed, logReceiptAdoptFailed, + logCameraCaptureFailed, + logCameraRuntimeError, logReceiptQueueSnapshot, getPickerCaptureSource, RECEIPT_BEARING_COMMANDS, diff --git a/tests/ui/IOURequestStepScanTest.tsx b/tests/ui/IOURequestStepScanTest.tsx index edb13c7cf633..0a6676cde985 100644 --- a/tests/ui/IOURequestStepScanTest.tsx +++ b/tests/ui/IOURequestStepScanTest.tsx @@ -56,6 +56,7 @@ jest.mock('@hooks/useFilesValidation', () => { jest.mock('react-native-vision-camera', () => ({ useCameraDevice: jest.fn(() => null), + useCameraDevices: jest.fn(() => []), useCameraFormat: jest.fn(() => null), })); diff --git a/tests/ui/ScanSkipConfirmationTest.tsx b/tests/ui/ScanSkipConfirmationTest.tsx index 6ef764df3ba1..870453affba2 100644 --- a/tests/ui/ScanSkipConfirmationTest.tsx +++ b/tests/ui/ScanSkipConfirmationTest.tsx @@ -56,6 +56,7 @@ jest.mock('react-native-permissions', () => ({ jest.mock('react-native-vision-camera', () => ({ useCameraDevice: jest.fn(() => null), + useCameraDevices: jest.fn(() => []), useCameraFormat: jest.fn(() => null), })); diff --git a/tests/ui/components/AttachmentCameraTest.tsx b/tests/ui/components/AttachmentCameraTest.tsx new file mode 100644 index 000000000000..fb6dcb5937e8 --- /dev/null +++ b/tests/ui/components/AttachmentCameraTest.tsx @@ -0,0 +1,184 @@ +import {act, fireEvent, render, screen} from '@testing-library/react-native'; + +import AttachmentCamera from '@components/AttachmentPicker/AttachmentCamera'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; + +import type {CameraDevice} from 'react-native-vision-camera'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; +import {useCameraDevice, useCameraDevices} from 'react-native-vision-camera'; + +import createMock from '../../utils/createMock'; +import {translateLocal} from '../../utils/TestHelper'; +import waitForBatchedUpdatesWithAct from '../../utils/waitForBatchedUpdatesWithAct'; + +const mockTakePhoto = jest.fn(() => Promise.resolve({path: '/tmp/photos/shot.jpg', width: 3024, height: 4032})); +let mockPermissionStatus = 'granted'; + +jest.mock('@pages/iou/request/step/IOURequestStepScan/CameraPermission', () => ({ + getCameraPermissionStatus: jest.fn(() => Promise.resolve(mockPermissionStatus)), + requestCameraPermission: jest.fn(() => Promise.resolve(mockPermissionStatus)), +})); + +// Render the modal body inline so the assertions target the camera UI rather than modal plumbing. +// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- ignore for testing +const {View: MockView} = jest.requireActual('react-native'); +jest.mock( + '@components/Modal', + () => + ({isVisible, children}: {isVisible: boolean; children: React.ReactNode}) => + isVisible ? {children} : null, +); + +jest.mock('react-native-vision-camera', () => { + const actualReact = jest.requireActual('react'); + return { + useCameraDevice: jest.fn(), + useCameraDevices: jest.fn(() => []), + useCameraFormat: jest.fn(() => null), + Camera: actualReact.forwardRef((_props: Record, ref: React.ForwardedRef) => { + actualReact.useImperativeHandle(ref, () => ({takePhoto: mockTakePhoto, focus: jest.fn(() => Promise.resolve())})); + return null; + }), + }; +}); + +const BACK_DEVICE = createMock({id: 'back', position: 'back', hasFlash: true, supportsFocus: true, neutralZoom: 1}); +const FRONT_DEVICE = createMock({id: 'front', position: 'front', hasFlash: false, supportsFocus: true, neutralZoom: 1}); + +const mockedUseCameraDevice = jest.mocked(useCameraDevice); +const mockedUseCameraDevices = jest.mocked(useCameraDevices); + +function renderCamera(props: Partial> = {}) { + const onCapture = jest.fn(); + const onClose = jest.fn(); + const onModalHide = jest.fn(); + + render( + + + + + , + ); + + return {onCapture, onClose, onModalHide}; +} + +describe('AttachmentCamera', () => { + beforeAll(() => { + Onyx.init({keys: {}}); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + mockPermissionStatus = 'granted'; + mockTakePhoto.mockResolvedValue({path: '/tmp/photos/shot.jpg', width: 3024, height: 4032}); + mockedUseCameraDevice.mockReturnValue(BACK_DEVICE); + mockedUseCameraDevices.mockReturnValue([BACK_DEVICE, FRONT_DEVICE]); + await act(async () => { + await Onyx.clear(); + }); + }); + + it('shows the permission prompt when camera access is not granted', async () => { + mockPermissionStatus = 'blocked'; + renderCamera(); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByText(translateLocal('receipt.cameraAccess'))).toBeOnTheScreen(); + expect(screen.getByText(translateLocal('common.continue'))).toBeOnTheScreen(); + // The shutter still renders, but every camera control is disabled until permission is granted. + expect(screen.getByLabelText(translateLocal('receipt.flipCamera'))).toBeDisabled(); + }); + + it('renders the shutter once permission is granted and a device is available', async () => { + renderCamera(); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByLabelText(translateLocal('receipt.shutter'))).toBeOnTheScreen(); + expect(screen.queryByText(translateLocal('receipt.cameraAccess'))).not.toBeOnTheScreen(); + }); + + it('passes the captured photo to onCapture', async () => { + const {onCapture} = renderCamera(); + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getByLabelText(translateLocal('receipt.shutter'))); + await waitForBatchedUpdatesWithAct(); + + expect(mockTakePhoto).toHaveBeenCalledTimes(1); + expect(onCapture).toHaveBeenCalledWith([expect.objectContaining({fileName: 'shot.jpg', type: 'image/jpeg', width: 3024, height: 4032})]); + }); + + it('does not capture twice while a capture is already in flight', async () => { + renderCamera(); + await waitForBatchedUpdatesWithAct(); + + const shutter = screen.getByLabelText(translateLocal('receipt.shutter')); + fireEvent.press(shutter); + fireEvent.press(shutter); + await waitForBatchedUpdatesWithAct(); + + expect(mockTakePhoto).toHaveBeenCalledTimes(1); + }); + + it('surfaces a capture failure instead of failing silently', async () => { + mockTakePhoto.mockRejectedValueOnce(new Error('capture failed')); + const {onCapture} = renderCamera(); + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getByLabelText(translateLocal('receipt.shutter'))); + await waitForBatchedUpdatesWithAct(); + + expect(onCapture).not.toHaveBeenCalled(); + }); + + it('calls onClose when the close button is pressed', async () => { + const {onClose} = renderCamera(); + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getByLabelText(translateLocal('common.close'))); + await waitForBatchedUpdatesWithAct(); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('disables the flip control when only one camera position exists', async () => { + mockedUseCameraDevices.mockReturnValue([BACK_DEVICE]); + renderCamera(); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByLabelText(translateLocal('receipt.flipCamera'))).toBeDisabled(); + }); + + it('keeps the flip control enabled when both positions exist', async () => { + renderCamera(); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByLabelText(translateLocal('receipt.flipCamera'))).not.toBeDisabled(); + }); + + it('does not attempt a capture when no device is resolved yet', async () => { + mockedUseCameraDevice.mockReturnValue(undefined); + const {onCapture} = renderCamera(); + await waitForBatchedUpdatesWithAct(); + + // Permission is granted, so the prompt is gone, but there is no camera to shoot with yet. + expect(screen.queryByText(translateLocal('receipt.cameraAccess'))).not.toBeOnTheScreen(); + + fireEvent.press(screen.getByLabelText(translateLocal('receipt.shutter'))); + await waitForBatchedUpdatesWithAct(); + + expect(mockTakePhoto).not.toHaveBeenCalled(); + expect(onCapture).not.toHaveBeenCalled(); + }); +});