Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
6ba09d9
fix: use in-app VisionCamera for Android chat attachments
MelvinBot Mar 10, 2026
cd7a480
Fix: add sentryLabel props to Pressable components in AttachmentCamera
MelvinBot Mar 10, 2026
d05cc77
Add Jest mock for react-native-vision-camera
MelvinBot Mar 10, 2026
490a9bd
Use in-app VisionCamera on both Android and iOS
MelvinBot Mar 10, 2026
4c0c714
Add camera flip button to switch between front and back cameras
MelvinBot Mar 13, 2026
8a6c7b8
Add top safe area padding to camera viewfinder
MelvinBot Mar 13, 2026
9e54657
Remove back arrow close button from camera view
MelvinBot Mar 13, 2026
527e77d
Fix: add expo-location mock to jest setup to fix test crash
MelvinBot Mar 13, 2026
814f2f3
Fix: add expo-location mock directly in test file to prevent crash
MelvinBot Mar 13, 2026
77d16ea
Fix: add Accuracy enum to expo-location mock in jest setup
MelvinBot Mar 13, 2026
deb129f
Fix: remove global expo-location mock that broke Accuracy imports
MelvinBot Mar 13, 2026
67758b9
Revert unrelated test changes to IOURequestStepConfirmationPageTest
MelvinBot Mar 13, 2026
d8298e1
Add expo-location jest mock to fix test suite failures
MelvinBot Mar 13, 2026
d9dbcff
Use custom CameraFlip icon for camera flip button
MelvinBot Mar 17, 2026
2e1a94f
Fix: compress SVG and format with Prettier
MelvinBot Mar 17, 2026
6c5cf89
Add HeaderWithBackButton to camera modal for clean close navigation
MelvinBot Mar 19, 2026
d943bab
Fix typecheck: add required reasonAttributes to ActivityIndicator usage
MelvinBot Mar 19, 2026
9d61c41
Revert unrelated changes to ReportUtils.ts and reportAttributes.ts
MelvinBot Mar 19, 2026
9ed186d
Use CONST.RECEIPT.PHOTO_ASPECT_RATIO for camera aspect ratio
MelvinBot Mar 24, 2026
60d7999
Remove unnecessary useCallback from launchInAppCamera, handleCameraCa…
MelvinBot Mar 24, 2026
abd7811
Fix: add missing ONYXKEYS.COLLECTION.REPORT_VIOLATIONS and ReportViol…
MelvinBot Mar 29, 2026
1ee74d3
Revert all ReportUtils changes and dependent reportAttributes changes
MelvinBot Mar 29, 2026
a0f58c8
Merge remote-tracking branch 'origin/claude-useVisionCameraForAttachm…
MelvinBot Mar 29, 2026
34cee68
Revert ONYXKEYS.ts changes (remove REPORT_VIOLATIONS collection key)
MelvinBot Mar 29, 2026
cf1293a
Revert types/onyx changes (remove ReportViolations type)
MelvinBot Mar 29, 2026
0882c50
Revert expo-location mock from jest/setup.ts
MelvinBot Mar 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions assets/images/camera-flip.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 7 additions & 0 deletions jest/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()})),
}));
1 change: 1 addition & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
253 changes: 253 additions & 0 deletions src/components/AttachmentPicker/AttachmentCamera.tsx
Original file line number Diff line number Diff line change
@@ -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<Camera>(null);
const [cameraPermissionStatus, setCameraPermissionStatus] = useState<string | null>(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(() => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ PERF-15 (docs)

This useEffect performs async work (.then() chain) that calls setCameraPermissionStatus, but has no cleanup to discard stale responses. When isVisible changes from true to false, the effect re-runs and hits the early return, but the previous promise may still be in-flight and can call setCameraPermissionStatus after the modal has closed.

Add an ignore flag to prevent stale state updates:

useEffect(() => {
    if (!isVisible) {
        return;
    }

    let ignore = false;

    CameraPermission.getCameraPermissionStatus?.()
        .then((status) => {
            if (ignore) return;
            if (status === RESULTS.DENIED) {
                return CameraPermission.requestCameraPermission?.().then((s) => {
                    if (!ignore) setCameraPermissionStatus(s);
                });
            }
            setCameraPermissionStatus(status);
        })
        .catch(() => {
            if (!ignore) setCameraPermissionStatus(RESULTS.UNAVAILABLE);
        });

    return () => {
        ignore = true;
    };
}, [isVisible]);

Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

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 (
<Modal
visible={isVisible}
animationType="slide"
presentationStyle="fullScreen"
statusBarTranslucent
onRequestClose={onClose}
>
<View style={[styles.flex1, StyleUtils.getBackgroundColorStyle(theme.appBG), {paddingTop: insets.top}]}>
<HeaderWithBackButton onBackButtonPress={onClose} />
{/* Camera viewfinder area */}
<View style={styles.flex1}>
{cameraPermissionStatus !== RESULTS.GRANTED && (
<View style={[styles.cameraView, styles.permissionView, styles.userSelectNone]}>
<ImageSVG
contentFit="contain"
src={lazyIllustrations.Hand}
width={CONST.RECEIPT.HAND_ICON_WIDTH}
height={CONST.RECEIPT.HAND_ICON_HEIGHT}
style={styles.pb5}
/>
<Text style={[styles.textFileUpload]}>{translate('receipt.takePhoto')}</Text>
<Text style={[styles.subTextFileUpload]}>{translate('receipt.cameraAccess')}</Text>
<Button
success
text={translate('common.continue')}
accessibilityLabel={translate('common.continue')}
style={[styles.p9, styles.pt5]}
onPress={askForPermissions}
/>
</View>
)}
{cameraPermissionStatus === RESULTS.GRANTED && device == null && (
<View style={styles.cameraView}>
<ActivityIndicator
size={CONST.ACTIVITY_INDICATOR_SIZE.LARGE}
style={styles.flex1}
color={theme.textSupporting}
reasonAttributes={{context: 'AttachmentCamera.deviceLoading'}}
/>
</View>
)}
{cameraPermissionStatus === RESULTS.GRANTED && device != null && (
<View style={[styles.cameraView, styles.alignItemsCenter]}>
<View style={StyleUtils.getCameraViewfinderStyle(cameraAspectRatio)}>
<VisionCamera
ref={camera}
device={device}
format={format}
style={styles.flex1}
zoom={device.neutralZoom}
photo
isActive={isVisible}
photoQualityBalance="speed"
/>
</View>
</View>
)}
</View>

{/* Bottom controls */}
<View style={[styles.flexRow, styles.justifyContentAround, styles.alignItemsCenter, styles.pv3, {paddingBottom: insets.bottom + 12}]}>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ CONSISTENCY-2 (docs)

The number 12 in {paddingBottom: insets.bottom + 12} is a magic number with no explanation. Extract it to a named constant:

const BOTTOM_CONTROLS_EXTRA_PADDING = 12;

// In the style:
{paddingBottom: insets.bottom + BOTTOM_CONTROLS_EXTRA_PADDING}

Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

{/* Flash toggle */}
<PressableWithFeedback
role={CONST.ROLE.BUTTON}
accessibilityLabel={translate('receipt.flash')}
style={[styles.alignItemsEnd, !hasFlash && styles.opacity0]}
disabled={!hasFlash}
onPress={() => setFlash((prev) => !prev)}
sentryLabel="AttachmentCamera-FlashToggle"
>
<Icon
height={32}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ CONSISTENCY-2 (docs)

The number 32 is used as a hardcoded icon size for height and width in multiple <Icon> components (lines 206 and 238). The codebase defines variables.iconSizeMenuItem (which equals 32) in src/styles/variables.ts. Use the existing named constant:

import variables from '@styles/variables';

<Icon
    height={variables.iconSizeMenuItem}
    width={variables.iconSizeMenuItem}
    ...
/>

Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

width={32}
src={flash ? lazyIcons.Bolt : lazyIcons.boltSlash}
fill={theme.textSupporting}
/>
</PressableWithFeedback>

{/* Shutter button */}
<PressableWithFeedback
role={CONST.ROLE.BUTTON}
accessibilityLabel={translate('receipt.shutter')}
style={styles.alignItemsCenter}
onPress={capturePhoto}
sentryLabel="AttachmentCamera-Shutter"
>
<ImageSVG
contentFit="contain"
src={lazyIllustrations.Shutter}
width={CONST.RECEIPT.SHUTTER_SIZE}
height={CONST.RECEIPT.SHUTTER_SIZE}
/>
</PressableWithFeedback>

{/* Camera flip button */}
<PressableWithFeedback
role={CONST.ROLE.BUTTON}
accessibilityLabel={translate('receipt.flipCamera')}
style={styles.alignItemsEnd}
onPress={() => setCameraPosition((prev) => (prev === 'back' ? 'front' : 'back'))}
sentryLabel="AttachmentCamera-FlipCamera"
>
<Icon
height={32}
width={32}
src={lazyIcons.CameraFlip}
fill={theme.textSupporting}
/>
</PressableWithFeedback>
</View>
</View>
</Modal>
);
}

AttachmentCamera.displayName = 'AttachmentCamera';

export default AttachmentCamera;
export type {CapturedPhoto};
55 changes: 52 additions & 3 deletions src/components/AttachmentPicker/index.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
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 = {
Expand Down Expand Up @@ -136,6 +137,10 @@
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();

Expand All @@ -149,6 +154,43 @@
[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<Asset[] | void> => {

Check warning on line 162 in src/components/AttachmentPicker/index.native.tsx

View workflow job for this annotation

GitHub Actions / ESLint check

The 'launchInAppCamera' function makes the dependencies of useMemo Hook (at line 351) change on every render. Move it inside the useMemo callback. Alternatively, wrap the definition of 'launchInAppCamera' in its own useCallback() Hook

Check warning on line 162 in src/components/AttachmentPicker/index.native.tsx

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

The 'launchInAppCamera' function makes the dependencies of useMemo Hook (at line 351) change on every render. Move it inside the useMemo callback. Alternatively, wrap the definition of 'launchInAppCamera' in its own useCallback() Hook
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
*
Expand Down Expand Up @@ -301,12 +343,12 @@
data.unshift({
icon: icons.Camera,
textTranslationKey: 'attachmentPicker.takePhoto',
pickAttachment: () => showImagePicker(launchCamera),
pickAttachment: launchInAppCamera,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve video capture in file-mode camera picker

In AttachmentPicker file-mode flows (for example chat compose), camera launches previously went through showImagePicker(launchCamera), which uses getImagePickerOptions() and sets mediaType to mixed for ATTACHMENT_PICKER_TYPE.FILE; replacing that with launchInAppCamera means the camera path now only ever returns a JPEG photo and removes direct video capture from the camera. This is a functional regression for users who attach videos from the camera in file-mode pickers.

Useful? React with 👍 / 👎.

});
}

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

Expand Down Expand Up @@ -528,6 +570,13 @@
))}
</View>
</Popover>
{showAttachmentCamera && (
<AttachmentCamera
isVisible={showAttachmentCamera}
onCapture={handleCameraCapture}
onClose={handleCameraClose}
/>
)}
{renderChildren()}
</>
);
Expand Down
2 changes: 2 additions & 0 deletions src/components/Icon/chunks/expensify-icons.chunk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -284,6 +285,7 @@ const Expensicons = {
Buildings,
Calendar,
Camera,
CameraFlip,
Car,
CarPlus,
Cash,
Expand Down
1 change: 1 addition & 0 deletions src/languages/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1144,6 +1144,7 @@ const translations: TranslationDeepObject<typeof en> = {
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?',
Expand Down
Loading
Loading