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/CONST/index.ts b/src/CONST/index.ts
index 856389fb39cc0..2f9c3d7ec2b7d 100644
--- a/src/CONST/index.ts
+++ b/src/CONST/index.ts
@@ -1266,6 +1266,7 @@ const CONST = {
SHUTTER_SIZE: 90,
MAX_REPORT_PREVIEW_RECEIPTS: 3,
FLASH_DELAY_MS: 2000,
+ PHOTO_ASPECT_RATIO: 4 / 3,
},
RECEIPT_PREVIEW_TOP_BOTTOM_MARGIN: 120,
REPORT: {
diff --git a/src/components/AttachmentPicker/AttachmentCamera.tsx b/src/components/AttachmentPicker/AttachmentCamera.tsx
new file mode 100644
index 0000000000000..080407d17691f
--- /dev/null
+++ b/src/components/AttachmentPicker/AttachmentCamera.tsx
@@ -0,0 +1,253 @@
+import React, {useCallback, useEffect, useRef, useState} from 'react';
+import {Alert, 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.PHOTO_ASPECT_RATIO}, {photoResolution: 'max'}]);
+ const cameraAspectRatio = format ? format.photoHeight / format.photoWidth : undefined;
+ const hasFlash = !!device?.hasFlash;
+
+ // Request camera permissions when modal opens
+ useEffect(() => {
+ if (!isVisible) {
+ return;
+ }
+
+ CameraPermission.getCameraPermissionStatus?.()
+ .then((status) => {
+ if (status === RESULTS.DENIED) {
+ return CameraPermission.requestCameraPermission?.().then(setCameraPermissionStatus);
+ }
+ setCameraPermissionStatus(status);
+ })
+ .catch(() => setCameraPermissionStatus(RESULTS.UNAVAILABLE));
+ }, [isVisible]);
+
+ const [flash, setFlash] = useState(false);
+
+ const askForPermissions = useCallback(() => {
+ CameraPermission.requestCameraPermission?.()
+ .then((status: string) => {
+ setCameraPermissionStatus(status);
+ if (status === RESULTS.BLOCKED) {
+ showCameraPermissionsAlert(translate);
+ }
+ })
+ .catch(() => setCameraPermissionStatus(RESULTS.UNAVAILABLE));
+ }, [translate]);
+
+ const capturePhoto = useCallback(() => {
+ if (!camera.current || isCapturing.current) {
+ return;
+ }
+
+ if (cameraPermissionStatus === RESULTS.DENIED || cameraPermissionStatus === RESULTS.BLOCKED) {
+ askForPermissions();
+ 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')}
+
+
+ )}
+ {cameraPermissionStatus === RESULTS.GRANTED && device == null && (
+
+
+
+ )}
+ {cameraPermissionStatus === RESULTS.GRANTED && device != null && (
+
+
+
+
+
+ )}
+
+
+ {/* Bottom controls */}
+
+ {/* Flash toggle */}
+ setFlash((prev) => !prev)}
+ sentryLabel="AttachmentCamera-FlashToggle"
+ >
+
+
+
+ {/* Shutter button */}
+
+
+
+
+ {/* Camera flip button */}
+ setCameraPosition((prev) => (prev === 'back' ? 'front' : 'back'))}
+ sentryLabel="AttachmentCamera-FlipCamera"
+ >
+
+
+
+
+
+ );
+}
+
+AttachmentCamera.displayName = 'AttachmentCamera';
+
+export default AttachmentCamera;
+export type {CapturedPhoto};
diff --git a/src/components/AttachmentPicker/index.native.tsx b/src/components/AttachmentPicker/index.native.tsx
index 076b44e5feb82..7463e2b4add41 100644
--- a/src/components/AttachmentPicker/index.native.tsx
+++ b/src/components/AttachmentPicker/index.native.tsx
@@ -24,7 +24,8 @@ import CONST from '@src/CONST';
import type {TranslationPaths} from '@src/languages/types';
import type {FileObject, ImagePickerResponse as FileResponse} from '@src/types/utils/Attachment';
import type IconAsset from '@src/types/utils/IconAsset';
-import launchCamera from './launchCamera/launchCamera';
+import AttachmentCamera from './AttachmentCamera';
+import type {CapturedPhoto} from './AttachmentCamera';
import type AttachmentPickerProps from './types';
type LocalCopy = {
@@ -136,6 +137,10 @@ function AttachmentPicker({
const onClosed = useRef<() => void>(() => {});
const popoverRef = useRef(null);
+ // In-app camera state — uses VisionCamera to keep the app in the foreground during photo capture
+ const [showAttachmentCamera, setShowAttachmentCamera] = useState(false);
+ const cameraResolveRef = useRef<((photos?: CapturedPhoto[]) => void) | null>(null);
+
const {translate} = useLocalize();
const {shouldUseNarrowLayout} = useResponsiveLayout();
@@ -149,6 +154,43 @@ function AttachmentPicker({
[translate],
);
+ /**
+ * Launch the in-app camera using VisionCamera.
+ * Returns a Promise that resolves with the captured photo as an Asset-compatible object,
+ * or resolves with void if the user closes the camera without capturing.
+ */
+ const launchInAppCamera = (): Promise => {
+ return new Promise((resolve) => {
+ cameraResolveRef.current = (photos?: CapturedPhoto[]) => {
+ if (!photos || photos.length === 0) {
+ resolve();
+ return;
+ }
+ const assets: Asset[] = photos.map((photo) => ({
+ uri: photo.uri,
+ fileName: photo.fileName,
+ type: photo.type,
+ width: photo.width,
+ height: photo.height,
+ }));
+ resolve(assets);
+ };
+ setShowAttachmentCamera(true);
+ });
+ };
+
+ const handleCameraCapture = (photos: CapturedPhoto[]) => {
+ setShowAttachmentCamera(false);
+ cameraResolveRef.current?.(photos);
+ cameraResolveRef.current = null;
+ };
+
+ const handleCameraClose = () => {
+ setShowAttachmentCamera(false);
+ cameraResolveRef.current?.();
+ cameraResolveRef.current = null;
+ };
+
/**
* Common image picker handling
*
@@ -301,12 +343,12 @@ function AttachmentPicker({
data.unshift({
icon: icons.Camera,
textTranslationKey: 'attachmentPicker.takePhoto',
- pickAttachment: () => showImagePicker(launchCamera),
+ pickAttachment: launchInAppCamera,
});
}
return data;
- }, [icons.Camera, icons.Paperclip, icons.Gallery, showDocumentPicker, shouldHideGalleryOption, shouldHideCameraOption, showImagePicker]);
+ }, [icons.Camera, icons.Paperclip, icons.Gallery, showDocumentPicker, shouldHideGalleryOption, shouldHideCameraOption, showImagePicker, launchInAppCamera]);
const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({initialFocusedIndex: -1, maxIndex: menuItemData.length - 1, isActive: isVisible});
@@ -528,6 +570,13 @@ function AttachmentPicker({
))}
+ {showAttachmentCamera && (
+
+ )}
{renderChildren()}
>
);
diff --git a/src/components/Icon/chunks/expensify-icons.chunk.ts b/src/components/Icon/chunks/expensify-icons.chunk.ts
index b9d067388d1e3..5aafb9e76a2db 100644
--- a/src/components/Icon/chunks/expensify-icons.chunk.ts
+++ b/src/components/Icon/chunks/expensify-icons.chunk.ts
@@ -34,6 +34,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 CarPlus from '@assets/images/car-plus.svg';
import CarWithKey from '@assets/images/car-with-key.svg';
@@ -284,6 +285,7 @@ const Expensicons = {
Buildings,
Calendar,
Camera,
+ CameraFlip,
Car,
CarPlus,
Cash,
diff --git a/src/languages/de.ts b/src/languages/de.ts
index 1020b775aa0f3..dde4b56e06289 100644
--- a/src/languages/de.ts
+++ b/src/languages/de.ts
@@ -1144,6 +1144,7 @@ const translations: TranslationDeepObject = {
flash: 'Blitz',
multiScan: 'Mehrfachscan',
shutter: 'Verschluss',
+ flipCamera: 'Kamera wechseln',
gallery: 'Galerie',
deleteReceipt: 'Beleg löschen',
deleteConfirmation: 'Sind Sie sicher, dass Sie diesen Beleg löschen möchten?',
diff --git a/src/languages/en.ts b/src/languages/en.ts
index d94afeb0cd9a0..bdc6f27ca3df9 100644
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -1196,6 +1196,7 @@ const translations = {
flash: 'flash',
multiScan: 'multi-scan',
shutter: 'shutter',
+ flipCamera: 'flip camera',
gallery: 'gallery',
deleteReceipt: 'Delete receipt',
deleteConfirmation: 'Are you sure you want to delete this receipt?',
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 730a9c984b35a..98871387931d8 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -1064,6 +1064,7 @@ const translations: TranslationDeepObject = {
flash: 'flash',
multiScan: 'escaneo múltiple',
shutter: 'obturador',
+ flipCamera: 'cambiar cámara',
gallery: 'galería',
deleteReceipt: 'Eliminar recibo',
deleteConfirmation: '¿Estás seguro de que quieres borrar este recibo?',
diff --git a/src/languages/fr.ts b/src/languages/fr.ts
index d28b575859d2f..c2e3a574d1308 100644
--- a/src/languages/fr.ts
+++ b/src/languages/fr.ts
@@ -1148,6 +1148,7 @@ const translations: TranslationDeepObject = {
flash: 'flash',
multiScan: 'numérisation multiple',
shutter: 'obturateur',
+ flipCamera: 'changer de caméra',
gallery: 'galerie',
deleteReceipt: 'Supprimer le reçu',
deleteConfirmation: 'Voulez-vous vraiment supprimer ce reçu ?',
diff --git a/src/languages/it.ts b/src/languages/it.ts
index ee44d4b32ffc9..7e5d2c2e8f5e1 100644
--- a/src/languages/it.ts
+++ b/src/languages/it.ts
@@ -1143,6 +1143,7 @@ const translations: TranslationDeepObject = {
flash: 'flash',
multiScan: 'scansione multipla',
shutter: 'otturatore',
+ flipCamera: 'cambia fotocamera',
gallery: 'galleria',
deleteReceipt: 'Elimina ricevuta',
deleteConfirmation: 'Sei sicuro di voler eliminare questa ricevuta?',
diff --git a/src/languages/ja.ts b/src/languages/ja.ts
index 39a928b95cca5..21accf4430c78 100644
--- a/src/languages/ja.ts
+++ b/src/languages/ja.ts
@@ -1126,6 +1126,7 @@ const translations: TranslationDeepObject = {
flash: 'フラッシュ',
multiScan: 'マルチスキャン',
shutter: 'シャッター',
+ flipCamera: 'カメラ切替',
gallery: 'ギャラリー',
deleteReceipt: '領収書を削除',
deleteConfirmation: 'この領収書を削除してもよろしいですか?',
diff --git a/src/languages/nl.ts b/src/languages/nl.ts
index 731d636b55984..5f8fb36e021b1 100644
--- a/src/languages/nl.ts
+++ b/src/languages/nl.ts
@@ -1142,6 +1142,7 @@ const translations: TranslationDeepObject = {
flash: 'flits',
multiScan: 'meerscannen',
shutter: 'sluiter',
+ flipCamera: 'camera wisselen',
gallery: 'galerij',
deleteReceipt: 'Bon verwijderen',
deleteConfirmation: 'Weet je zeker dat je deze bon wilt verwijderen?',
diff --git a/src/languages/pl.ts b/src/languages/pl.ts
index 81693e0a5a89a..cd1e233a5956b 100644
--- a/src/languages/pl.ts
+++ b/src/languages/pl.ts
@@ -1142,6 +1142,7 @@ const translations: TranslationDeepObject = {
flash: 'błysk',
multiScan: 'wielokrotne skanowanie',
shutter: 'migawka',
+ flipCamera: 'przełącz kamerę',
gallery: 'galeria',
deleteReceipt: 'Usuń paragon',
deleteConfirmation: 'Czy na pewno chcesz usunąć ten paragon?',
diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts
index c0c3ad36b976d..362069f404a80 100644
--- a/src/languages/pt-BR.ts
+++ b/src/languages/pt-BR.ts
@@ -1141,6 +1141,7 @@ const translations: TranslationDeepObject = {
flash: 'flash',
multiScan: 'escaneamento múltiplo',
shutter: 'obturador',
+ flipCamera: 'trocar câmera',
gallery: 'galeria',
deleteReceipt: 'Excluir recibo',
deleteConfirmation: 'Tem certeza de que deseja excluir este recibo?',
diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts
index 8eab1af69a04e..d6522e80cd8af 100644
--- a/src/languages/zh-hans.ts
+++ b/src/languages/zh-hans.ts
@@ -1106,6 +1106,7 @@ const translations: TranslationDeepObject = {
flash: '闪光',
multiScan: '多重扫描',
shutter: '快门',
+ flipCamera: '切换摄像头',
gallery: '图库',
deleteReceipt: '删除收据',
deleteConfirmation: '确定要删除这张收据吗?',