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();
+ });
+});