From a74fb3c925bfad22b74416a770544d075d1b7ba5 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Fri, 18 Sep 2026 12:50:55 +0530 Subject: [PATCH 1/6] Fix AttachmentCamera UI rotation in landscape --- .../AttachmentPicker/AttachmentCamera.tsx | 197 +++++++++--------- tests/ui/components/AttachmentCameraTest.tsx | 18 ++ 2 files changed, 119 insertions(+), 96 deletions(-) diff --git a/src/components/AttachmentPicker/AttachmentCamera.tsx b/src/components/AttachmentPicker/AttachmentCamera.tsx index b73512da5177..94e2ffa3568c 100644 --- a/src/components/AttachmentPicker/AttachmentCamera.tsx +++ b/src/components/AttachmentPicker/AttachmentCamera.tsx @@ -7,6 +7,7 @@ import Icon from '@components/Icon'; import ImageSVG from '@components/ImageSVG'; import Modal from '@components/Modal'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; +import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; import useIsPlatformMuted from '@hooks/useIsPlatformMuted'; @@ -228,106 +229,110 @@ function AttachmentCamera({isVisible, onCapture, onClose, onModalHide}: Attachme - - {cameraPermissionStatus !== RESULTS.GRANTED && ( - + + + {cameraPermissionStatus !== RESULTS.GRANTED && ( + + + + {translate('receipt.takePhoto')} + {translate('receipt.cameraAccess')} + + + + )} + {cameraPermissionStatus === RESULTS.GRANTED && device == null && ( + + + + )} + {cameraPermissionStatus === RESULTS.GRANTED && device != null && ( + + + + + + + + + )} + + + + setFlash((prevFlash) => !prevFlash)} + sentryLabel="AttachmentCamera-Flash" + > + + + + - {translate('receipt.takePhoto')} - {translate('receipt.cameraAccess')} - - - )} - {cameraPermissionStatus === RESULTS.GRANTED && device == null && ( - - + + setCameraPosition((prev) => (prev === 'back' ? 'front' : 'back'))} + sentryLabel="AttachmentCamera-FlipCamera" + > + - - )} - {cameraPermissionStatus === RESULTS.GRANTED && device != null && ( - - - - - - - - - )} - - - - setFlash((prevFlash) => !prevFlash)} - sentryLabel="AttachmentCamera-Flash" - > - - - - - - - - setCameraPosition((prev) => (prev === 'back' ? 'front' : 'back'))} - sentryLabel="AttachmentCamera-FlipCamera" - > - - + + diff --git a/tests/ui/components/AttachmentCameraTest.tsx b/tests/ui/components/AttachmentCameraTest.tsx index fb6dcb5937e8..a952e47d01cd 100644 --- a/tests/ui/components/AttachmentCameraTest.tsx +++ b/tests/ui/components/AttachmentCameraTest.tsx @@ -4,6 +4,8 @@ import AttachmentCamera from '@components/AttachmentPicker/AttachmentCamera'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import isInLandscapeMode from '@libs/isInLandscapeMode'; + import type {CameraDevice} from 'react-native-vision-camera'; import React from 'react'; @@ -17,6 +19,9 @@ import waitForBatchedUpdatesWithAct from '../../utils/waitForBatchedUpdatesWithA const mockTakePhoto = jest.fn(() => Promise.resolve({path: '/tmp/photos/shot.jpg', width: 3024, height: 4032})); let mockPermissionStatus = 'granted'; +jest.mock('@libs/isInLandscapeMode'); +jest.mock('@expensify/react-native-hybrid-app', () => ({__esModule: true, default: {isHybridApp: jest.fn(() => false)}})); + jest.mock('@pages/iou/request/step/IOURequestStepScan/CameraPermission', () => ({ getCameraPermissionStatus: jest.fn(() => Promise.resolve(mockPermissionStatus)), requestCameraPermission: jest.fn(() => Promise.resolve(mockPermissionStatus)), @@ -50,6 +55,7 @@ const FRONT_DEVICE = createMock({id: 'front', position: 'front', h const mockedUseCameraDevice = jest.mocked(useCameraDevice); const mockedUseCameraDevices = jest.mocked(useCameraDevices); +const mockedIsInLandscapeMode = jest.mocked(isInLandscapeMode); function renderCamera(props: Partial> = {}) { const onCapture = jest.fn(); @@ -84,6 +90,7 @@ describe('AttachmentCamera', () => { mockTakePhoto.mockResolvedValue({path: '/tmp/photos/shot.jpg', width: 3024, height: 4032}); mockedUseCameraDevice.mockReturnValue(BACK_DEVICE); mockedUseCameraDevices.mockReturnValue([BACK_DEVICE, FRONT_DEVICE]); + mockedIsInLandscapeMode.mockReturnValue(false); await act(async () => { await Onyx.clear(); }); @@ -181,4 +188,15 @@ describe('AttachmentCamera', () => { expect(mockTakePhoto).not.toHaveBeenCalled(); expect(onCapture).not.toHaveBeenCalled(); }); + + it('adapts layout for landscape orientation', async () => { + mockedIsInLandscapeMode.mockReturnValue(true); + renderCamera(); + await waitForBatchedUpdatesWithAct(); + + const shutter = screen.getByLabelText(translateLocal('receipt.shutter')); + expect(shutter).toBeOnTheScreen(); + const controlsContainer = shutter.parent; + expect(controlsContainer?.props.style).toEqual(expect.not.arrayContaining([expect.objectContaining({flexDirection: 'row'})])); + }); }); From 7909d4aab01189313b33f4fff152343b6c6c3e72 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:50:04 +0530 Subject: [PATCH 2/6] Use CameraPermissionPrompt in AttachmentCamera --- .../AttachmentPicker/AttachmentCamera.tsx | 31 ++++--------------- 1 file changed, 6 insertions(+), 25 deletions(-) diff --git a/src/components/AttachmentPicker/AttachmentCamera.tsx b/src/components/AttachmentPicker/AttachmentCamera.tsx index 94e2ffa3568c..b2e98b70f0c0 100644 --- a/src/components/AttachmentPicker/AttachmentCamera.tsx +++ b/src/components/AttachmentPicker/AttachmentCamera.tsx @@ -2,13 +2,10 @@ * 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 ScrollView from '@components/ScrollView'; -import Text from '@components/Text'; import useIsPlatformMuted from '@hooks/useIsPlatformMuted'; import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; @@ -27,6 +24,7 @@ import isInLandscapeMode from '@libs/isInLandscapeMode'; import {logCameraCaptureFailed, logCameraRuntimeError} from '@libs/telemetry/ReceiptObservability'; import CameraPermission from '@pages/iou/request/step/IOURequestStepScan/CameraPermission'; +import CameraPermissionPrompt from '@pages/iou/request/step/IOURequestStepScan/components/CameraPermissionPrompt'; import getCameraAspectRatio from '@pages/iou/request/step/IOURequestStepScan/getCameraAspectRatio'; import variables from '@styles/variables'; @@ -73,7 +71,7 @@ function AttachmentCamera({isVisible, onCapture, onClose, onModalHide}: Attachme const {windowWidth, windowHeight} = useWindowDimensions(); const isLandscape = isInLandscapeMode(windowWidth, windowHeight); const lazyIcons = useMemoizedLazyExpensifyIcons(['Bolt', 'boltSlash', 'CameraFlip', 'Close']); - const lazyIllustrations = useMemoizedLazyIllustrations(['Shutter', 'Hand']); + const lazyIllustrations = useMemoizedLazyIllustrations(['Shutter']); const isPlatformMuted = useIsPlatformMuted(); const [cameraPosition, setCameraPosition] = useState<'back' | 'front'>('back'); @@ -232,27 +230,10 @@ function AttachmentCamera({isVisible, onCapture, onClose, onModalHide}: Attachme {cameraPermissionStatus !== RESULTS.GRANTED && ( - - - - {translate('receipt.takePhoto')} - {translate('receipt.cameraAccess')} - - - + )} {cameraPermissionStatus === RESULTS.GRANTED && device == null && ( From a703f07c61786db8e66c2dd32c10c2b3ce7a8fd3 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:51:09 +0530 Subject: [PATCH 3/6] Guard camera capture rejection on close --- .../AttachmentPicker/AttachmentCamera.tsx | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/components/AttachmentPicker/AttachmentCamera.tsx b/src/components/AttachmentPicker/AttachmentCamera.tsx index b2e98b70f0c0..a54a822920a5 100644 --- a/src/components/AttachmentPicker/AttachmentCamera.tsx +++ b/src/components/AttachmentPicker/AttachmentCamera.tsx @@ -90,7 +90,12 @@ function AttachmentCamera({isVisible, onCapture, onClose, onModalHide}: Attachme const format = useCameraFormat(device, [ {photoAspectRatio: CONST.RECEIPT_CAMERA.PHOTO_ASPECT_RATIO}, - {photoResolution: {width: CONST.RECEIPT_CAMERA.PHOTO_WIDTH, height: CONST.RECEIPT_CAMERA.PHOTO_HEIGHT}}, + { + photoResolution: { + width: CONST.RECEIPT_CAMERA.PHOTO_WIDTH, + height: CONST.RECEIPT_CAMERA.PHOTO_HEIGHT, + }, + }, getVideoResolutionFormatFilter(windowWidth, windowHeight), ]); const hasFlash = !!device?.hasFlash; @@ -181,6 +186,12 @@ function AttachmentCamera({isVisible, onCapture, onClose, onModalHide}: Attachme ]); }) .catch((error: Error) => { + // Tearing down the camera (e.g. tapping X while takePhoto is in-flight) rejects the + // promise on Android. That is the user's own cancellation, not a real failure, so + // skip both the alert and the Sentry log when the camera is no longer active. + if (!isActiveRef.current) { + return; + } Alert.alert(translate('receipt.cameraErrorTitle'), translate('receipt.cameraErrorMessage')); logCameraCaptureFailed(error); }) @@ -190,11 +201,18 @@ function AttachmentCamera({isVisible, onCapture, onClose, onModalHide}: Attachme }; const handleCameraError = (error: CameraRuntimeError) => { + if (!isActiveRef.current) { + return; + } Alert.alert(translate('receipt.cameraErrorTitle'), translate('receipt.cameraErrorMessage')); logCameraRuntimeError({code: error.code, message: error.message}); }; const handleClose = () => { + // Drop the active flag synchronously so the guard in the takePhoto catch and + // handleCameraError is already correct when the teardown-induced rejection arrives, + // instead of waiting for the isVisible useEffect to commit. + isActiveRef.current = false; isCapturing.current = false; setFlash(false); setCameraPosition('back'); From c64664f75a7a232ff81fb82c67e687acaf504633 Mon Sep 17 00:00:00 2001 From: Shridhar Goel <35566748+ShridharGoel@users.noreply.github.com> Date: Fri, 18 Sep 2026 19:19:08 +0530 Subject: [PATCH 4/6] Update AttachmentCamera.tsx --- src/components/AttachmentPicker/AttachmentCamera.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/components/AttachmentPicker/AttachmentCamera.tsx b/src/components/AttachmentPicker/AttachmentCamera.tsx index a54a822920a5..d65a66022b1c 100644 --- a/src/components/AttachmentPicker/AttachmentCamera.tsx +++ b/src/components/AttachmentPicker/AttachmentCamera.tsx @@ -186,9 +186,6 @@ function AttachmentCamera({isVisible, onCapture, onClose, onModalHide}: Attachme ]); }) .catch((error: Error) => { - // Tearing down the camera (e.g. tapping X while takePhoto is in-flight) rejects the - // promise on Android. That is the user's own cancellation, not a real failure, so - // skip both the alert and the Sentry log when the camera is no longer active. if (!isActiveRef.current) { return; } @@ -209,9 +206,6 @@ function AttachmentCamera({isVisible, onCapture, onClose, onModalHide}: Attachme }; const handleClose = () => { - // Drop the active flag synchronously so the guard in the takePhoto catch and - // handleCameraError is already correct when the teardown-induced rejection arrives, - // instead of waiting for the isVisible useEffect to commit. isActiveRef.current = false; isCapturing.current = false; setFlash(false); From c0274e1553f1917bf257b019badae412e651d218 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Fri, 18 Sep 2026 19:57:49 +0530 Subject: [PATCH 5/6] Inline permission prompt in AttachmentCamera --- .../AttachmentPicker/AttachmentCamera.tsx | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/src/components/AttachmentPicker/AttachmentCamera.tsx b/src/components/AttachmentPicker/AttachmentCamera.tsx index d65a66022b1c..b31665729e76 100644 --- a/src/components/AttachmentPicker/AttachmentCamera.tsx +++ b/src/components/AttachmentPicker/AttachmentCamera.tsx @@ -2,10 +2,13 @@ * 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 ScrollView from '@components/ScrollView'; +import Text from '@components/Text'; import useIsPlatformMuted from '@hooks/useIsPlatformMuted'; import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; @@ -24,7 +27,6 @@ import isInLandscapeMode from '@libs/isInLandscapeMode'; import {logCameraCaptureFailed, logCameraRuntimeError} from '@libs/telemetry/ReceiptObservability'; import CameraPermission from '@pages/iou/request/step/IOURequestStepScan/CameraPermission'; -import CameraPermissionPrompt from '@pages/iou/request/step/IOURequestStepScan/components/CameraPermissionPrompt'; import getCameraAspectRatio from '@pages/iou/request/step/IOURequestStepScan/getCameraAspectRatio'; import variables from '@styles/variables'; @@ -71,7 +73,7 @@ function AttachmentCamera({isVisible, onCapture, onClose, onModalHide}: Attachme const {windowWidth, windowHeight} = useWindowDimensions(); const isLandscape = isInLandscapeMode(windowWidth, windowHeight); const lazyIcons = useMemoizedLazyExpensifyIcons(['Bolt', 'boltSlash', 'CameraFlip', 'Close']); - const lazyIllustrations = useMemoizedLazyIllustrations(['Shutter']); + const lazyIllustrations = useMemoizedLazyIllustrations(['Shutter', 'Hand']); const isPlatformMuted = useIsPlatformMuted(); const [cameraPosition, setCameraPosition] = useState<'back' | 'front'>('back'); @@ -242,10 +244,28 @@ function AttachmentCamera({isVisible, onCapture, onClose, onModalHide}: Attachme {cameraPermissionStatus !== RESULTS.GRANTED && ( - + + + + {translate('receipt.takePhoto')} + {translate('receipt.cameraAccess')} + + + )} {cameraPermissionStatus === RESULTS.GRANTED && device == null && ( From 63c76bdb817a7bcafdb2e6d149aa2b9f3d02a698 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:01:02 +0530 Subject: [PATCH 6/6] Use CONST.SENTRY_LABEL in AttachmentCamera --- src/CONST/index.ts | 7 +++++++ src/components/AttachmentPicker/AttachmentCamera.tsx | 10 +++++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 67f6ad2c0904..12056f46bcd2 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -9070,6 +9070,13 @@ const CONST = { OPTION_CARD_PICKER: { OPTION_ITEM: 'OptionCardPicker-OptionItem', }, + ATTACHMENT_CAMERA: { + CLOSE: 'AttachmentCamera-Close', + FLASH: 'AttachmentCamera-Flash', + SHUTTER: 'AttachmentCamera-Shutter', + FLIP_CAMERA: 'AttachmentCamera-FlipCamera', + PERMISSION_PROMPT_BUTTON: 'AttachmentCamera-PermissionPromptButton', + }, ATTACHMENT_CAROUSEL: { PREVIOUS_BUTTON: 'AttachmentCarousel-PreviousButton', NEXT_BUTTON: 'AttachmentCarousel-NextButton', diff --git a/src/components/AttachmentPicker/AttachmentCamera.tsx b/src/components/AttachmentPicker/AttachmentCamera.tsx index b31665729e76..757baa712c2a 100644 --- a/src/components/AttachmentPicker/AttachmentCamera.tsx +++ b/src/components/AttachmentPicker/AttachmentCamera.tsx @@ -230,7 +230,7 @@ function AttachmentCamera({isVisible, onCapture, onClose, onModalHide}: Attachme role={CONST.ROLE.BUTTON} accessibilityLabel={translate('common.close')} onPress={handleClose} - sentryLabel="AttachmentCamera-Close" + sentryLabel={CONST.SENTRY_LABEL.ATTACHMENT_CAMERA.CLOSE} > {translate('common.continue')} @@ -305,7 +305,7 @@ function AttachmentCamera({isVisible, onCapture, onClose, onModalHide}: Attachme style={[styles.alignItemsStart, !hasFlash && styles.opacity0]} disabled={cameraPermissionStatus !== RESULTS.GRANTED || !hasFlash} onPress={() => setFlash((prevFlash) => !prevFlash)} - sentryLabel="AttachmentCamera-Flash" + sentryLabel={CONST.SENTRY_LABEL.ATTACHMENT_CAMERA.FLASH} > setCameraPosition((prev) => (prev === 'back' ? 'front' : 'back'))} - sentryLabel="AttachmentCamera-FlipCamera" + sentryLabel={CONST.SENTRY_LABEL.ATTACHMENT_CAMERA.FLIP_CAMERA} >