Skip to content

Commit b96c699

Browse files
authored
Merge pull request Expensify#86981 from Expensify/claude-useInAppCameraForAttachments
fix: use in-app VisionCamera for chat attachments with proper permission handling
2 parents f066c17 + 8bd806e commit b96c699

15 files changed

Lines changed: 348 additions & 3 deletions

File tree

assets/images/camera-flip.svg

Lines changed: 1 addition & 0 deletions
Loading

jest/setup.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,3 +385,10 @@ jest.mock('@src/hooks/useDomainDocumentTitle', () => ({
385385
__esModule: true,
386386
default: jest.fn(),
387387
}));
388+
389+
jest.mock('react-native-vision-camera', () => ({
390+
Camera: 'Camera',
391+
useCameraDevice: jest.fn(() => null),
392+
useCameraFormat: jest.fn(() => null),
393+
useCameraPermission: jest.fn(() => ({hasPermission: false, requestPermission: jest.fn()})),
394+
}));
Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
import React, {useCallback, useEffect, useRef, useState} from 'react';
2+
import {Alert, AppState, Modal, View} from 'react-native';
3+
import {RESULTS} from 'react-native-permissions';
4+
import type {Camera, PhotoFile} from 'react-native-vision-camera';
5+
import {useCameraDevice, useCameraFormat, Camera as VisionCamera} from 'react-native-vision-camera';
6+
import ActivityIndicator from '@components/ActivityIndicator';
7+
import Button from '@components/Button';
8+
import HeaderWithBackButton from '@components/HeaderWithBackButton';
9+
import Icon from '@components/Icon';
10+
import ImageSVG from '@components/ImageSVG';
11+
import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
12+
import Text from '@components/Text';
13+
import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset';
14+
import useLocalize from '@hooks/useLocalize';
15+
import useSafeAreaInsets from '@hooks/useSafeAreaInsets';
16+
import useStyleUtils from '@hooks/useStyleUtils';
17+
import useTheme from '@hooks/useTheme';
18+
import useThemeStyles from '@hooks/useThemeStyles';
19+
import {showCameraPermissionsAlert} from '@libs/fileDownload/FileUtils';
20+
import getPhotoSource from '@libs/fileDownload/getPhotoSource';
21+
import Log from '@libs/Log';
22+
import CameraPermission from '@pages/iou/request/step/IOURequestStepScan/CameraPermission';
23+
import CONST from '@src/CONST';
24+
25+
type CapturedPhoto = {
26+
uri: string;
27+
fileName: string;
28+
type: string;
29+
width: number;
30+
height: number;
31+
};
32+
33+
type AttachmentCameraProps = {
34+
/** Whether the camera modal is visible */
35+
isVisible: boolean;
36+
37+
/** Callback when a photo is captured */
38+
onCapture: (photos: CapturedPhoto[]) => void;
39+
40+
/** Callback when the camera is closed without capturing */
41+
onClose: () => void;
42+
};
43+
44+
function AttachmentCamera({isVisible, onCapture, onClose}: AttachmentCameraProps) {
45+
const theme = useTheme();
46+
const styles = useThemeStyles();
47+
const StyleUtils = useStyleUtils();
48+
const {translate} = useLocalize();
49+
const insets = useSafeAreaInsets();
50+
51+
const lazyIcons = useMemoizedLazyExpensifyIcons(['Bolt', 'boltSlash', 'CameraFlip']);
52+
const lazyIllustrations = useMemoizedLazyIllustrations(['Shutter', 'Hand']);
53+
54+
const camera = useRef<Camera>(null);
55+
const [cameraPermissionStatus, setCameraPermissionStatus] = useState<string | null>(null);
56+
const isCapturing = useRef(false);
57+
const [cameraPosition, setCameraPosition] = useState<'back' | 'front'>('back');
58+
59+
const device = useCameraDevice(cameraPosition, {
60+
physicalDevices: ['wide-angle-camera', 'ultra-wide-angle-camera'],
61+
});
62+
const format = useCameraFormat(device, [{photoAspectRatio: CONST.RECEIPT_CAMERA.PHOTO_ASPECT_RATIO}, {photoResolution: 'max'}]);
63+
const cameraAspectRatio = format ? format.photoHeight / format.photoWidth : undefined;
64+
const hasFlash = !!device?.hasFlash;
65+
66+
// Check camera permissions when modal opens and refresh when app returns to foreground
67+
useEffect(() => {
68+
if (!isVisible) {
69+
return;
70+
}
71+
72+
const refreshCameraPermissionStatus = () => {
73+
CameraPermission.getCameraPermissionStatus?.()
74+
.then(setCameraPermissionStatus)
75+
.catch(() => setCameraPermissionStatus(RESULTS.UNAVAILABLE));
76+
};
77+
78+
// Initial permission check — request if not yet asked
79+
CameraPermission.getCameraPermissionStatus?.()
80+
.then((status) => {
81+
if (status === RESULTS.DENIED) {
82+
return CameraPermission.requestCameraPermission?.().then(setCameraPermissionStatus);
83+
}
84+
setCameraPermissionStatus(status);
85+
})
86+
.catch(() => setCameraPermissionStatus(RESULTS.UNAVAILABLE));
87+
88+
// Refresh permission when the app returns to foreground (e.g. after granting in OS Settings)
89+
const subscription = AppState.addEventListener('change', (appState) => {
90+
if (appState !== 'active') {
91+
return;
92+
}
93+
refreshCameraPermissionStatus();
94+
});
95+
96+
return () => {
97+
subscription.remove();
98+
};
99+
}, [isVisible]);
100+
101+
const [flash, setFlash] = useState(false);
102+
103+
const askForPermissions = useCallback(() => {
104+
// There's no way we can check for the BLOCKED status without requesting the permission first
105+
// https://github.com/zoontek/react-native-permissions/blob/a836e114ce3a180b2b23916292c79841a267d828/README.md?plain=1#L670
106+
CameraPermission.requestCameraPermission?.()
107+
.then((status: string) => {
108+
setCameraPermissionStatus(status);
109+
if (status === RESULTS.BLOCKED) {
110+
showCameraPermissionsAlert(translate);
111+
}
112+
})
113+
.catch(() => setCameraPermissionStatus(RESULTS.UNAVAILABLE));
114+
}, [translate]);
115+
116+
const capturePhoto = useCallback(() => {
117+
// Check permissions first — camera ref will be null when permission is not granted
118+
// because the VisionCamera component is not rendered
119+
if (!camera.current && (cameraPermissionStatus === RESULTS.DENIED || cameraPermissionStatus === RESULTS.BLOCKED)) {
120+
askForPermissions();
121+
return;
122+
}
123+
124+
if (!camera.current || isCapturing.current) {
125+
return;
126+
}
127+
128+
isCapturing.current = true;
129+
130+
camera.current
131+
.takePhoto({
132+
flash: flash && hasFlash ? 'on' : 'off',
133+
})
134+
.then((photo: PhotoFile) => {
135+
const uri = getPhotoSource(photo.path);
136+
const fileName = photo.path.split('/').pop() ?? `photo_${Date.now()}.jpg`;
137+
138+
onCapture([
139+
{
140+
uri,
141+
fileName,
142+
type: 'image/jpeg',
143+
width: photo.width,
144+
height: photo.height,
145+
},
146+
]);
147+
})
148+
.catch((error: unknown) => {
149+
Log.warn('AttachmentCamera: Error taking photo', {error});
150+
Alert.alert(translate('receipt.cameraErrorTitle'), translate('receipt.cameraErrorMessage'));
151+
})
152+
.finally(() => {
153+
isCapturing.current = false;
154+
});
155+
}, [cameraPermissionStatus, flash, hasFlash, onCapture, translate, askForPermissions]);
156+
157+
return (
158+
<Modal
159+
visible={isVisible}
160+
animationType="slide"
161+
presentationStyle="fullScreen"
162+
statusBarTranslucent
163+
onRequestClose={onClose}
164+
>
165+
<View style={[styles.flex1, StyleUtils.getBackgroundColorStyle(theme.appBG), {paddingTop: insets.top}]}>
166+
<HeaderWithBackButton onBackButtonPress={onClose} />
167+
{/* Camera viewfinder area */}
168+
<View style={styles.flex1}>
169+
{cameraPermissionStatus !== RESULTS.GRANTED && (
170+
<View style={[styles.cameraView, styles.permissionView, styles.userSelectNone]}>
171+
<ImageSVG
172+
contentFit="contain"
173+
src={lazyIllustrations.Hand}
174+
width={CONST.RECEIPT.HAND_ICON_WIDTH}
175+
height={CONST.RECEIPT.HAND_ICON_HEIGHT}
176+
style={styles.pb5}
177+
/>
178+
<Text style={[styles.textFileUpload]}>{translate('receipt.takePhoto')}</Text>
179+
<Text style={[styles.subTextFileUpload]}>{translate('receipt.cameraAccess')}</Text>
180+
<Button
181+
success
182+
text={translate('common.continue')}
183+
accessibilityLabel={translate('common.continue')}
184+
style={[styles.p9, styles.pt5]}
185+
onPress={askForPermissions}
186+
/>
187+
</View>
188+
)}
189+
{cameraPermissionStatus === RESULTS.GRANTED && device == null && (
190+
<View style={styles.cameraView}>
191+
<ActivityIndicator
192+
size={CONST.ACTIVITY_INDICATOR_SIZE.LARGE}
193+
style={styles.flex1}
194+
color={theme.textSupporting}
195+
reasonAttributes={{context: 'AttachmentCamera.deviceLoading'}}
196+
/>
197+
</View>
198+
)}
199+
{cameraPermissionStatus === RESULTS.GRANTED && device != null && (
200+
<View style={[styles.cameraView, styles.alignItemsCenter]}>
201+
<View style={StyleUtils.getCameraViewfinderStyle(cameraAspectRatio)}>
202+
<VisionCamera
203+
ref={camera}
204+
device={device}
205+
format={format}
206+
style={styles.flex1}
207+
zoom={device.neutralZoom}
208+
photo
209+
isActive={isVisible}
210+
photoQualityBalance="speed"
211+
/>
212+
</View>
213+
</View>
214+
)}
215+
</View>
216+
217+
{/* Bottom controls */}
218+
<View style={[styles.flexRow, styles.justifyContentAround, styles.alignItemsCenter, styles.pv3, {paddingBottom: insets.bottom + 12}]}>
219+
{/* Flash toggle */}
220+
<PressableWithFeedback
221+
role={CONST.ROLE.BUTTON}
222+
accessibilityLabel={translate('receipt.flash')}
223+
style={[styles.alignItemsEnd, !hasFlash && styles.opacity0]}
224+
disabled={!hasFlash}
225+
onPress={() => setFlash((prev) => !prev)}
226+
sentryLabel="AttachmentCamera-FlashToggle"
227+
>
228+
<Icon
229+
height={32}
230+
width={32}
231+
src={flash ? lazyIcons.Bolt : lazyIcons.boltSlash}
232+
fill={theme.textSupporting}
233+
/>
234+
</PressableWithFeedback>
235+
236+
{/* Shutter button */}
237+
<PressableWithFeedback
238+
role={CONST.ROLE.BUTTON}
239+
accessibilityLabel={translate('receipt.shutter')}
240+
style={styles.alignItemsCenter}
241+
onPress={capturePhoto}
242+
sentryLabel="AttachmentCamera-Shutter"
243+
>
244+
<ImageSVG
245+
contentFit="contain"
246+
src={lazyIllustrations.Shutter}
247+
width={CONST.RECEIPT.SHUTTER_SIZE}
248+
height={CONST.RECEIPT.SHUTTER_SIZE}
249+
/>
250+
</PressableWithFeedback>
251+
252+
{/* Camera flip button */}
253+
<PressableWithFeedback
254+
role={CONST.ROLE.BUTTON}
255+
accessibilityLabel={translate('receipt.flipCamera')}
256+
style={styles.alignItemsEnd}
257+
onPress={() => setCameraPosition((prev) => (prev === 'back' ? 'front' : 'back'))}
258+
sentryLabel="AttachmentCamera-FlipCamera"
259+
>
260+
<Icon
261+
height={32}
262+
width={32}
263+
src={lazyIcons.CameraFlip}
264+
fill={theme.textSupporting}
265+
/>
266+
</PressableWithFeedback>
267+
</View>
268+
</View>
269+
</Modal>
270+
);
271+
}
272+
273+
AttachmentCamera.displayName = 'AttachmentCamera';
274+
275+
export default AttachmentCamera;
276+
export type {CapturedPhoto};

src/components/AttachmentPicker/index.native.tsx

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ import CONST from '@src/CONST';
2424
import type {TranslationPaths} from '@src/languages/types';
2525
import type {FileObject, ImagePickerResponse as FileResponse} from '@src/types/utils/Attachment';
2626
import type IconAsset from '@src/types/utils/IconAsset';
27-
import launchCamera from './launchCamera/launchCamera';
27+
import AttachmentCamera from './AttachmentCamera';
28+
import type {CapturedPhoto} from './AttachmentCamera';
2829
import type AttachmentPickerProps from './types';
2930

3031
type LocalCopy = {
@@ -136,6 +137,10 @@ function AttachmentPicker({
136137
const onClosed = useRef<() => void>(() => {});
137138
const popoverRef = useRef(null);
138139

140+
// In-app camera state — uses VisionCamera to keep the app in the foreground during photo capture
141+
const [showAttachmentCamera, setShowAttachmentCamera] = useState(false);
142+
const cameraResolveRef = useRef<((photos?: CapturedPhoto[]) => void) | null>(null);
143+
139144
const {translate} = useLocalize();
140145
const {shouldUseNarrowLayout} = useResponsiveLayout();
141146

@@ -149,6 +154,43 @@ function AttachmentPicker({
149154
[translate],
150155
);
151156

157+
/**
158+
* Launch the in-app camera using VisionCamera.
159+
* Returns a Promise that resolves with the captured photo as an Asset-compatible object,
160+
* or resolves with void if the user closes the camera without capturing.
161+
*/
162+
const launchInAppCamera = useCallback((): Promise<Asset[] | void> => {
163+
return new Promise((resolve) => {
164+
cameraResolveRef.current = (photos?: CapturedPhoto[]) => {
165+
if (!photos || photos.length === 0) {
166+
resolve();
167+
return;
168+
}
169+
const assets: Asset[] = photos.map((photo) => ({
170+
uri: photo.uri,
171+
fileName: photo.fileName,
172+
type: photo.type,
173+
width: photo.width,
174+
height: photo.height,
175+
}));
176+
resolve(assets);
177+
};
178+
setShowAttachmentCamera(true);
179+
});
180+
}, []);
181+
182+
const handleCameraCapture = (photos: CapturedPhoto[]) => {
183+
setShowAttachmentCamera(false);
184+
cameraResolveRef.current?.(photos);
185+
cameraResolveRef.current = null;
186+
};
187+
188+
const handleCameraClose = () => {
189+
setShowAttachmentCamera(false);
190+
cameraResolveRef.current?.();
191+
cameraResolveRef.current = null;
192+
};
193+
152194
/**
153195
* Common image picker handling
154196
*
@@ -301,12 +343,12 @@ function AttachmentPicker({
301343
data.unshift({
302344
icon: icons.Camera,
303345
textTranslationKey: 'attachmentPicker.takePhoto',
304-
pickAttachment: () => showImagePicker(launchCamera),
346+
pickAttachment: launchInAppCamera,
305347
});
306348
}
307349

308350
return data;
309-
}, [icons.Camera, icons.Paperclip, icons.Gallery, showDocumentPicker, shouldHideGalleryOption, shouldHideCameraOption, showImagePicker]);
351+
}, [icons.Camera, icons.Paperclip, icons.Gallery, showDocumentPicker, shouldHideGalleryOption, shouldHideCameraOption, showImagePicker, launchInAppCamera]);
310352

311353
const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({initialFocusedIndex: -1, maxIndex: menuItemData.length - 1, isActive: isVisible});
312354

@@ -528,6 +570,13 @@ function AttachmentPicker({
528570
))}
529571
</View>
530572
</Popover>
573+
{showAttachmentCamera && (
574+
<AttachmentCamera
575+
isVisible={showAttachmentCamera}
576+
onCapture={handleCameraCapture}
577+
onClose={handleCameraClose}
578+
/>
579+
)}
531580
{renderChildren()}
532581
</>
533582
);

0 commit comments

Comments
 (0)