-
Notifications
You must be signed in to change notification settings - Fork 4k
fix: use in-app VisionCamera for chat attachment photos (re-land) #96458
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
Merged
Merged
Changes from all commits
Commits
Show all changes
28 commits
Select commit
Hold shift + click to select a range
9191e55
Re-land in-app VisionCamera for chat attachment photos
MelvinBot 6bd1224
Fix React Compiler divergence in useTapToFocusGesture
MelvinBot d43b5da
Constrain iOS preview resolution for in-app attachment camera
MelvinBot f702d47
Merge main into claude-inAppCameraAttachments-relandV2
MelvinBot 71c2d26
Fix typecheck: drop removed reasonAttributes prop, add Greek flipCame…
MelvinBot 771a9a4
Use VisionCamera default temp dir for chat photos instead of receipts…
MelvinBot f8d21e0
Merge remote-tracking branch 'origin/main' into claude-inAppCameraAtt…
MelvinBot 10a2f5c
Hide camera flip control when the device has only one camera position
MelvinBot c74f438
Inline fill-rule/clip-rule on camera-flip.svg to drop the <style>-blo…
MelvinBot a9823bc
Fully minify camera-flip.svg so it passes the Compress SVG check
MelvinBot 37a88eb
Fix: add missing useCameraDevices export to vision-camera jest mock
MelvinBot 6d5d9e4
Fix: add useCameraDevices to inline vision-camera mocks in Scan tests
MelvinBot 0d57176
Add header comment to AttachmentCamera describing the in-app VisionCa…
MelvinBot ecaa885
Address review: extract platform videoResolution helper, name modal d…
MelvinBot f2fafd8
Run npm run fmt
MelvinBot 3cbe381
Merge remote-tracking branch 'origin/main' into claude-inAppCameraAtt…
MelvinBot 3a84f53
Merge remote-tracking branch 'origin/main' into claude-inAppCameraAtt…
MelvinBot 37a6cb7
Apply review patch: landscape support, shutter sound, camera error ha…
MelvinBot 828a392
Apply Polyglot Parrot translations for lowercased flipCamera label
MelvinBot cb6a3cc
Merge remote-tracking branch 'origin/main' into claude-inAppCameraAtt…
MelvinBot 30c2452
Merge origin/main into claude-inAppCameraAttachments-relandV2
MelvinBot b6d983f
Remove unnecessary comments and tighten the remaining ones
MelvinBot adb8b8d
Address review feedback: use app Modal, extract useIsPlatformMuted, d…
MelvinBot 5c5f805
Apply oxfmt formatting
MelvinBot e681ed5
Move camera videoResolution filter into a platform-split module
MelvinBot 58576a8
Address review feedback: reuse permission logic, getFileName, Sentry-…
MelvinBot a2eb5fd
Merge origin/main into claude-inAppCameraAttachments-relandV2
MelvinBot fd7c3b9
Merge origin/main into claude-inAppCameraAttachments-relandV2
MelvinBot File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| const useCameraDevice = jest.fn(() => null); | ||
| const useCameraDevices = jest.fn(() => []); | ||
| const useCameraFormat = jest.fn(() => null); | ||
| const useCameraPermission = jest.fn(() => ({hasPermission: false, requestPermission: jest.fn(() => Promise.resolve(false))})); | ||
|
|
||
| const Camera = Object.assign( | ||
| jest.fn(() => null), | ||
| { | ||
| getCameraPermissionStatus: jest.fn(() => 'not-determined'), | ||
| requestCameraPermission: jest.fn(() => Promise.resolve('granted')), | ||
| }, | ||
| ); | ||
|
|
||
| export {Camera, useCameraDevice, useCameraDevices, useCameraFormat, useCameraPermission}; |
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,338 @@ | ||
| /** | ||
| * In-app VisionCamera modal used by the native AttachmentPicker. | ||
| */ | ||
| import ActivityIndicator from '@components/ActivityIndicator'; | ||
| import Button from '@components/Button'; | ||
| import Icon from '@components/Icon'; | ||
| import ImageSVG from '@components/ImageSVG'; | ||
| import Modal from '@components/Modal'; | ||
| import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; | ||
| import Text from '@components/Text'; | ||
|
|
||
| import useIsPlatformMuted from '@hooks/useIsPlatformMuted'; | ||
| import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; | ||
| import useLocalize from '@hooks/useLocalize'; | ||
| import {requestCameraPermission, useTapToFocusGesture} from '@hooks/useNativeCamera'; | ||
| import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; | ||
| import useStyleUtils from '@hooks/useStyleUtils'; | ||
| import useTheme from '@hooks/useTheme'; | ||
| import useThemeStyles from '@hooks/useThemeStyles'; | ||
| import useWindowDimensions from '@hooks/useWindowDimensions'; | ||
|
|
||
| import {getFileName} from '@libs/fileDownload/FileUtils'; | ||
| import getPhotoSource from '@libs/fileDownload/getPhotoSource'; | ||
| import getVideoResolutionFormatFilter from '@libs/getVideoResolutionFormatFilter'; | ||
| import isInLandscapeMode from '@libs/isInLandscapeMode'; | ||
| import {logCameraCaptureFailed, logCameraRuntimeError} from '@libs/telemetry/ReceiptObservability'; | ||
|
|
||
| import CameraPermission from '@pages/iou/request/step/IOURequestStepScan/CameraPermission'; | ||
| import getCameraAspectRatio from '@pages/iou/request/step/IOURequestStepScan/getCameraAspectRatio'; | ||
|
|
||
| import variables from '@styles/variables'; | ||
|
|
||
| import CONST from '@src/CONST'; | ||
|
|
||
| import type {Camera, CameraRuntimeError, PhotoFile} from 'react-native-vision-camera'; | ||
|
|
||
| import React, {useEffect, useRef, useState} from 'react'; | ||
| import {Alert, AppState, View} from 'react-native'; | ||
| import {GestureDetector} from 'react-native-gesture-handler'; | ||
| import {RESULTS} from 'react-native-permissions'; | ||
| import Animated from 'react-native-reanimated'; | ||
| import {useCameraDevice, useCameraDevices, useCameraFormat, Camera as VisionCamera} from 'react-native-vision-camera'; | ||
|
|
||
| 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 */ | ||
| onClose: () => void; | ||
|
|
||
| /** Callback fired once the modal has finished its hide animation */ | ||
| onModalHide: () => void; | ||
| }; | ||
|
|
||
| function AttachmentCamera({isVisible, onCapture, onClose, onModalHide}: AttachmentCameraProps) { | ||
| const theme = useTheme(); | ||
| const styles = useThemeStyles(); | ||
| const {translate} = useLocalize(); | ||
| const insets = useSafeAreaInsets(); | ||
| const StyleUtils = useStyleUtils(); | ||
| const {windowWidth, windowHeight} = useWindowDimensions(); | ||
| const isLandscape = isInLandscapeMode(windowWidth, windowHeight); | ||
| const lazyIcons = useMemoizedLazyExpensifyIcons(['Bolt', 'boltSlash', 'CameraFlip', 'Close']); | ||
| const lazyIllustrations = useMemoizedLazyIllustrations(['Shutter', 'Hand']); | ||
| const isPlatformMuted = useIsPlatformMuted(); | ||
|
|
||
| const [cameraPosition, setCameraPosition] = useState<'back' | 'front'>('back'); | ||
| const [flash, setFlash] = useState(false); | ||
| const [cameraPermissionStatus, setCameraPermissionStatus] = useState<string | null>(null); | ||
| const isCapturing = useRef(false); | ||
| const isActiveRef = useRef(false); | ||
| const cameraRef = useRef<Camera>(null); | ||
|
|
||
| const device = useCameraDevice(cameraPosition, { | ||
| physicalDevices: ['wide-angle-camera', 'ultra-wide-angle-camera'], | ||
| }); | ||
|
MelvinBot marked this conversation as resolved.
|
||
|
|
||
| const cameraDevices = useCameraDevices(); | ||
| const canFlipCamera = cameraDevices.some((d) => d.position === 'front') && cameraDevices.some((d) => d.position === 'back'); | ||
|
|
||
| const format = useCameraFormat(device, [ | ||
| {photoAspectRatio: CONST.RECEIPT_CAMERA.PHOTO_ASPECT_RATIO}, | ||
| {photoResolution: {width: CONST.RECEIPT_CAMERA.PHOTO_WIDTH, height: CONST.RECEIPT_CAMERA.PHOTO_HEIGHT}}, | ||
| getVideoResolutionFormatFilter(windowWidth, windowHeight), | ||
| ]); | ||
|
MelvinBot marked this conversation as resolved.
|
||
| const hasFlash = !!device?.hasFlash; | ||
| const cameraAspectRatio = getCameraAspectRatio(format, isLandscape); | ||
|
|
||
| const {tapGesture, cameraFocusIndicatorAnimatedStyle} = useTapToFocusGesture(cameraRef, device?.supportsFocus ?? false); | ||
|
|
||
| const askForPermissions = () => requestCameraPermission(translate, setCameraPermissionStatus); | ||
|
|
||
| useEffect(() => { | ||
| isActiveRef.current = isVisible; | ||
| }, [isVisible]); | ||
|
|
||
| // Refresh permissions when modal becomes visible or when returning from app settings | ||
| useEffect(() => { | ||
| if (!isVisible) { | ||
| return; | ||
| } | ||
|
|
||
| let ignore = false; | ||
| const refreshCameraPermissionStatus = (autoRequest = false) => { | ||
| CameraPermission?.getCameraPermissionStatus?.() | ||
| .then((status: string) => { | ||
| if (ignore) { | ||
| return; | ||
| } | ||
| setCameraPermissionStatus(status); | ||
| if (autoRequest && status === RESULTS.DENIED) { | ||
| requestCameraPermission(translate, setCameraPermissionStatus); | ||
| } | ||
| }) | ||
| .catch(() => { | ||
| if (ignore) { | ||
| return; | ||
| } | ||
| setCameraPermissionStatus(RESULTS.UNAVAILABLE); | ||
| }); | ||
| }; | ||
|
|
||
| refreshCameraPermissionStatus(true); | ||
|
|
||
| const subscription = AppState.addEventListener('change', (appState) => { | ||
| if (appState !== 'active') { | ||
| return; | ||
| } | ||
| refreshCameraPermissionStatus(); | ||
| }); | ||
|
|
||
| return () => { | ||
| ignore = true; | ||
| subscription.remove(); | ||
| }; | ||
| }, [isVisible, translate]); | ||
|
|
||
| const capturePhoto = () => { | ||
| if (cameraPermissionStatus !== RESULTS.GRANTED) { | ||
| askForPermissions(); | ||
| return; | ||
| } | ||
|
|
||
| if (!cameraRef.current || isCapturing.current) { | ||
| return; | ||
| } | ||
|
|
||
| isCapturing.current = true; | ||
|
|
||
| cameraRef.current | ||
| .takePhoto({ | ||
| flash: flash && hasFlash ? 'on' : 'off', | ||
|
MelvinBot marked this conversation as resolved.
|
||
| enableShutterSound: !isPlatformMuted, | ||
| }) | ||
| .then((photo: PhotoFile) => { | ||
| // Discard capture if the camera was closed while takePhoto was in-flight | ||
| if (!isActiveRef.current) { | ||
| return; | ||
|
MelvinBot marked this conversation as resolved.
|
||
| } | ||
| const uri = getPhotoSource(photo.path); | ||
| const fileName = getFileName(photo.path) || `photo_${Date.now()}.jpg`; | ||
|
|
||
| onCapture([ | ||
| { | ||
| uri, | ||
| fileName, | ||
| type: 'image/jpeg', | ||
| width: photo.width, | ||
| height: photo.height, | ||
| }, | ||
| ]); | ||
| }) | ||
| .catch((error: Error) => { | ||
| Alert.alert(translate('receipt.cameraErrorTitle'), translate('receipt.cameraErrorMessage')); | ||
| logCameraCaptureFailed(error); | ||
| }) | ||
| .finally(() => { | ||
| isCapturing.current = false; | ||
| }); | ||
| }; | ||
|
|
||
| const handleCameraError = (error: CameraRuntimeError) => { | ||
| Alert.alert(translate('receipt.cameraErrorTitle'), translate('receipt.cameraErrorMessage')); | ||
| logCameraRuntimeError({code: error.code, message: error.message}); | ||
| }; | ||
|
|
||
| const handleClose = () => { | ||
| isCapturing.current = false; | ||
| setFlash(false); | ||
| setCameraPosition('back'); | ||
| onClose(); | ||
| }; | ||
|
|
||
| return ( | ||
| <Modal | ||
|
MelvinBot marked this conversation as resolved.
|
||
| isVisible={isVisible} | ||
| onClose={handleClose} | ||
| onModalHide={onModalHide} | ||
| type={CONST.MODAL.MODAL_TYPE.FULLSCREEN} | ||
| style={styles.appBG} | ||
| innerContainerStyle={styles.flex1} | ||
| > | ||
| <View style={[styles.flex1, styles.appBG, StyleUtils.getPlatformSafeAreaPadding(insets)]}> | ||
| <View style={[styles.flexRow, styles.justifyContentEnd, styles.ph3, styles.pv2]}> | ||
| <PressableWithFeedback | ||
| role={CONST.ROLE.BUTTON} | ||
| accessibilityLabel={translate('common.close')} | ||
| onPress={handleClose} | ||
| sentryLabel="AttachmentCamera-Close" | ||
| > | ||
| <Icon | ||
| height={variables.iconSizeNormal} | ||
| width={variables.iconSizeNormal} | ||
| src={lazyIcons.Close} | ||
| fill={theme.icon} | ||
| /> | ||
| </PressableWithFeedback> | ||
| </View> | ||
|
|
||
| <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 | ||
| variant={CONST.BUTTON_VARIANT.SUCCESS} | ||
| accessibilityLabel={translate('common.continue')} | ||
| style={[styles.p9, styles.pt5]} | ||
| onPress={askForPermissions} | ||
| > | ||
| <Button.Text>{translate('common.continue')}</Button.Text> | ||
| </Button> | ||
| </View> | ||
| )} | ||
| {cameraPermissionStatus === RESULTS.GRANTED && device == null && ( | ||
| <View style={[styles.cameraView, styles.justifyContentCenter, styles.alignItemsCenter]}> | ||
| <ActivityIndicator | ||
| size={CONST.ACTIVITY_INDICATOR_SIZE.LARGE} | ||
| style={[styles.flex1]} | ||
| color={theme.textSupporting} | ||
| /> | ||
| </View> | ||
| )} | ||
| {cameraPermissionStatus === RESULTS.GRANTED && device != null && ( | ||
| <View style={[styles.cameraView, styles.alignItemsCenter]}> | ||
| <GestureDetector gesture={tapGesture}> | ||
| <View style={StyleUtils.getCameraViewfinderStyle(cameraAspectRatio, isLandscape)}> | ||
| <VisionCamera | ||
| ref={cameraRef} | ||
| device={device} | ||
| format={format ?? undefined} | ||
| style={styles.flex1} | ||
| zoom={device.neutralZoom} | ||
| photo | ||
| isActive={isVisible} | ||
| photoQualityBalance="quality" | ||
| onError={handleCameraError} | ||
| /> | ||
| <Animated.View style={[styles.cameraFocusIndicator, cameraFocusIndicatorAnimatedStyle]} /> | ||
| </View> | ||
| </GestureDetector> | ||
| </View> | ||
| )} | ||
| </View> | ||
|
|
||
| <View style={[styles.flexRow, styles.justifyContentAround, styles.alignItemsCenter, styles.pv3]}> | ||
| <PressableWithFeedback | ||
| role={CONST.ROLE.BUTTON} | ||
| accessibilityLabel={translate('receipt.flash')} | ||
| style={[styles.alignItemsStart, !hasFlash && styles.opacity0]} | ||
| disabled={cameraPermissionStatus !== RESULTS.GRANTED || !hasFlash} | ||
| onPress={() => setFlash((prevFlash) => !prevFlash)} | ||
| sentryLabel="AttachmentCamera-Flash" | ||
| > | ||
| <Icon | ||
| height={variables.iconSizeMenuItem} | ||
| width={variables.iconSizeMenuItem} | ||
| src={flash ? lazyIcons.Bolt : lazyIcons.boltSlash} | ||
| fill={theme.textSupporting} | ||
| /> | ||
| </PressableWithFeedback> | ||
|
|
||
| <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> | ||
|
|
||
| <PressableWithFeedback | ||
| role={CONST.ROLE.BUTTON} | ||
| accessibilityLabel={translate('receipt.flipCamera')} | ||
| style={[styles.alignItemsEnd, !canFlipCamera && styles.opacity0]} | ||
| disabled={cameraPermissionStatus !== RESULTS.GRANTED || !canFlipCamera} | ||
| onPress={() => setCameraPosition((prev) => (prev === 'back' ? 'front' : 'back'))} | ||
| sentryLabel="AttachmentCamera-FlipCamera" | ||
| > | ||
| <Icon | ||
| height={variables.iconSizeMenuItem} | ||
| width={variables.iconSizeMenuItem} | ||
| src={lazyIcons.CameraFlip} | ||
| fill={theme.textSupporting} | ||
| /> | ||
| </PressableWithFeedback> | ||
| </View> | ||
| </View> | ||
| </Modal> | ||
| ); | ||
| } | ||
|
|
||
| export default AttachmentCamera; | ||
| export type {CapturedPhoto}; | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.