diff --git a/assets/images/camera-flip.svg b/assets/images/camera-flip.svg new file mode 100644 index 0000000000000..6d05251e0c777 --- /dev/null +++ b/assets/images/camera-flip.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/jest/setup.ts b/jest/setup.ts index ee2b1702ba1e9..ea2a0d44086ed 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -375,3 +375,10 @@ jest.mock('@src/hooks/useDomainDocumentTitle', () => ({ __esModule: true, default: jest.fn(), })); + +jest.mock('react-native-vision-camera', () => ({ + Camera: 'Camera', + useCameraDevice: jest.fn(() => null), + useCameraFormat: jest.fn(() => null), + useCameraPermission: jest.fn(() => ({hasPermission: false, requestPermission: jest.fn()})), +})); diff --git a/src/components/AttachmentPicker/AttachmentCamera.tsx b/src/components/AttachmentPicker/AttachmentCamera.tsx new file mode 100644 index 0000000000000..7f0b5a7f4db9a --- /dev/null +++ b/src/components/AttachmentPicker/AttachmentCamera.tsx @@ -0,0 +1,276 @@ +import React, {useCallback, useEffect, useRef, useState} from 'react'; +import {Alert, AppState, Modal, View} from 'react-native'; +import {RESULTS} from 'react-native-permissions'; +import type {Camera, PhotoFile} from 'react-native-vision-camera'; +import {useCameraDevice, useCameraFormat, Camera as VisionCamera} from 'react-native-vision-camera'; +import ActivityIndicator from '@components/ActivityIndicator'; +import Button from '@components/Button'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import Icon from '@components/Icon'; +import ImageSVG from '@components/ImageSVG'; +import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; +import Text from '@components/Text'; +import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {showCameraPermissionsAlert} from '@libs/fileDownload/FileUtils'; +import getPhotoSource from '@libs/fileDownload/getPhotoSource'; +import Log from '@libs/Log'; +import CameraPermission from '@pages/iou/request/step/IOURequestStepScan/CameraPermission'; +import CONST from '@src/CONST'; + +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 without capturing */ + onClose: () => void; +}; + +function AttachmentCamera({isVisible, onCapture, onClose}: AttachmentCameraProps) { + const theme = useTheme(); + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {translate} = useLocalize(); + const insets = useSafeAreaInsets(); + + const lazyIcons = useMemoizedLazyExpensifyIcons(['Bolt', 'boltSlash', 'CameraFlip']); + const lazyIllustrations = useMemoizedLazyIllustrations(['Shutter', 'Hand']); + + const camera = useRef(null); + const [cameraPermissionStatus, setCameraPermissionStatus] = useState(null); + const isCapturing = useRef(false); + const [cameraPosition, setCameraPosition] = useState<'back' | 'front'>('back'); + + const device = useCameraDevice(cameraPosition, { + physicalDevices: ['wide-angle-camera', 'ultra-wide-angle-camera'], + }); + const format = useCameraFormat(device, [{photoAspectRatio: CONST.RECEIPT_CAMERA.PHOTO_ASPECT_RATIO}, {photoResolution: 'max'}]); + const cameraAspectRatio = format ? format.photoHeight / format.photoWidth : undefined; + const hasFlash = !!device?.hasFlash; + + // Check camera permissions when modal opens and refresh when app returns to foreground + useEffect(() => { + if (!isVisible) { + return; + } + + const refreshCameraPermissionStatus = () => { + CameraPermission.getCameraPermissionStatus?.() + .then(setCameraPermissionStatus) + .catch(() => setCameraPermissionStatus(RESULTS.UNAVAILABLE)); + }; + + // Initial permission check — request if not yet asked + CameraPermission.getCameraPermissionStatus?.() + .then((status) => { + if (status === RESULTS.DENIED) { + return CameraPermission.requestCameraPermission?.().then(setCameraPermissionStatus); + } + setCameraPermissionStatus(status); + }) + .catch(() => setCameraPermissionStatus(RESULTS.UNAVAILABLE)); + + // Refresh permission when the app returns to foreground (e.g. after granting in OS Settings) + const subscription = AppState.addEventListener('change', (appState) => { + if (appState !== 'active') { + return; + } + refreshCameraPermissionStatus(); + }); + + return () => { + subscription.remove(); + }; + }, [isVisible]); + + const [flash, setFlash] = useState(false); + + 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]); + + const capturePhoto = useCallback(() => { + // Check permissions first — camera ref will be null when permission is not granted + // because the VisionCamera component is not rendered + if (!camera.current && (cameraPermissionStatus === RESULTS.DENIED || cameraPermissionStatus === RESULTS.BLOCKED)) { + askForPermissions(); + return; + } + + if (!camera.current || isCapturing.current) { + return; + } + + isCapturing.current = true; + + camera.current + .takePhoto({ + flash: flash && hasFlash ? 'on' : 'off', + }) + .then((photo: PhotoFile) => { + const uri = getPhotoSource(photo.path); + const fileName = photo.path.split('/').pop() ?? `photo_${Date.now()}.jpg`; + + onCapture([ + { + uri, + fileName, + type: 'image/jpeg', + width: photo.width, + height: photo.height, + }, + ]); + }) + .catch((error: unknown) => { + Log.warn('AttachmentCamera: Error taking photo', {error}); + Alert.alert(translate('receipt.cameraErrorTitle'), translate('receipt.cameraErrorMessage')); + }) + .finally(() => { + isCapturing.current = false; + }); + }, [cameraPermissionStatus, flash, hasFlash, onCapture, translate, askForPermissions]); + + return ( + + + + {/* Camera viewfinder area */} + + {cameraPermissionStatus !== RESULTS.GRANTED && ( + + + {translate('receipt.takePhoto')} + {translate('receipt.cameraAccess')} +