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}
>