Skip to content

Commit 0e60bec

Browse files
fix: use in-app VisionCamera for chat attachments with proper permission handling
Replace the external camera intent with an in-app camera modal using react-native-vision-camera for the chat attachment photo flow. This keeps the app in the foreground during capture, preventing OS memory reclaim crashes. Key fix from the previous attempt (PR 86621): the capturePhoto function now checks permission status BEFORE the camera ref, matching the pattern used by IOURequestStepScan. Previously, !camera.current returned early before reaching the permission check, so tapping the shutter with denied permissions did nothing. Also adds an AppState listener to refresh permission status when returning from OS Settings, ensuring the camera view updates automatically after the user grants permission. Co-authored-by: Shridhar Goel <ShridharGoel@users.noreply.github.com>
1 parent 4bc2a13 commit 0e60bec

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
@@ -375,3 +375,10 @@ jest.mock('@src/hooks/useDomainDocumentTitle', () => ({
375375
__esModule: true,
376376
default: jest.fn(),
377377
}));
378+
379+
jest.mock('react-native-vision-camera', () => ({
380+
Camera: 'Camera',
381+
useCameraDevice: jest.fn(() => null),
382+
useCameraFormat: jest.fn(() => null),
383+
useCameraPermission: jest.fn(() => ({hasPermission: false, requestPermission: jest.fn()})),
384+
}));
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
);

src/components/Icon/chunks/expensify-icons.chunk.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import Building from '@assets/images/building.svg';
3535
import Buildings from '@assets/images/buildings.svg';
3636
import CalendarSolid from '@assets/images/calendar-solid.svg';
3737
import Calendar from '@assets/images/calendar.svg';
38+
import CameraFlip from '@assets/images/camera-flip.svg';
3839
import Camera from '@assets/images/camera.svg';
3940
import CarCircleSlash from '@assets/images/car-circle-slash.svg';
4041
import CarPlus from '@assets/images/car-plus.svg';
@@ -288,6 +289,7 @@ const Expensicons = {
288289
Buildings,
289290
Calendar,
290291
Camera,
292+
CameraFlip,
291293
Car,
292294
CarPlus,
293295
Cash,

0 commit comments

Comments
 (0)