-
Notifications
You must be signed in to change notification settings - Fork 3.7k
fix: use in-app VisionCamera for chat attachments on Android and iOS (v2) #86621
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6ba09d9
cd7a480
d05cc77
490a9bd
4c0c714
8a6c7b8
9e54657
527e77d
814f2f3
77d16ea
deb129f
67758b9
d8298e1
d9dbcff
2e1a94f
6c5cf89
d943bab
9d61c41
9ed186d
60d7999
abd7811
1ee74d3
a0f58c8
34cee68
cf1293a
0882c50
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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(() => { | ||
| 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}]}> | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ❌ CONSISTENCY-2 (docs)The number 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} | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ❌ CONSISTENCY-2 (docs)The number 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}; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 = { | ||
|
|
@@ -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(); | ||
|
|
||
|
|
@@ -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
|
||
| 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 @@ | |
| data.unshift({ | ||
| icon: icons.Camera, | ||
| textTranslationKey: 'attachmentPicker.takePhoto', | ||
| pickAttachment: () => showImagePicker(launchCamera), | ||
| pickAttachment: launchInAppCamera, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In 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}); | ||
|
|
||
|
|
@@ -528,6 +570,13 @@ | |
| ))} | ||
| </View> | ||
| </Popover> | ||
| {showAttachmentCamera && ( | ||
| <AttachmentCamera | ||
| isVisible={showAttachmentCamera} | ||
| onCapture={handleCameraCapture} | ||
| onClose={handleCameraClose} | ||
| /> | ||
| )} | ||
| {renderChildren()} | ||
| </> | ||
| ); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
❌ PERF-15 (docs)
This
useEffectperforms async work (.then()chain) that callssetCameraPermissionStatus, but has no cleanup to discard stale responses. WhenisVisiblechanges fromtruetofalse, the effect re-runs and hits the early return, but the previous promise may still be in-flight and can callsetCameraPermissionStatusafter the modal has closed.Add an
ignoreflag to prevent stale state updates:Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.