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')}
+
+
+ )}
+ {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..24296c5a2f6a7 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 = useCallback((): 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 d4274e9e7f5e1..041be5dd02f48 100644
--- a/src/components/Icon/chunks/expensify-icons.chunk.ts
+++ b/src/components/Icon/chunks/expensify-icons.chunk.ts
@@ -35,6 +35,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';
@@ -288,6 +289,7 @@ const Expensicons = {
Buildings,
Calendar,
Camera,
+ CameraFlip,
Car,
CarPlus,
Cash,
diff --git a/src/languages/de.ts b/src/languages/de.ts
index 48259e0e9cfc8..8833f8cbdaa53 100644
--- a/src/languages/de.ts
+++ b/src/languages/de.ts
@@ -1165,6 +1165,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 3e6c0039bedcf..3dfcceb727cf0 100644
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -1221,6 +1221,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 dc989bf98c399..f325070ed980b 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -1086,6 +1086,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 23d84087b8401..c39eac6c03a07 100644
--- a/src/languages/fr.ts
+++ b/src/languages/fr.ts
@@ -1169,6 +1169,7 @@ const translations: TranslationDeepObject = {
flash: 'flash',
multiScan: 'numérisation multiple',
shutter: 'obturateur',
+ flipCamera: 'inverser la 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 92b3a0ec43279..e5857cf2c32ec 100644
--- a/src/languages/it.ts
+++ b/src/languages/it.ts
@@ -1167,6 +1167,7 @@ const translations: TranslationDeepObject = {
flash: 'flash',
multiScan: 'scansione multipla',
shutter: 'otturatore',
+ flipCamera: 'capovolgi 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 da263fc720b8d..ce36d9e7715ce 100644
--- a/src/languages/ja.ts
+++ b/src/languages/ja.ts
@@ -1150,6 +1150,7 @@ const translations: TranslationDeepObject = {
flash: 'フラッシュ',
multiScan: 'マルチスキャン',
shutter: 'シャッター',
+ flipCamera: 'カメラを反転',
gallery: 'ギャラリー',
deleteReceipt: '領収書を削除',
deleteConfirmation: 'この領収書を削除してもよろしいですか?',
diff --git a/src/languages/nl.ts b/src/languages/nl.ts
index 44da67b5ca033..d183191f0d7e4 100644
--- a/src/languages/nl.ts
+++ b/src/languages/nl.ts
@@ -1166,6 +1166,7 @@ const translations: TranslationDeepObject = {
flash: 'flits',
multiScan: 'meerscannen',
shutter: 'sluiter',
+ flipCamera: 'camera omdraaien',
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 0af572aa767a1..251ed56fbbac0 100644
--- a/src/languages/pl.ts
+++ b/src/languages/pl.ts
@@ -1165,6 +1165,7 @@ const translations: TranslationDeepObject = {
flash: 'błysk',
multiScan: 'wielokrotne skanowanie',
shutter: 'migawka',
+ flipCamera: 'obróć 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 6ecf8c4d4141a..066c11d75a68d 100644
--- a/src/languages/pt-BR.ts
+++ b/src/languages/pt-BR.ts
@@ -1165,6 +1165,7 @@ const translations: TranslationDeepObject = {
flash: 'flash',
multiScan: 'escaneamento múltiplo',
shutter: 'obturador',
+ flipCamera: 'inverter 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 4c1003a161000..7ebd3c338b7d6 100644
--- a/src/languages/zh-hans.ts
+++ b/src/languages/zh-hans.ts
@@ -1126,6 +1126,7 @@ const translations: TranslationDeepObject = {
flash: '闪光',
multiScan: '多重扫描',
shutter: '快门',
+ flipCamera: '翻转摄像头',
gallery: '图库',
deleteReceipt: '删除收据',
deleteConfirmation: '确定要删除这张收据吗?',