From 6ba09d95e6abfcaba52ec1edf415f6894b131085 Mon Sep 17 00:00:00 2001 From: "Shridhar Goel (via MelvinBot)" Date: Tue, 10 Mar 2026 16:52:25 +0000 Subject: [PATCH 01/25] fix: use in-app VisionCamera for Android chat attachments On Android, tapping "Take photo" in the attachment picker launches the system camera intent, which backgrounds Expensify. The OS can reclaim the app's memory during post-capture processing, causing a crash when returning from the camera. Replace the external camera intent with an in-app VisionCamera modal on Android. This keeps Expensify in the foreground during photo capture, eliminating the memory reclaim window. The change only affects Android; iOS continues to use the existing external camera. Fixed Issues: Expensify/App#84018 Co-authored-by: Shridhar Goel --- .../AttachmentPicker/AttachmentCamera.tsx | 257 ++++++++++++++++++ .../AttachmentPicker/index.native.tsx | 58 +++- 2 files changed, 312 insertions(+), 3 deletions(-) create mode 100644 src/components/AttachmentPicker/AttachmentCamera.tsx diff --git a/src/components/AttachmentPicker/AttachmentCamera.tsx b/src/components/AttachmentPicker/AttachmentCamera.tsx new file mode 100644 index 0000000000000..12afc56daf477 --- /dev/null +++ b/src/components/AttachmentPicker/AttachmentCamera.tsx @@ -0,0 +1,257 @@ +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 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; +}; + +// Spacer to match the flash toggle width for centering the shutter button +const spacerStyle = {width: 32, height: 32} as const; + +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', 'BackArrow']); + const lazyIllustrations = useMemoizedLazyIllustrations(['Shutter', 'Hand']); + + const camera = useRef(null); + const [cameraPermissionStatus, setCameraPermissionStatus] = useState(null); + const isCapturing = useRef(false); + + const device = useCameraDevice('back', { + physicalDevices: ['wide-angle-camera', 'ultra-wide-angle-camera'], + }); + const format = useCameraFormat(device, [{photoAspectRatio: 4 / 3}, {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')} +