From 5bfe7bbcdc5709bfa3a215b0df5d2280461f0b85 Mon Sep 17 00:00:00 2001
From: DylanDylann <dylandylan9112@gmail.com>
Date: Thu, 19 Mar 2026 17:26:13 +0700
Subject: [PATCH 01/10] dry camera logic

---
 .../index.native.tsx                          | 144 +++-----------
 .../hooks/useNativeCamera.ts                  | 167 ++++++++++++++++
 .../step/IOURequestStepScan/index.native.tsx  | 182 +++++-------------
 3 files changed, 243 insertions(+), 250 deletions(-)
 create mode 100644 src/pages/iou/request/step/IOURequestStepScan/hooks/useNativeCamera.ts

diff --git a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx
index cfedd87c414d..7779494c965c 100644
--- a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx
+++ b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx
@@ -1,17 +1,14 @@
-import {useFocusEffect} from '@react-navigation/core';
-import React, {useRef, useState} from 'react';
-import {Alert, AppState, StyleSheet, View} from 'react-native';
+import React, {useRef} from 'react';
+import {Alert, StyleSheet, View} from 'react-native';
 import type {LayoutRectangle} from 'react-native';
 import ReactNativeBlobUtil from 'react-native-blob-util';
-import {Gesture, GestureDetector} from 'react-native-gesture-handler';
+import {GestureDetector} from 'react-native-gesture-handler';
 import {RESULTS} from 'react-native-permissions';
-import Animated, {useAnimatedStyle, useSharedValue, withDelay, withSequence, withSpring, withTiming} from 'react-native-reanimated';
-import type {Camera, PhotoFile, Point} from 'react-native-vision-camera';
-import {useCameraDevice} from 'react-native-vision-camera';
+import Animated, {useAnimatedStyle, useSharedValue} from 'react-native-reanimated';
+import type {PhotoFile} from 'react-native-vision-camera';
 import ActivityIndicator from '@components/ActivityIndicator';
 import AttachmentPicker from '@components/AttachmentPicker';
 import Button from '@components/Button';
-import {useFullScreenLoaderActions, useFullScreenLoaderState} from '@components/FullScreenLoaderContext';
 import Icon from '@components/Icon';
 import ImageSVG from '@components/ImageSVG';
 import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
@@ -20,49 +17,29 @@ import Text from '@components/Text';
 import useFilesValidation from '@hooks/useFilesValidation';
 import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset';
 import useLocalize from '@hooks/useLocalize';
-import useOnyx from '@hooks/useOnyx';
 import useTheme from '@hooks/useTheme';
 import useThemeStyles from '@hooks/useThemeStyles';
-import {showCameraPermissionsAlert} from '@libs/fileDownload/FileUtils';
 import getPhotoSource from '@libs/fileDownload/getPhotoSource';
-import getPlatform from '@libs/getPlatform';
-import type Platform from '@libs/getPlatform/types';
 import getReceiptsUploadFolderPath from '@libs/getReceiptsUploadFolderPath';
 import {shouldUseTransactionDraft} from '@libs/IOUUtils';
 import Log from '@libs/Log';
 import Navigation from '@libs/Navigation/Navigation';
-import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan';
-import CameraPermission from '@pages/iou/request/step/IOURequestStepScan/CameraPermission';
 import NavigationAwareCamera from '@pages/iou/request/step/IOURequestStepScan/components/NavigationAwareCamera/Camera';
 import {cropImageToAspectRatio} from '@pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio';
 import type {ImageObject} from '@pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio';
+import useNativeCamera from '@pages/iou/request/step/IOURequestStepScan/hooks/useNativeCamera';
 import StepScreenWrapper from '@pages/iou/request/step/StepScreenWrapper';
 import withFullTransactionOrNotFound from '@pages/iou/request/step/withFullTransactionOrNotFound';
 import type {WithFullTransactionOrNotFoundProps} from '@pages/iou/request/step/withFullTransactionOrNotFound';
 import variables from '@styles/variables';
 import {setMoneyRequestOdometerImage} from '@userActions/IOU';
 import CONST from '@src/CONST';
-import ONYXKEYS from '@src/ONYXKEYS';
 import ROUTES from '@src/ROUTES';
 import type SCREENS from '@src/SCREENS';
 import type {FileObject} from '@src/types/utils/Attachment';
-import {getEmptyObject} from '@src/types/utils/EmptyObject';
 
 type IOURequestStepOdometerImageProps = WithFullTransactionOrNotFoundProps<typeof SCREENS.MONEY_REQUEST.ODOMETER_IMAGE>;
 
-function focusCamera(cameraRef: React.RefObject<Camera | null>, point: Point) {
-    if (!cameraRef.current) {
-        return;
-    }
-
-    cameraRef.current.focus(point).catch((error: Record<string, unknown>) => {
-        if (error.message === '[unknown/unknown] Cancelled by another startFocusAndMetering()') {
-            return;
-        }
-        Log.warn('Error focusing camera', error);
-    });
-}
-
 function IOURequestStepOdometerImage({
     route: {
         params: {action, iouType, transactionID, reportID, backToReport, imageType, isEditingConfirmation},
@@ -74,25 +51,28 @@ function IOURequestStepOdometerImage({
     const lazyIcons = useMemoizedLazyExpensifyIcons(['Bolt', 'Gallery', 'boltSlash', 'OdometerStart', 'OdometerEnd']);
     const lazyIllustrationsOnly = useMemoizedLazyIllustrations(['Hand', 'Shutter']);
 
-    const {isLoaderVisible} = useFullScreenLoaderState();
-    const {setIsLoaderVisible} = useFullScreenLoaderActions();
-    const [isAttachmentPickerActive, setIsAttachmentPickerActive] = useState(false);
-
-    const device = useCameraDevice('back', {
-        physicalDevices: ['wide-angle-camera', 'ultra-wide-angle-camera'],
-    });
-    const platform = getPlatform(true);
-    const [mutedPlatforms = getEmptyObject<Partial<Record<Platform, true>>>()] = useOnyx(ONYXKEYS.NVP_MUTED_PLATFORMS);
-    const isPlatformMuted = mutedPlatforms[platform];
-
-    const [cameraPermissionStatus, setCameraPermissionStatus] = useState<string | null>(null);
-    const hasFlash = !!device?.hasFlash;
-    const [flash, setFlash] = useState(false);
-    const [didCapturePhoto, setDidCapturePhoto] = useState(false);
-    const camera = useRef<Camera>(null);
     const viewfinderLayout = useRef<LayoutRectangle>(null);
     const isTransactionDraft = shouldUseTransactionDraft(action ?? CONST.IOU.ACTION.CREATE, iouType ?? CONST.IOU.TYPE.REQUEST);
 
+    const {
+        camera,
+        device,
+        cameraPermissionStatus,
+        flash,
+        setFlash,
+        hasFlash,
+        didCapturePhoto,
+        setDidCapturePhoto,
+        isAttachmentPickerActive,
+        setIsAttachmentPickerActive,
+        isPlatformMuted,
+        askForPermissions,
+        tapGesture,
+        cameraFocusIndicatorAnimatedStyle,
+        cameraLoadingReasonAttributes,
+        setIsLoaderVisible,
+    } = useNativeCamera({context: 'IOURequestStepOdometerImage'});
+
     const title = imageType === 'start' ? translate('distance.odometer.startTitle') : translate('distance.odometer.endTitle');
     const snapPhotoText = imageType === CONST.IOU.ODOMETER_IMAGE_TYPE.START ? translate('distance.odometer.snapPhotoStart') : translate('distance.odometer.snapPhotoEnd');
     const icon = imageType === CONST.IOU.ODOMETER_IMAGE_TYPE.START ? lazyIcons.OdometerStart : lazyIcons.OdometerEnd;
@@ -106,77 +86,11 @@ function IOURequestStepOdometerImage({
         Navigation.goBack(goBackRoute);
     };
 
-    const askForPermissions = () => {
-        // There's no way we can check for the BLOCKED status without requesting the permission first
-        // https://github.com/zoontek/react-native-permissions/blob/a836e114ce3a180b2b23916292c79841a267d828/README.md?plain=1#L670
-        CameraPermission.requestCameraPermission?.()
-            .then((status: string) => {
-                setCameraPermissionStatus(status);
-
-                if (status === RESULTS.BLOCKED) {
-                    showCameraPermissionsAlert(translate);
-                }
-            })
-            .catch(() => {
-                setCameraPermissionStatus(RESULTS.UNAVAILABLE);
-            });
-    };
-
     const blinkOpacity = useSharedValue(0);
     const blinkStyle = useAnimatedStyle(() => ({
         opacity: blinkOpacity.get(),
     }));
 
-    const focusIndicatorOpacity = useSharedValue(0);
-    const focusIndicatorScale = useSharedValue(1);
-    const focusIndicatorPosition = useSharedValue({x: 0, y: 0});
-    const cameraFocusIndicatorAnimatedStyle = useAnimatedStyle(() => ({
-        opacity: focusIndicatorOpacity.get(),
-        transform: [{translateX: focusIndicatorPosition.get().x}, {translateY: focusIndicatorPosition.get().y}, {scale: focusIndicatorScale.get()}],
-    }));
-
-    const tapGesture = Gesture.Tap()
-        .enabled(device?.supportsFocus ?? false)
-        .runOnJS(true)
-        .onStart((ev: {x: number; y: number}) => {
-            const point = {x: ev.x, y: ev.y};
-
-            focusIndicatorOpacity.set(withSequence(withTiming(0.8, {duration: 250}), withDelay(1000, withTiming(0, {duration: 250}))));
-            focusIndicatorScale.set(2);
-            focusIndicatorScale.set(withSpring(1, {damping: 10, stiffness: 200}));
-            focusIndicatorPosition.set(point);
-
-            focusCamera(camera, point);
-        });
-
-    useFocusEffect(() => {
-        setDidCapturePhoto(false);
-        const refreshCameraPermissionStatus = () => {
-            CameraPermission?.getCameraPermissionStatus?.()
-                .then(setCameraPermissionStatus)
-                .catch(() => setCameraPermissionStatus(RESULTS.UNAVAILABLE));
-        };
-
-        refreshCameraPermissionStatus();
-
-        // Refresh permission status when app gains focus
-        const subscription = AppState.addEventListener('change', (appState) => {
-            if (appState !== 'active') {
-                return;
-            }
-
-            refreshCameraPermissionStatus();
-        });
-
-        return () => {
-            subscription.remove();
-
-            if (isLoaderVisible) {
-                setIsLoaderVisible(false);
-            }
-        };
-    });
-
     const handleImageSelected = (files: FileObject[]) => {
         if (files.length === 0) {
             return;
@@ -256,12 +170,6 @@ function IOURequestStepOdometerImage({
             });
     };
 
-    const cameraLoadingReasonAttributes: SkeletonSpanReasonAttributes = {
-        context: 'IOURequestStepOdometerImage',
-        cameraPermissionGranted: cameraPermissionStatus === RESULTS.GRANTED,
-        deviceAvailable: device != null,
-    };
-
     // Wait for camera permission status to render
     if (cameraPermissionStatus == null) {
         return null;
@@ -419,7 +327,7 @@ function IOURequestStepOdometerImage({
 
 IOURequestStepOdometerImage.displayName = 'IOURequestStepOdometerImage';
 
-// eslint-disable-next-line rulesdir/no-negated-variables
+// eslint-disable-next-line rulesdir/no-negated-variables -- withFullTransactionOrNotFound HOC requires this pattern
 const IOURequestStepOdometerImageWithFullTransactionOrNotFound = withFullTransactionOrNotFound(IOURequestStepOdometerImage);
 
 export default IOURequestStepOdometerImageWithFullTransactionOrNotFound;
diff --git a/src/pages/iou/request/step/IOURequestStepScan/hooks/useNativeCamera.ts b/src/pages/iou/request/step/IOURequestStepScan/hooks/useNativeCamera.ts
new file mode 100644
index 000000000000..9d29b6d427b2
--- /dev/null
+++ b/src/pages/iou/request/step/IOURequestStepScan/hooks/useNativeCamera.ts
@@ -0,0 +1,167 @@
+import {useFocusEffect} from '@react-navigation/core';
+import {useCallback, useRef, useState} from 'react';
+import {AppState} from 'react-native';
+import {Gesture} from 'react-native-gesture-handler';
+import {RESULTS} from 'react-native-permissions';
+import {useAnimatedStyle, useSharedValue, withDelay, withSequence, withSpring, withTiming} from 'react-native-reanimated';
+import type {Camera, Point} from 'react-native-vision-camera';
+import {useCameraDevice} from 'react-native-vision-camera';
+import {scheduleOnRN} from 'react-native-worklets';
+import {useFullScreenLoaderActions, useFullScreenLoaderState} from '@components/FullScreenLoaderContext';
+import useLocalize from '@hooks/useLocalize';
+import useOnyx from '@hooks/useOnyx';
+import {showCameraPermissionsAlert} from '@libs/fileDownload/FileUtils';
+import getPlatform from '@libs/getPlatform';
+import type Platform from '@libs/getPlatform/types';
+import Log from '@libs/Log';
+import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan';
+import CameraPermission from '@pages/iou/request/step/IOURequestStepScan/CameraPermission';
+import ONYXKEYS from '@src/ONYXKEYS';
+import {getEmptyObject} from '@src/types/utils/EmptyObject';
+
+type UseNativeCameraOptions = {
+    /** Context name for telemetry reason attributes */
+    context: string;
+
+    /** Additional logic to run when the screen gains focus */
+    onFocusStart?: () => void;
+
+    /** Additional cleanup to run when focus effect cleans up */
+    onFocusCleanup?: () => void;
+};
+
+function useNativeCamera({context, onFocusStart, onFocusCleanup}: UseNativeCameraOptions) {
+    const {translate} = useLocalize();
+    const {isLoaderVisible} = useFullScreenLoaderState();
+    const {setIsLoaderVisible} = useFullScreenLoaderActions();
+
+    const device = useCameraDevice('back', {
+        physicalDevices: ['wide-angle-camera', 'ultra-wide-angle-camera'],
+    });
+
+    const platform = getPlatform(true);
+    const [mutedPlatforms = getEmptyObject<Partial<Record<Platform, true>>>()] = useOnyx(ONYXKEYS.NVP_MUTED_PLATFORMS);
+    const isPlatformMuted = mutedPlatforms[platform];
+
+    const [cameraPermissionStatus, setCameraPermissionStatus] = useState<string | null>(null);
+    const hasFlash = !!device?.hasFlash;
+    const [flash, setFlash] = useState(false);
+    const [didCapturePhoto, setDidCapturePhoto] = useState(false);
+    const [isAttachmentPickerActive, setIsAttachmentPickerActive] = useState(false);
+    const camera = useRef<Camera>(null);
+
+    const askForPermissions = useCallback(() => {
+        // There's no way we can check for the BLOCKED status without requesting the permission first
+        // https://github.com/zoontek/react-native-permissions/blob/a836e114ce3a180b2b23916292c79841a267d828/README.md?plain=1#L670
+        CameraPermission.requestCameraPermission?.()
+            .then((status: string) => {
+                setCameraPermissionStatus(status);
+
+                if (status === RESULTS.BLOCKED) {
+                    showCameraPermissionsAlert(translate);
+                }
+            })
+            .catch(() => {
+                setCameraPermissionStatus(RESULTS.UNAVAILABLE);
+            });
+    }, [translate]);
+
+    // Focus indicator animations
+    const focusIndicatorOpacity = useSharedValue(0);
+    const focusIndicatorScale = useSharedValue(2);
+    const focusIndicatorPosition = useSharedValue({x: 0, y: 0});
+
+    const cameraFocusIndicatorAnimatedStyle = useAnimatedStyle(() => ({
+        opacity: focusIndicatorOpacity.get(),
+        transform: [{translateX: focusIndicatorPosition.get().x}, {translateY: focusIndicatorPosition.get().y}, {scale: focusIndicatorScale.get()}],
+    }));
+
+    const focusCamera = useCallback(
+        (point: Point) => {
+            if (!camera.current) {
+                return;
+            }
+
+            camera.current.focus(point).catch((error: Record<string, unknown>) => {
+                if (error.message === '[unknown/unknown] Cancelled by another startFocusAndMetering()') {
+                    return;
+                }
+                Log.warn('Error focusing camera', error);
+            });
+        },
+        [camera],
+    );
+
+    const tapGesture = Gesture.Tap()
+        .enabled(device?.supportsFocus ?? false)
+        .onStart((ev: {x: number; y: number}) => {
+            const point = {x: ev.x, y: ev.y};
+
+            focusIndicatorOpacity.set(withSequence(withTiming(0.8, {duration: 250}), withDelay(1000, withTiming(0, {duration: 250}))));
+            focusIndicatorScale.set(2);
+            focusIndicatorScale.set(withSpring(1, {damping: 10, stiffness: 200}));
+            focusIndicatorPosition.set(point);
+
+            scheduleOnRN(focusCamera, point);
+        });
+
+    // Refresh camera permission on screen focus and app state changes
+    useFocusEffect(
+        useCallback(() => {
+            setDidCapturePhoto(false);
+            onFocusStart?.();
+            const refreshCameraPermissionStatus = () => {
+                CameraPermission?.getCameraPermissionStatus?.()
+                    .then(setCameraPermissionStatus)
+                    .catch(() => setCameraPermissionStatus(RESULTS.UNAVAILABLE));
+            };
+
+            refreshCameraPermissionStatus();
+
+            // Refresh permission status when app gains focus
+            const subscription = AppState.addEventListener('change', (appState) => {
+                if (appState !== 'active') {
+                    return;
+                }
+
+                refreshCameraPermissionStatus();
+            });
+
+            return () => {
+                subscription.remove();
+                onFocusCleanup?.();
+
+                if (isLoaderVisible) {
+                    setIsLoaderVisible(false);
+                }
+            };
+        }, [isLoaderVisible, setIsLoaderVisible, onFocusStart, onFocusCleanup]),
+    );
+
+    const cameraLoadingReasonAttributes: SkeletonSpanReasonAttributes = {
+        context,
+        cameraPermissionGranted: cameraPermissionStatus === RESULTS.GRANTED,
+        deviceAvailable: device != null,
+    };
+
+    return {
+        camera,
+        device,
+        cameraPermissionStatus,
+        flash,
+        setFlash,
+        hasFlash,
+        didCapturePhoto,
+        setDidCapturePhoto,
+        isAttachmentPickerActive,
+        setIsAttachmentPickerActive,
+        isPlatformMuted,
+        askForPermissions,
+        tapGesture,
+        cameraFocusIndicatorAnimatedStyle,
+        cameraLoadingReasonAttributes,
+        setIsLoaderVisible,
+    };
+}
+
+export default useNativeCamera;
diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx
index 00ddc69ff1c8..2897d3a6dcf9 100644
--- a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx
+++ b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx
@@ -1,18 +1,15 @@
-import {useFocusEffect} from '@react-navigation/core';
-import React, {useCallback, useEffect, useRef, useState} from 'react';
-import {Alert, AppState, StyleSheet, View} from 'react-native';
+import React, {useCallback, useEffect, useRef} from 'react';
+import {Alert, StyleSheet, View} from 'react-native';
 import ReactNativeBlobUtil from 'react-native-blob-util';
-import {Gesture, GestureDetector} from 'react-native-gesture-handler';
+import {GestureDetector} from 'react-native-gesture-handler';
 import {RESULTS} from 'react-native-permissions';
-import Animated, {useAnimatedStyle, useSharedValue, withDelay, withSequence, withSpring, withTiming} from 'react-native-reanimated';
-import type {Camera, PhotoFile, Point} from 'react-native-vision-camera';
-import {useCameraDevice, useCameraFormat} from 'react-native-vision-camera';
-import {scheduleOnRN} from 'react-native-worklets';
+import Animated from 'react-native-reanimated';
+import type {PhotoFile} from 'react-native-vision-camera';
+import {useCameraFormat} from 'react-native-vision-camera';
 import ActivityIndicator from '@components/ActivityIndicator';
 import AttachmentPicker from '@components/AttachmentPicker';
 import Button from '@components/Button';
 import FeatureTrainingModal from '@components/FeatureTrainingModal';
-import {useFullScreenLoaderActions, useFullScreenLoaderState} from '@components/FullScreenLoaderContext';
 import Icon from '@components/Icon';
 import ImageSVG from '@components/ImageSVG';
 import LocationPermissionModal from '@components/LocationPermissionModal';
@@ -26,19 +23,16 @@ import usePolicy from '@hooks/usePolicy';
 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 getPlatform from '@libs/getPlatform';
-import type Platform from '@libs/getPlatform/types';
 import getReceiptsUploadFolderPath from '@libs/getReceiptsUploadFolderPath';
 import Log from '@libs/Log';
 import Navigation from '@libs/Navigation/Navigation';
 import navigationRef from '@libs/Navigation/navigationRef';
 import {cancelSpan, endSpan, getSpan, startSpan} from '@libs/telemetry/activeSpans';
-import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan';
 import StepScreenWrapper from '@pages/iou/request/step/StepScreenWrapper';
 import withFullTransactionOrNotFound from '@pages/iou/request/step/withFullTransactionOrNotFound';
 import withWritableReportOrNotFound from '@pages/iou/request/step/withWritableReportOrNotFound';
+import variables from '@styles/variables';
 import {replaceReceipt, setMoneyRequestReceipt, updateLastLocationPermissionPrompt} from '@userActions/IOU';
 import {buildOptimisticTransactionAndCreateDraft} from '@userActions/TransactionEdit';
 import CONST from '@src/CONST';
@@ -46,11 +40,10 @@ import ONYXKEYS from '@src/ONYXKEYS';
 import type {Route} from '@src/ROUTES';
 import ROUTES from '@src/ROUTES';
 import type {FileObject} from '@src/types/utils/Attachment';
-import {getEmptyObject} from '@src/types/utils/EmptyObject';
-import CameraPermission from './CameraPermission';
 import NavigationAwareCamera from './components/NavigationAwareCamera/Camera';
 import ReceiptPreviews from './components/ReceiptPreviews';
 import useMobileReceiptScan from './hooks/useMobileReceiptScan';
+import useNativeCamera from './hooks/useNativeCamera';
 import useReceiptScan from './hooks/useReceiptScan';
 import type IOURequestStepScanProps from './types';
 
@@ -70,11 +63,38 @@ function IOURequestStepScan({
     const styles = useThemeStyles();
     const StyleUtils = useStyleUtils();
     const {translate} = useLocalize();
-    const {isLoaderVisible} = useFullScreenLoaderState();
-    const {setIsLoaderVisible} = useFullScreenLoaderActions();
-    const device = useCameraDevice('back', {
-        physicalDevices: ['wide-angle-camera', 'ultra-wide-angle-camera'],
-    });
+
+    // Ref for double-tap protection (doesn't trigger re-render)
+    const isCapturingPhoto = useRef(false);
+
+    const onFocusStart = useCallback(() => {
+        isCapturingPhoto.current = false;
+    }, []);
+
+    const onFocusCleanup = useCallback(() => {
+        cancelSpan(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE);
+        cancelSpan(CONST.TELEMETRY.SPAN_SHUTTER_TO_CONFIRMATION);
+    }, []);
+
+    const {
+        camera,
+        device,
+        cameraPermissionStatus,
+        flash,
+        setFlash,
+        hasFlash,
+        didCapturePhoto,
+        setDidCapturePhoto,
+        isAttachmentPickerActive,
+        setIsAttachmentPickerActive,
+        isPlatformMuted,
+        askForPermissions,
+        tapGesture,
+        cameraFocusIndicatorAnimatedStyle,
+        cameraLoadingReasonAttributes,
+        setIsLoaderVisible,
+    } = useNativeCamera({context: 'IOURequestStepScan', onFocusStart, onFocusCleanup});
+
     const format = useCameraFormat(device, [{photoAspectRatio: 4 / 3}, {videoResolution: 'max'}, {photoResolution: 'max'}]);
     // Format dimensions are in landscape orientation, so height/width gives portrait aspect ratio
     const cameraAspectRatio = format ? format.photoHeight / format.photoWidth : undefined;
@@ -82,17 +102,8 @@ function IOURequestStepScan({
     const navigateBack = useCallback(() => {
         Navigation.goBack(backTo);
     }, [backTo]);
-    const hasFlash = !!device?.hasFlash;
-    const camera = useRef<Camera>(null);
-    const [flash, setFlash] = useState(false);
     const lazyIllustrations = useMemoizedLazyIllustrations(['MultiScan', 'Hand', 'Shutter']);
     const lazyIcons = useMemoizedLazyExpensifyIcons(['Bolt', 'Gallery', 'ReceiptMultiple', 'boltSlash']);
-    const platform = getPlatform(true);
-    const [mutedPlatforms = getEmptyObject<Partial<Record<Platform, true>>>()] = useOnyx(ONYXKEYS.NVP_MUTED_PLATFORMS);
-    const isPlatformMuted = mutedPlatforms[platform];
-    const [cameraPermissionStatus, setCameraPermissionStatus] = useState<string | null>(null);
-    const [isAttachmentPickerActive, setIsAttachmentPickerActive] = useState(false);
-    const [didCapturePhoto, setDidCapturePhoto] = useState(false);
     const policy = usePolicy(report?.policyID);
 
     const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report?.policyID}`);
@@ -101,9 +112,6 @@ function IOURequestStepScan({
     const cameraInitSpanStarted = useRef(false);
     const cameraInitialized = useRef(false);
 
-    // Ref for double-tap protection (doesn't trigger re-render)
-    const isCapturingPhoto = useRef(false);
-
     // Start camera init span when permission is granted and camera is ready
     useEffect(() => {
         if (cameraInitSpanStarted.current || cameraPermissionStatus !== RESULTS.GRANTED || device == null) {
@@ -169,90 +177,6 @@ function IOURequestStepScan({
             });
     }, []);
 
-    const askForPermissions = useCallback(() => {
-        // There's no way we can check for the BLOCKED status without requesting the permission first
-        // https://github.com/zoontek/react-native-permissions/blob/a836e114ce3a180b2b23916292c79841a267d828/README.md?plain=1#L670
-        CameraPermission.requestCameraPermission?.()
-            .then((status: string) => {
-                setCameraPermissionStatus(status);
-
-                if (status === RESULTS.BLOCKED) {
-                    showCameraPermissionsAlert(translate);
-                }
-            })
-            .catch(() => {
-                setCameraPermissionStatus(RESULTS.UNAVAILABLE);
-            });
-    }, [translate]);
-
-    const focusIndicatorOpacity = useSharedValue(0);
-    const focusIndicatorScale = useSharedValue(2);
-    const focusIndicatorPosition = useSharedValue({x: 0, y: 0});
-
-    const cameraFocusIndicatorAnimatedStyle = useAnimatedStyle(() => ({
-        opacity: focusIndicatorOpacity.get(),
-        transform: [{translateX: focusIndicatorPosition.get().x}, {translateY: focusIndicatorPosition.get().y}, {scale: focusIndicatorScale.get()}],
-    }));
-
-    const focusCamera = (point: Point) => {
-        if (!camera.current) {
-            return;
-        }
-
-        camera.current.focus(point).catch((error: Record<string, unknown>) => {
-            if (error.message === '[unknown/unknown] Cancelled by another startFocusAndMetering()') {
-                return;
-            }
-            Log.warn('Error focusing camera', error);
-        });
-    };
-
-    const tapGesture = Gesture.Tap()
-        .enabled(device?.supportsFocus ?? false)
-        .onStart((ev: {x: number; y: number}) => {
-            const point = {x: ev.x, y: ev.y};
-
-            focusIndicatorOpacity.set(withSequence(withTiming(0.8, {duration: 250}), withDelay(1000, withTiming(0, {duration: 250}))));
-            focusIndicatorScale.set(2);
-            focusIndicatorScale.set(withSpring(1, {damping: 10, stiffness: 200}));
-            focusIndicatorPosition.set(point);
-
-            scheduleOnRN(focusCamera, point);
-        });
-
-    useFocusEffect(
-        useCallback(() => {
-            setDidCapturePhoto(false);
-            isCapturingPhoto.current = false;
-            const refreshCameraPermissionStatus = () => {
-                CameraPermission?.getCameraPermissionStatus?.()
-                    .then(setCameraPermissionStatus)
-                    .catch(() => setCameraPermissionStatus(RESULTS.UNAVAILABLE));
-            };
-
-            refreshCameraPermissionStatus();
-
-            // Refresh permission status when app gain focus
-            const subscription = AppState.addEventListener('change', (appState) => {
-                if (appState !== 'active') {
-                    return;
-                }
-
-                refreshCameraPermissionStatus();
-            });
-
-            return () => {
-                subscription.remove();
-                cancelSpan(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE);
-                cancelSpan(CONST.TELEMETRY.SPAN_SHUTTER_TO_CONFIRMATION);
-
-                if (isLoaderVisible) {
-                    setIsLoaderVisible(false);
-                }
-            };
-        }, [isLoaderVisible, setIsLoaderVisible]),
-    );
-
     const updateScanAndNavigate = useCallback(
         (file: FileObject, source: string) => {
             // Fix for the issue where the navigation state is lost after returning from device settings https://github.com/Expensify/App/issues/65992
@@ -442,12 +366,6 @@ function IOURequestStepScan({
         askForPermissions,
     ]);
 
-    const cameraLoadingReasonAttributes: SkeletonSpanReasonAttributes = {
-        context: 'IOURequestStepScan',
-        cameraPermissionGranted: cameraPermissionStatus === RESULTS.GRANTED,
-        deviceAvailable: device != null,
-    };
-
     // Wait for camera permission status to render
     if (cameraPermissionStatus == null) {
         return null;
@@ -536,8 +454,8 @@ function IOURequestStepScan({
                                         onPress={() => setFlash((prevFlash) => !prevFlash)}
                                     >
                                         <Icon
-                                            height={16}
-                                            width={16}
+                                            height={variables.iconSizeSmall}
+                                            width={variables.iconSizeSmall}
                                             src={lazyIcons.Bolt}
                                             fill={flash ? theme.white : theme.icon}
                                         />
@@ -591,8 +509,8 @@ function IOURequestStepScan({
                                 }}
                             >
                                 <Icon
-                                    height={32}
-                                    width={32}
+                                    height={variables.iconSizeMenuItem}
+                                    width={variables.iconSizeMenuItem}
                                     src={lazyIcons.Gallery}
                                     fill={theme.textSupporting}
                                 />
@@ -623,8 +541,8 @@ function IOURequestStepScan({
                             onPress={toggleMultiScan}
                         >
                             <Icon
-                                height={32}
-                                width={32}
+                                height={variables.iconSizeMenuItem}
+                                width={variables.iconSizeMenuItem}
                                 src={lazyIcons.ReceiptMultiple}
                                 fill={isMultiScanEnabled ? theme.iconMenu : theme.textSupporting}
                             />
@@ -639,8 +557,8 @@ function IOURequestStepScan({
                             onPress={() => setFlash((prevFlash) => !prevFlash)}
                         >
                             <Icon
-                                height={32}
-                                width={32}
+                                height={variables.iconSizeMenuItem}
+                                width={variables.iconSizeMenuItem}
                                 src={flash ? lazyIcons.Bolt : lazyIcons.boltSlash}
                                 fill={theme.textSupporting}
                             />
@@ -674,9 +592,9 @@ function IOURequestStepScan({
 }
 
 const IOURequestStepScanWithCurrentUserPersonalDetails = withCurrentUserPersonalDetails(IOURequestStepScan);
-// eslint-disable-next-line rulesdir/no-negated-variables
+// eslint-disable-next-line rulesdir/no-negated-variables -- withWritableReportOrNotFound HOC requires this pattern
 const IOURequestStepScanWithWritableReportOrNotFound = withWritableReportOrNotFound(IOURequestStepScanWithCurrentUserPersonalDetails, true);
-// eslint-disable-next-line rulesdir/no-negated-variables
+// eslint-disable-next-line rulesdir/no-negated-variables -- withFullTransactionOrNotFound HOC requires this pattern
 const IOURequestStepScanWithFullTransactionOrNotFound = withFullTransactionOrNotFound(IOURequestStepScanWithWritableReportOrNotFound);
 
 export default IOURequestStepScanWithFullTransactionOrNotFound;

From e1056adef5e65d54a17bdfa1bb5ddd03e0662aaa Mon Sep 17 00:00:00 2001
From: DylanDylann <dylandylan9112@gmail.com>
Date: Thu, 19 Mar 2026 17:40:41 +0700
Subject: [PATCH 02/10] dry web camera

---
 .../IOURequestStepOdometerImage/index.tsx     | 176 +++------------
 .../components/MobileWebCameraView.tsx        | 208 +++---------------
 .../IOURequestStepScan/hooks/useWebCamera.ts  | 208 ++++++++++++++++++
 3 files changed, 263 insertions(+), 329 deletions(-)
 create mode 100644 src/pages/iou/request/step/IOURequestStepScan/hooks/useWebCamera.ts

diff --git a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx
index 3c5d9a94e4ec..ab4fb31391b2 100644
--- a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx
+++ b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx
@@ -1,8 +1,5 @@
-import {useIsFocused} from '@react-navigation/native';
-import React, {useCallback, useEffect, useReducer, useRef, useState} from 'react';
+import React, {useCallback, useEffect, useRef, useState} from 'react';
 import {PanResponder, View} from 'react-native';
-import type {LayoutRectangle} from 'react-native';
-import type Webcam from 'react-webcam';
 import ActivityIndicator from '@components/ActivityIndicator';
 import AttachmentPicker from '@components/AttachmentPicker';
 import Button from '@components/Button';
@@ -19,15 +16,15 @@ import useLocalize from '@hooks/useLocalize';
 import useResponsiveLayout from '@hooks/useResponsiveLayout';
 import useTheme from '@hooks/useTheme';
 import useThemeStyles from '@hooks/useThemeStyles';
-import {isMobile, isMobileWebKit} from '@libs/Browser';
+import {isMobile} from '@libs/Browser';
 import {base64ToFile} from '@libs/fileDownload/FileUtils';
 import {shouldUseTransactionDraft} from '@libs/IOUUtils';
 import Log from '@libs/Log';
 import Navigation from '@libs/Navigation/Navigation';
-import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan';
 import NavigationAwareCamera from '@pages/iou/request/step/IOURequestStepScan/components/NavigationAwareCamera/WebCamera';
 import {cropImageToAspectRatio} from '@pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio';
 import type {ImageObject} from '@pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio';
+import useWebCamera from '@pages/iou/request/step/IOURequestStepScan/hooks/useWebCamera';
 import StepScreenDragAndDropWrapper from '@pages/iou/request/step/StepScreenDragAndDropWrapper';
 import withFullTransactionOrNotFound from '@pages/iou/request/step/withFullTransactionOrNotFound';
 import type {WithFullTransactionOrNotFoundProps} from '@pages/iou/request/step/withFullTransactionOrNotFound';
@@ -60,15 +57,22 @@ function IOURequestStepOdometerImage({
     // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
     const {isSmallScreenWidth} = useResponsiveLayout();
 
-    const [cameraPermissionState, setCameraPermissionState] = useState<PermissionState | undefined>('prompt');
-    const [isFlashLightOn, toggleFlashlight] = useReducer((state) => !state, false);
-    const [isTorchAvailable, setIsTorchAvailable] = useState(false);
-    const cameraRef = useRef<Webcam>(null);
-    const trackRef = useRef<MediaStreamTrack | null>(null);
-    const [isQueriedPermissionState, setIsQueriedPermissionState] = useState(false);
-    const getScreenshotTimeoutRef = useRef<NodeJS.Timeout | null>(null);
-    const [videoConstraints, setVideoConstraints] = useState<MediaTrackConstraints>();
-    const isTabActive = useIsFocused();
+    const {
+        cameraRef,
+        viewfinderLayout,
+        cameraPermissionState,
+        setCameraPermissionState,
+        isFlashLightOn,
+        toggleFlashlight,
+        isTorchAvailable,
+        isQueriedPermissionState,
+        videoConstraints,
+        requestCameraPermission,
+        setupCameraPermissionsAndCapabilities,
+        capturePhotoWithFlash,
+    } = useWebCamera();
+
+    const [isInitialPermissionQueried, setIsInitialPermissionQueried] = useState(false);
 
     const lazyIcons = useMemoizedLazyExpensifyIcons(['OdometerStart', 'OdometerEnd', 'Bolt', 'Gallery']);
     const lazyIllustrations = useMemoizedLazyIllustrations(['Hand', 'Shutter']);
@@ -98,111 +102,16 @@ function IOURequestStepOdometerImage({
         if (!file) {
             return;
         }
-        // For file selection, source is the blob URL
         handleImageSelected(file);
     });
 
-    /**
-     * On phones that have ultra-wide lens, react-webcam uses ultra-wide by default.
-     * The last deviceId is of regular len camera.
-     */
-    const requestCameraPermission = useCallback(() => {
-        if (!isMobile()) {
-            return;
-        }
-
-        const defaultConstraints = {facingMode: {exact: 'environment'}};
-        navigator.mediaDevices
-            .getUserMedia({video: {facingMode: {exact: 'environment'}, zoom: {ideal: 1}}})
-            .then((stream) => {
-                setCameraPermissionState('granted');
-                for (const track of stream.getTracks()) {
-                    track.stop();
-                }
-                // Only Safari 17+ supports zoom constraint
-                if (isMobileWebKit() && stream.getTracks().length > 0) {
-                    let deviceId;
-                    for (const track of stream.getTracks()) {
-                        const setting = track.getSettings();
-                        if (setting.zoom === 1) {
-                            deviceId = setting.deviceId;
-                            break;
-                        }
-                    }
-                    if (deviceId) {
-                        setVideoConstraints({deviceId});
-                        return;
-                    }
-                }
-                if (!navigator.mediaDevices.enumerateDevices) {
-                    setVideoConstraints(defaultConstraints);
-                    return;
-                }
-                navigator.mediaDevices
-                    .enumerateDevices()
-                    .then((devices) => {
-                        let lastBackDeviceId = '';
-                        for (let i = devices.length - 1; i >= 0; i--) {
-                            const device = devices.at(i);
-                            if (device?.kind === 'videoinput') {
-                                lastBackDeviceId = device.deviceId;
-                                break;
-                            }
-                        }
-                        if (!lastBackDeviceId) {
-                            setVideoConstraints(defaultConstraints);
-                            return;
-                        }
-                        setVideoConstraints({deviceId: lastBackDeviceId});
-                    })
-                    .catch(() => {
-                        setVideoConstraints(defaultConstraints);
-                    });
-            })
-            .catch(() => {
-                setVideoConstraints(defaultConstraints);
-                setCameraPermissionState('denied');
-            });
-    }, []);
-
+    // The odometer's own initial permission query for non-mobile (useWebCamera handles mobile)
     useEffect(() => {
-        if (!isMobile() || !isTabActive) {
+        if (!isMobile() || isInitialPermissionQueried) {
             return;
         }
-        navigator.permissions
-            .query({
-                name: 'camera',
-            })
-            .then((permissionState) => {
-                setCameraPermissionState(permissionState.state);
-                if (permissionState.state === 'granted') {
-                    requestCameraPermission();
-                }
-            })
-            .catch(() => {
-                setCameraPermissionState('denied');
-            })
-            .finally(() => {
-                setIsQueriedPermissionState(true);
-            });
-        return () => {
-            setVideoConstraints(undefined);
-        };
-    }, [isTabActive, requestCameraPermission]);
-
-    const setupCameraPermissionsAndCapabilities = (stream: MediaStream) => {
-        setCameraPermissionState('granted');
-
-        const [track] = stream.getVideoTracks();
-        const capabilities = track.getCapabilities();
-
-        if ('torch' in capabilities && capabilities.torch) {
-            trackRef.current = track;
-        }
-        setIsTorchAvailable('torch' in capabilities && !!capabilities.torch);
-    };
-
-    const viewfinderLayout = useRef<LayoutRectangle>(null);
+        setIsInitialPermissionQueried(true);
+    }, [isInitialPermissionQueried]);
 
     const getScreenshot = () => {
         if (!cameraRef.current) {
@@ -238,44 +147,11 @@ function IOURequestStepOdometerImage({
             });
     };
 
-    const clearTorchConstraints = () => {
-        if (!trackRef.current) {
-            return;
-        }
-        trackRef.current.applyConstraints({
-            advanced: [{torch: false}],
-        });
-    };
-
     const capturePhoto = () => {
-        if (trackRef.current && isFlashLightOn) {
-            trackRef.current
-                .applyConstraints({
-                    advanced: [{torch: true}],
-                })
-                .then(() => {
-                    getScreenshotTimeoutRef.current = setTimeout(() => {
-                        getScreenshot();
-                        clearTorchConstraints();
-                    }, CONST.RECEIPT.FLASH_DELAY_MS);
-                });
-            return;
-        }
-
-        getScreenshot();
+        capturePhotoWithFlash(getScreenshot);
     };
 
-    useEffect(
-        () => () => {
-            if (!getScreenshotTimeoutRef.current) {
-                return;
-            }
-            clearTimeout(getScreenshotTimeoutRef.current);
-        },
-        [],
-    );
-
-    const cameraLoadingReasonAttributes: SkeletonSpanReasonAttributes = {
+    const cameraLoadingReasonAttributes = {
         context: 'IOURequestStepOdometerImage',
         cameraPermissionState,
         isQueriedPermissionState,
@@ -523,7 +399,7 @@ function IOURequestStepOdometerImage({
 
 IOURequestStepOdometerImage.displayName = 'IOURequestStepOdometerImage';
 
-// eslint-disable-next-line rulesdir/no-negated-variables
+// eslint-disable-next-line rulesdir/no-negated-variables -- withFullTransactionOrNotFound HOC requires this pattern
 const IOURequestStepOdometerImageWithFullTransactionOrNotFound = withFullTransactionOrNotFound(IOURequestStepOdometerImage);
 
 export default IOURequestStepOdometerImageWithFullTransactionOrNotFound;
diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/MobileWebCameraView.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/MobileWebCameraView.tsx
index c829e9d9572e..7a9f9aeca0f9 100644
--- a/src/pages/iou/request/step/IOURequestStepScan/components/MobileWebCameraView.tsx
+++ b/src/pages/iou/request/step/IOURequestStepScan/components/MobileWebCameraView.tsx
@@ -1,10 +1,7 @@
-import {useIsFocused} from '@react-navigation/native';
-import React, {useCallback, useEffect, useReducer, useRef, useState} from 'react';
-import type {LayoutRectangle} from 'react-native';
+import React, {useCallback} from 'react';
 import {StyleSheet, View} from 'react-native';
 import type {OnyxEntry} from 'react-native-onyx';
 import Animated from 'react-native-reanimated';
-import type Webcam from 'react-webcam';
 import ActivityIndicator from '@components/ActivityIndicator';
 import AttachmentPicker from '@components/AttachmentPicker';
 import Button from '@components/Button';
@@ -17,15 +14,16 @@ import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hook
 import useLocalize from '@hooks/useLocalize';
 import useTheme from '@hooks/useTheme';
 import useThemeStyles from '@hooks/useThemeStyles';
-import {isMobileWebKit} from '@libs/Browser';
 import {base64ToFile} from '@libs/fileDownload/FileUtils';
 import {cancelSpan, endSpan, getSpan, startSpan} from '@libs/telemetry/activeSpans';
 import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan';
 import {cropImageToAspectRatio} from '@pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio';
 import type {ImageObject} from '@pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio';
 import useMobileReceiptScan from '@pages/iou/request/step/IOURequestStepScan/hooks/useMobileReceiptScan';
+import useWebCamera from '@pages/iou/request/step/IOURequestStepScan/hooks/useWebCamera';
 import type {ReceiptFile} from '@pages/iou/request/step/IOURequestStepScan/types';
 import StepScreenWrapper from '@pages/iou/request/step/StepScreenWrapper';
+import variables from '@styles/variables';
 import {setMoneyRequestReceipt} from '@userActions/IOU';
 import {buildOptimisticTransactionAndCreateDraft} from '@userActions/TransactionEdit';
 import CONST from '@src/CONST';
@@ -61,42 +59,6 @@ type MobileWebCameraViewProps = {
     shouldShowWrapper: boolean;
 };
 
-/**
- * Preload camera permission state at module load so first render can use a cached value.
- */
-let cachedPermissionState: PermissionState | undefined;
-
-if (typeof navigator !== 'undefined' && navigator.permissions) {
-    navigator.permissions
-        .query({name: 'camera'})
-        .then((status) => {
-            cachedPermissionState = status.state;
-            if ('addEventListener' in status) {
-                status.addEventListener('change', () => {
-                    cachedPermissionState = status.state;
-                });
-            }
-        })
-        .catch(() => {
-            cachedPermissionState = 'denied';
-        });
-}
-
-function queryCameraPermission(): Promise<PermissionState> {
-    if (cachedPermissionState !== undefined) {
-        return Promise.resolve(cachedPermissionState);
-    }
-
-    if (typeof navigator === 'undefined' || !navigator.permissions) {
-        return Promise.resolve('denied');
-    }
-
-    return navigator.permissions
-        .query({name: 'camera'})
-        .then((status) => status.state)
-        .catch(() => 'denied');
-}
-
 function MobileWebCameraView({
     initialTransaction,
     initialTransactionID,
@@ -137,124 +99,26 @@ function MobileWebCameraView({
     const {translate} = useLocalize();
     const lazyIllustrations = useMemoizedLazyIllustrations(['MultiScan', 'Hand', 'Shutter']);
     const lazyIcons = useMemoizedLazyExpensifyIcons(['Bolt', 'Gallery', 'ReceiptMultiple', 'boltSlash']);
-    const isTabActive = useIsFocused();
-    const [cameraPermissionState, setCameraPermissionState] = useState<PermissionState | undefined>(() => cachedPermissionState ?? 'prompt');
-    const [isFlashLightOn, toggleFlashlight] = useReducer((state: boolean) => !state, false);
-    const [isTorchAvailable, setIsTorchAvailable] = useState(false);
-    const [isQueriedPermissionState, setIsQueriedPermissionState] = useState(() => cachedPermissionState !== undefined);
-    const [deviceConstraints, setDeviceConstraints] = useState<MediaTrackConstraints>();
-    const videoConstraints = isTabActive ? deviceConstraints : undefined;
-    const cameraRef = useRef<Webcam>(null);
-    const trackRef = useRef<MediaStreamTrack | null>(null);
-    const viewfinderLayout = useRef<LayoutRectangle>(null);
-    const getScreenshotTimeoutRef = useRef<NodeJS.Timeout | null>(null);
 
-    /**
-     * On phones that have ultra-wide lens, react-webcam uses ultra-wide by default.
-     * The last deviceId is of regular lens camera.
-     */
-    const requestCameraPermission = useCallback(() => {
-        const defaultConstraints = {facingMode: {exact: 'environment'}};
-        navigator.mediaDevices
-            .getUserMedia({video: {facingMode: {exact: 'environment'}, zoom: {ideal: 1}}})
-            .then((stream) => {
-                setCameraPermissionState('granted');
-                for (const track of stream.getTracks()) {
-                    track.stop();
-                }
-                // Only Safari 17+ supports zoom constraint
-                if (isMobileWebKit() && stream.getTracks().length > 0) {
-                    let deviceId;
-                    for (const track of stream.getTracks()) {
-                        const setting = track.getSettings();
-                        if (setting.zoom === 1) {
-                            deviceId = setting.deviceId;
-                            break;
-                        }
-                    }
-                    if (deviceId) {
-                        setDeviceConstraints({deviceId});
-                        return;
-                    }
-                }
-                if (!navigator.mediaDevices.enumerateDevices) {
-                    setDeviceConstraints(defaultConstraints);
-                    return;
-                }
-                navigator.mediaDevices.enumerateDevices().then((devices) => {
-                    let lastBackDeviceId = '';
-                    for (let i = devices.length - 1; i >= 0; i--) {
-                        const device = devices.at(i);
-                        if (device?.kind === 'videoinput') {
-                            lastBackDeviceId = device.deviceId;
-                            break;
-                        }
-                    }
-                    if (!lastBackDeviceId) {
-                        setDeviceConstraints(defaultConstraints);
-                        return;
-                    }
-                    setDeviceConstraints({deviceId: lastBackDeviceId});
-                });
-            })
-            .catch(() => {
-                setDeviceConstraints(defaultConstraints);
-                setCameraPermissionState('denied');
-            });
+    const onUnmount = useCallback(() => {
+        cancelSpan(CONST.TELEMETRY.SPAN_SHUTTER_TO_CONFIRMATION);
+        cancelSpan(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE);
     }, []);
 
-    useEffect(() => {
-        if (!isTabActive) {
-            return;
-        }
-        queryCameraPermission()
-            .then((state) => {
-                setCameraPermissionState(state);
-                if (state === 'granted') {
-                    requestCameraPermission();
-                }
-            })
-            .catch(() => {
-                setCameraPermissionState('denied');
-            })
-            .finally(() => {
-                setIsQueriedPermissionState(true);
-            });
-        // Refresh permission state whenever this tab regains focus.
-    }, [isTabActive, requestCameraPermission]);
-
-    useEffect(
-        () => () => {
-            cancelSpan(CONST.TELEMETRY.SPAN_SHUTTER_TO_CONFIRMATION);
-            cancelSpan(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE);
-            if (!getScreenshotTimeoutRef.current) {
-                return;
-            }
-            clearTimeout(getScreenshotTimeoutRef.current);
-        },
-        [],
-    );
-
-    const setupCameraPermissionsAndCapabilities = (stream: MediaStream) => {
-        setCameraPermissionState('granted');
-
-        const [track] = stream.getVideoTracks();
-        const capabilities = track.getCapabilities();
-
-        if ('torch' in capabilities && capabilities.torch) {
-            trackRef.current = track;
-        }
-        setIsTorchAvailable('torch' in capabilities && !!capabilities.torch);
-    };
-
-    const clearTorchConstraints = () => {
-        if (!trackRef.current) {
-            return;
-        }
-        trackRef.current.applyConstraints({
-            advanced: [{torch: false}],
-        });
-    };
+    const {
+        cameraRef,
+        viewfinderLayout,
+        cameraPermissionState,
+        setCameraPermissionState,
+        isFlashLightOn,
+        toggleFlashlight,
+        isTorchAvailable,
+        isQueriedPermissionState,
+        videoConstraints,
+        requestCameraPermission,
+        setupCameraPermissionsAndCapabilities,
+        capturePhotoWithFlash,
+    } = useWebCamera({onUnmount});
 
     const onCapture = (file: FileObject, filename: string, source: string) => {
         const transaction =
@@ -331,21 +195,7 @@ function MobileWebCameraView({
     };
 
     const capturePhoto = () => {
-        if (trackRef.current && isFlashLightOn) {
-            trackRef.current
-                .applyConstraints({
-                    advanced: [{torch: true}],
-                })
-                .then(() => {
-                    getScreenshotTimeoutRef.current = setTimeout(() => {
-                        getScreenshot();
-                        clearTorchConstraints();
-                    }, CONST.RECEIPT.FLASH_DELAY_MS);
-                });
-            return;
-        }
-
-        getScreenshot();
+        capturePhotoWithFlash(getScreenshot);
     };
 
     return (
@@ -434,8 +284,8 @@ function MobileWebCameraView({
                                             sentryLabel={CONST.SENTRY_LABEL.REQUEST_STEP.SCAN.FLASH}
                                         >
                                             <Icon
-                                                height={16}
-                                                width={16}
+                                                height={variables.iconSizeSmall}
+                                                width={variables.iconSizeSmall}
                                                 src={lazyIcons.Bolt}
                                                 fill={isFlashLightOn ? theme.white : theme.icon}
                                             />
@@ -468,8 +318,8 @@ function MobileWebCameraView({
                                     sentryLabel={shouldAcceptMultipleFiles ? CONST.SENTRY_LABEL.REQUEST_STEP.SCAN.CHOOSE_FILES : CONST.SENTRY_LABEL.REQUEST_STEP.SCAN.CHOOSE_FILE}
                                 >
                                     <Icon
-                                        height={32}
-                                        width={32}
+                                        height={variables.iconSizeMenuItem}
+                                        width={variables.iconSizeMenuItem}
                                         src={lazyIcons.Gallery}
                                         fill={theme.textSupporting}
                                     />
@@ -499,8 +349,8 @@ function MobileWebCameraView({
                                 sentryLabel={CONST.SENTRY_LABEL.REQUEST_STEP.SCAN.MULTI_SCAN}
                             >
                                 <Icon
-                                    height={32}
-                                    width={32}
+                                    height={variables.iconSizeMenuItem}
+                                    width={variables.iconSizeMenuItem}
                                     src={lazyIcons.ReceiptMultiple}
                                     fill={isMultiScanEnabled ? theme.iconMenu : theme.textSupporting}
                                 />
@@ -515,8 +365,8 @@ function MobileWebCameraView({
                                 sentryLabel={CONST.SENTRY_LABEL.REQUEST_STEP.SCAN.FLASH}
                             >
                                 <Icon
-                                    height={32}
-                                    width={32}
+                                    height={variables.iconSizeMenuItem}
+                                    width={variables.iconSizeMenuItem}
                                     src={isFlashLightOn ? lazyIcons.Bolt : lazyIcons.boltSlash}
                                     fill={theme.textSupporting}
                                 />
diff --git a/src/pages/iou/request/step/IOURequestStepScan/hooks/useWebCamera.ts b/src/pages/iou/request/step/IOURequestStepScan/hooks/useWebCamera.ts
new file mode 100644
index 000000000000..695de3a0e393
--- /dev/null
+++ b/src/pages/iou/request/step/IOURequestStepScan/hooks/useWebCamera.ts
@@ -0,0 +1,208 @@
+import {useIsFocused} from '@react-navigation/native';
+import {useCallback, useEffect, useReducer, useRef, useState} from 'react';
+import type {LayoutRectangle} from 'react-native';
+import type Webcam from 'react-webcam';
+import {isMobileWebKit} from '@libs/Browser';
+import CONST from '@src/CONST';
+
+/**
+ * Preload camera permission state at module load so first render can use a cached value.
+ */
+let cachedPermissionState: PermissionState | undefined;
+
+if (typeof navigator !== 'undefined' && navigator.permissions) {
+    navigator.permissions
+        .query({name: 'camera'})
+        .then((status) => {
+            cachedPermissionState = status.state;
+            if ('addEventListener' in status) {
+                status.addEventListener('change', () => {
+                    cachedPermissionState = status.state;
+                });
+            }
+        })
+        .catch(() => {
+            cachedPermissionState = 'denied';
+        });
+}
+
+function queryCameraPermission(): Promise<PermissionState> {
+    if (cachedPermissionState !== undefined) {
+        return Promise.resolve(cachedPermissionState);
+    }
+
+    if (typeof navigator === 'undefined' || !navigator.permissions) {
+        return Promise.resolve('denied');
+    }
+
+    return navigator.permissions
+        .query({name: 'camera'})
+        .then((status) => status.state)
+        .catch(() => 'denied' as const);
+}
+
+type UseWebCameraOptions = {
+    /** Additional cleanup to run on unmount */
+    onUnmount?: () => void;
+};
+
+function useWebCamera({onUnmount}: UseWebCameraOptions = {}) {
+    const isTabActive = useIsFocused();
+    const [cameraPermissionState, setCameraPermissionState] = useState<PermissionState | undefined>(() => cachedPermissionState ?? 'prompt');
+    const [isFlashLightOn, toggleFlashlight] = useReducer((state: boolean) => !state, false);
+    const [isTorchAvailable, setIsTorchAvailable] = useState(false);
+    const [isQueriedPermissionState, setIsQueriedPermissionState] = useState(() => cachedPermissionState !== undefined);
+    const [deviceConstraints, setDeviceConstraints] = useState<MediaTrackConstraints>();
+    const videoConstraints = isTabActive ? deviceConstraints : undefined;
+    const cameraRef = useRef<Webcam>(null);
+    const trackRef = useRef<MediaStreamTrack | null>(null);
+    const viewfinderLayout = useRef<LayoutRectangle>(null);
+    const getScreenshotTimeoutRef = useRef<NodeJS.Timeout | null>(null);
+
+    /**
+     * On phones that have ultra-wide lens, react-webcam uses ultra-wide by default.
+     * The last deviceId is of regular lens camera.
+     */
+    const requestCameraPermission = useCallback(() => {
+        const defaultConstraints = {facingMode: {exact: 'environment'}};
+        navigator.mediaDevices
+            .getUserMedia({video: {facingMode: {exact: 'environment'}, zoom: {ideal: 1}}})
+            .then((stream) => {
+                setCameraPermissionState('granted');
+                for (const track of stream.getTracks()) {
+                    track.stop();
+                }
+                // Only Safari 17+ supports zoom constraint
+                if (isMobileWebKit() && stream.getTracks().length > 0) {
+                    let deviceId;
+                    for (const track of stream.getTracks()) {
+                        const setting = track.getSettings();
+                        if (setting.zoom === 1) {
+                            deviceId = setting.deviceId;
+                            break;
+                        }
+                    }
+                    if (deviceId) {
+                        setDeviceConstraints({deviceId});
+                        return;
+                    }
+                }
+                if (!navigator.mediaDevices.enumerateDevices) {
+                    setDeviceConstraints(defaultConstraints);
+                    return;
+                }
+                navigator.mediaDevices.enumerateDevices().then((devices) => {
+                    let lastBackDeviceId = '';
+                    for (let i = devices.length - 1; i >= 0; i--) {
+                        const device = devices.at(i);
+                        if (device?.kind === 'videoinput') {
+                            lastBackDeviceId = device.deviceId;
+                            break;
+                        }
+                    }
+                    if (!lastBackDeviceId) {
+                        setDeviceConstraints(defaultConstraints);
+                        return;
+                    }
+                    setDeviceConstraints({deviceId: lastBackDeviceId});
+                });
+            })
+            .catch(() => {
+                setDeviceConstraints(defaultConstraints);
+                setCameraPermissionState('denied');
+            });
+    }, []);
+
+    useEffect(() => {
+        if (!isTabActive) {
+            return;
+        }
+        queryCameraPermission()
+            .then((state) => {
+                setCameraPermissionState(state);
+                if (state === 'granted') {
+                    requestCameraPermission();
+                }
+            })
+            .catch(() => {
+                setCameraPermissionState('denied');
+            })
+            .finally(() => {
+                setIsQueriedPermissionState(true);
+            });
+        // Refresh permission state whenever this tab regains focus.
+    }, [isTabActive, requestCameraPermission]);
+
+    useEffect(
+        () => () => {
+            onUnmount?.();
+            if (!getScreenshotTimeoutRef.current) {
+                return;
+            }
+            clearTimeout(getScreenshotTimeoutRef.current);
+        },
+        // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- only run on unmount
+        [],
+    );
+
+    const setupCameraPermissionsAndCapabilities = useCallback((stream: MediaStream) => {
+        setCameraPermissionState('granted');
+
+        const [track] = stream.getVideoTracks();
+        const capabilities = track.getCapabilities();
+
+        if ('torch' in capabilities && capabilities.torch) {
+            trackRef.current = track;
+        }
+        setIsTorchAvailable('torch' in capabilities && !!capabilities.torch);
+    }, []);
+
+    const clearTorchConstraints = useCallback(() => {
+        if (!trackRef.current) {
+            return;
+        }
+        trackRef.current.applyConstraints({
+            advanced: [{torch: false}],
+        });
+    }, []);
+
+    const capturePhotoWithFlash = useCallback(
+        (getScreenshot: () => void) => {
+            if (trackRef.current && isFlashLightOn) {
+                trackRef.current
+                    .applyConstraints({
+                        advanced: [{torch: true}],
+                    })
+                    .then(() => {
+                        getScreenshotTimeoutRef.current = setTimeout(() => {
+                            getScreenshot();
+                            clearTorchConstraints();
+                        }, CONST.RECEIPT.FLASH_DELAY_MS);
+                    });
+                return;
+            }
+
+            getScreenshot();
+        },
+        [isFlashLightOn, clearTorchConstraints],
+    );
+
+    return {
+        cameraRef,
+        trackRef,
+        viewfinderLayout,
+        cameraPermissionState,
+        setCameraPermissionState,
+        isFlashLightOn,
+        toggleFlashlight,
+        isTorchAvailable,
+        isQueriedPermissionState,
+        videoConstraints,
+        requestCameraPermission,
+        setupCameraPermissionsAndCapabilities,
+        clearTorchConstraints,
+        capturePhotoWithFlash,
+    };
+}
+
+export default useWebCamera;

From d9439ecefa7e5425849c5234c8dca7034550f9f9 Mon Sep 17 00:00:00 2001
From: DylanDylann <dylandylan9112@gmail.com>
Date: Tue, 31 Mar 2026 11:24:05 +0700
Subject: [PATCH 03/10] fix failed checks

---
 .../iou/request/step/IOURequestStepScan/hooks/useWebCamera.ts   | 2 --
 1 file changed, 2 deletions(-)

diff --git a/src/pages/iou/request/step/IOURequestStepScan/hooks/useWebCamera.ts b/src/pages/iou/request/step/IOURequestStepScan/hooks/useWebCamera.ts
index 695de3a0e393..b80a5ea34f3b 100644
--- a/src/pages/iou/request/step/IOURequestStepScan/hooks/useWebCamera.ts
+++ b/src/pages/iou/request/step/IOURequestStepScan/hooks/useWebCamera.ts
@@ -135,13 +135,11 @@ function useWebCamera({onUnmount}: UseWebCameraOptions = {}) {
 
     useEffect(
         () => () => {
-            onUnmount?.();
             if (!getScreenshotTimeoutRef.current) {
                 return;
             }
             clearTimeout(getScreenshotTimeoutRef.current);
         },
-        // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- only run on unmount
         [],
     );
 

From 1ee0fdce951310c4f84d638ce7e7d9f6ecd5d7f0 Mon Sep 17 00:00:00 2001
From: DylanDylann <dylandylan9112@gmail.com>
Date: Tue, 31 Mar 2026 12:00:05 +0700
Subject: [PATCH 04/10] fix failed checks

---
 .../request/step/IOURequestStepOdometerImage/index.native.tsx  | 2 +-
 .../step/IOURequestStepScan/components/MobileWebCameraView.tsx | 2 +-
 .../iou/request/step/IOURequestStepScan/hooks/useWebCamera.ts  | 3 ++-
 3 files changed, 4 insertions(+), 3 deletions(-)

diff --git a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx
index fa84bb1ca25f..30141fc6ada4 100644
--- a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx
+++ b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx
@@ -271,7 +271,7 @@ function IOURequestStepOdometerImage({
                                 </View>
                                 <Animated.View
                                     pointerEvents="none"
-                                    style={[StyleSheet.absoluteFillObject, styles.backgroundWhite, blinkStyle, styles.zIndex10]}
+                                    style={[StyleSheet.absoluteFill, styles.backgroundWhite, blinkStyle, styles.zIndex10]}
                                 />
                                 <Animated.View style={[styles.cameraFocusIndicator, cameraFocusIndicatorAnimatedStyle]} />
                             </View>
diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/MobileWebCameraView.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/MobileWebCameraView.tsx
index 7a9f9aeca0f9..24272392fd12 100644
--- a/src/pages/iou/request/step/IOURequestStepScan/components/MobileWebCameraView.tsx
+++ b/src/pages/iou/request/step/IOURequestStepScan/components/MobileWebCameraView.tsx
@@ -294,7 +294,7 @@ function MobileWebCameraView({
                                 ) : null}
                                 <Animated.View
                                     pointerEvents="none"
-                                    style={[StyleSheet.absoluteFillObject, styles.backgroundWhite, blinkStyle, styles.zIndex10]}
+                                    style={[StyleSheet.absoluteFill, styles.backgroundWhite, blinkStyle, styles.zIndex10]}
                                 />
                             </View>
                         )}
diff --git a/src/pages/iou/request/step/IOURequestStepScan/hooks/useWebCamera.ts b/src/pages/iou/request/step/IOURequestStepScan/hooks/useWebCamera.ts
index b80a5ea34f3b..fb0ae063f858 100644
--- a/src/pages/iou/request/step/IOURequestStepScan/hooks/useWebCamera.ts
+++ b/src/pages/iou/request/step/IOURequestStepScan/hooks/useWebCamera.ts
@@ -135,12 +135,13 @@ function useWebCamera({onUnmount}: UseWebCameraOptions = {}) {
 
     useEffect(
         () => () => {
+            onUnmount?.();
             if (!getScreenshotTimeoutRef.current) {
                 return;
             }
             clearTimeout(getScreenshotTimeoutRef.current);
         },
-        [],
+        [onUnmount],
     );
 
     const setupCameraPermissionsAndCapabilities = useCallback((stream: MediaStream) => {

From 4854ca25eee713825fce8388fbb1a2788460574a Mon Sep 17 00:00:00 2001
From: DylanDylann <dylandylan9112@gmail.com>
Date: Tue, 31 Mar 2026 12:20:06 +0700
Subject: [PATCH 05/10] fix failed checks

---
 .../step/IOURequestStepScan/index.native.tsx  | 22 ++-----------------
 1 file changed, 2 insertions(+), 20 deletions(-)

diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx
index 2897d3a6dcf9..a3fe674bdc95 100644
--- a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx
+++ b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx
@@ -250,7 +250,7 @@ function IOURequestStepScan({
         cancelSpan(CONST.TELEMETRY.SPAN_SHUTTER_TO_CONFIRMATION);
     }, [isMultiScanEnabled]);
 
-    const capturePhoto = useCallback(() => {
+    const capturePhoto = () => {
         if (!isMultiScanEnabled) {
             startSpan(CONST.TELEMETRY.SPAN_SHUTTER_TO_CONFIRMATION, {
                 name: CONST.TELEMETRY.SPAN_SHUTTER_TO_CONFIRMATION,
@@ -346,25 +346,7 @@ function IOURequestStepScan({
                 showCameraAlert();
                 Log.warn('Error taking photo', error);
             });
-        // eslint-disable-next-line react-hooks/exhaustive-deps -- askForPermissions is not needed
-    }, [
-        cameraPermissionStatus,
-        isMultiScanEnabled,
-        translate,
-        showBlink,
-        flash,
-        hasFlash,
-        isPlatformMuted,
-        initialTransaction,
-        currentUserPersonalDetails,
-        reportID,
-        initialTransactionID,
-        isEditing,
-        receiptFiles,
-        submitReceipts,
-        updateScanAndNavigate,
-        askForPermissions,
-    ]);
+    }
 
     // Wait for camera permission status to render
     if (cameraPermissionStatus == null) {

From addde9ebed1a4c728dacfd002764fff193e6b8ac Mon Sep 17 00:00:00 2001
From: DylanDylann <dylandylan9112@gmail.com>
Date: Tue, 31 Mar 2026 12:35:56 +0700
Subject: [PATCH 06/10] prettier

---
 src/pages/iou/request/step/IOURequestStepScan/index.native.tsx | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx
index e47f0b292773..98b59e67e3ca 100644
--- a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx
+++ b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx
@@ -346,7 +346,7 @@ function IOURequestStepScan({
                 showCameraAlert();
                 Log.warn('Error taking photo', error);
             });
-    }
+    };
 
     // Wait for camera permission status to render
     if (cameraPermissionStatus == null) {

From 9414eefcbd34aad5b664f87d91264e7a28b51a3c Mon Sep 17 00:00:00 2001
From: DylanDylann <dylandylan9112@gmail.com>
Date: Wed, 1 Apr 2026 13:52:29 +0700
Subject: [PATCH 07/10] resolve comment

---
 .../hooks/useNativeCamera.ts                  |  5 +-
 .../hooks/useWebCamera.ts                     | 96 ++++++++++---------
 .../index.native.tsx                          |  5 +-
 .../IOURequestStepOdometerImage/index.tsx     | 12 +--
 .../components/MobileWebCameraView.tsx        |  2 +-
 .../step/IOURequestStepScan/index.native.tsx  |  5 +-
 6 files changed, 61 insertions(+), 64 deletions(-)
 rename src/{pages/iou/request/step/IOURequestStepScan => }/hooks/useNativeCamera.ts (98%)
 rename src/{pages/iou/request/step/IOURequestStepScan => }/hooks/useWebCamera.ts (75%)

diff --git a/src/pages/iou/request/step/IOURequestStepScan/hooks/useNativeCamera.ts b/src/hooks/useNativeCamera.ts
similarity index 98%
rename from src/pages/iou/request/step/IOURequestStepScan/hooks/useNativeCamera.ts
rename to src/hooks/useNativeCamera.ts
index 9d29b6d427b2..924a83c8463c 100644
--- a/src/pages/iou/request/step/IOURequestStepScan/hooks/useNativeCamera.ts
+++ b/src/hooks/useNativeCamera.ts
@@ -8,8 +8,6 @@ import type {Camera, Point} from 'react-native-vision-camera';
 import {useCameraDevice} from 'react-native-vision-camera';
 import {scheduleOnRN} from 'react-native-worklets';
 import {useFullScreenLoaderActions, useFullScreenLoaderState} from '@components/FullScreenLoaderContext';
-import useLocalize from '@hooks/useLocalize';
-import useOnyx from '@hooks/useOnyx';
 import {showCameraPermissionsAlert} from '@libs/fileDownload/FileUtils';
 import getPlatform from '@libs/getPlatform';
 import type Platform from '@libs/getPlatform/types';
@@ -18,6 +16,8 @@ import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan
 import CameraPermission from '@pages/iou/request/step/IOURequestStepScan/CameraPermission';
 import ONYXKEYS from '@src/ONYXKEYS';
 import {getEmptyObject} from '@src/types/utils/EmptyObject';
+import useLocalize from './useLocalize';
+import useOnyx from './useOnyx';
 
 type UseNativeCameraOptions = {
     /** Context name for telemetry reason attributes */
@@ -160,7 +160,6 @@ function useNativeCamera({context, onFocusStart, onFocusCleanup}: UseNativeCamer
         tapGesture,
         cameraFocusIndicatorAnimatedStyle,
         cameraLoadingReasonAttributes,
-        setIsLoaderVisible,
     };
 }
 
diff --git a/src/pages/iou/request/step/IOURequestStepScan/hooks/useWebCamera.ts b/src/hooks/useWebCamera.ts
similarity index 75%
rename from src/pages/iou/request/step/IOURequestStepScan/hooks/useWebCamera.ts
rename to src/hooks/useWebCamera.ts
index fb0ae063f858..2c3ca75611da 100644
--- a/src/pages/iou/request/step/IOURequestStepScan/hooks/useWebCamera.ts
+++ b/src/hooks/useWebCamera.ts
@@ -1,8 +1,8 @@
 import {useIsFocused} from '@react-navigation/native';
-import {useCallback, useEffect, useReducer, useRef, useState} from 'react';
+import {useEffect, useReducer, useRef, useState} from 'react';
 import type {LayoutRectangle} from 'react-native';
 import type Webcam from 'react-webcam';
-import {isMobileWebKit} from '@libs/Browser';
+import {isMobile, isMobileWebKit} from '@libs/Browser';
 import CONST from '@src/CONST';
 
 /**
@@ -63,7 +63,10 @@ function useWebCamera({onUnmount}: UseWebCameraOptions = {}) {
      * On phones that have ultra-wide lens, react-webcam uses ultra-wide by default.
      * The last deviceId is of regular lens camera.
      */
-    const requestCameraPermission = useCallback(() => {
+    const requestCameraPermission = () => {
+        if (!isMobile()) {
+            return;
+        }
         const defaultConstraints = {facingMode: {exact: 'environment'}};
         navigator.mediaDevices
             .getUserMedia({video: {facingMode: {exact: 'environment'}, zoom: {ideal: 1}}})
@@ -91,27 +94,32 @@ function useWebCamera({onUnmount}: UseWebCameraOptions = {}) {
                     setDeviceConstraints(defaultConstraints);
                     return;
                 }
-                navigator.mediaDevices.enumerateDevices().then((devices) => {
-                    let lastBackDeviceId = '';
-                    for (let i = devices.length - 1; i >= 0; i--) {
-                        const device = devices.at(i);
-                        if (device?.kind === 'videoinput') {
-                            lastBackDeviceId = device.deviceId;
-                            break;
+                navigator.mediaDevices
+                    .enumerateDevices()
+                    .then((devices) => {
+                        let lastBackDeviceId = '';
+                        for (let i = devices.length - 1; i >= 0; i--) {
+                            const device = devices.at(i);
+                            if (device?.kind === 'videoinput') {
+                                lastBackDeviceId = device.deviceId;
+                                break;
+                            }
                         }
-                    }
-                    if (!lastBackDeviceId) {
+                        if (!lastBackDeviceId) {
+                            setDeviceConstraints(defaultConstraints);
+                            return;
+                        }
+                        setDeviceConstraints({deviceId: lastBackDeviceId});
+                    })
+                    .catch(() => {
                         setDeviceConstraints(defaultConstraints);
-                        return;
-                    }
-                    setDeviceConstraints({deviceId: lastBackDeviceId});
-                });
+                    });
             })
             .catch(() => {
                 setDeviceConstraints(defaultConstraints);
                 setCameraPermissionState('denied');
             });
-    }, []);
+    };
 
     useEffect(() => {
         if (!isTabActive) {
@@ -130,8 +138,11 @@ function useWebCamera({onUnmount}: UseWebCameraOptions = {}) {
             .finally(() => {
                 setIsQueriedPermissionState(true);
             });
-        // Refresh permission state whenever this tab regains focus.
-    }, [isTabActive, requestCameraPermission]);
+        return () => {
+            setDeviceConstraints(undefined);
+        };
+        // eslint-disable-next-line react-hooks/exhaustive-deps
+    }, [isTabActive]);
 
     useEffect(
         () => () => {
@@ -144,7 +155,7 @@ function useWebCamera({onUnmount}: UseWebCameraOptions = {}) {
         [onUnmount],
     );
 
-    const setupCameraPermissionsAndCapabilities = useCallback((stream: MediaStream) => {
+    const setupCameraPermissionsAndCapabilities = (stream: MediaStream) => {
         setCameraPermissionState('granted');
 
         const [track] = stream.getVideoTracks();
@@ -154,41 +165,37 @@ function useWebCamera({onUnmount}: UseWebCameraOptions = {}) {
             trackRef.current = track;
         }
         setIsTorchAvailable('torch' in capabilities && !!capabilities.torch);
-    }, []);
+    };
 
-    const clearTorchConstraints = useCallback(() => {
+    const clearTorchConstraints = () => {
         if (!trackRef.current) {
             return;
         }
         trackRef.current.applyConstraints({
             advanced: [{torch: false}],
         });
-    }, []);
-
-    const capturePhotoWithFlash = useCallback(
-        (getScreenshot: () => void) => {
-            if (trackRef.current && isFlashLightOn) {
-                trackRef.current
-                    .applyConstraints({
-                        advanced: [{torch: true}],
-                    })
-                    .then(() => {
-                        getScreenshotTimeoutRef.current = setTimeout(() => {
-                            getScreenshot();
-                            clearTorchConstraints();
-                        }, CONST.RECEIPT.FLASH_DELAY_MS);
-                    });
-                return;
-            }
+    };
 
-            getScreenshot();
-        },
-        [isFlashLightOn, clearTorchConstraints],
-    );
+    const capturePhotoWithFlash = (getScreenshot: () => void) => {
+        if (trackRef.current && isFlashLightOn) {
+            trackRef.current
+                .applyConstraints({
+                    advanced: [{torch: true}],
+                })
+                .then(() => {
+                    getScreenshotTimeoutRef.current = setTimeout(() => {
+                        getScreenshot();
+                        clearTorchConstraints();
+                    }, CONST.RECEIPT.FLASH_DELAY_MS);
+                });
+            return;
+        }
+
+        getScreenshot();
+    };
 
     return {
         cameraRef,
-        trackRef,
         viewfinderLayout,
         cameraPermissionState,
         setCameraPermissionState,
@@ -199,7 +206,6 @@ function useWebCamera({onUnmount}: UseWebCameraOptions = {}) {
         videoConstraints,
         requestCameraPermission,
         setupCameraPermissionsAndCapabilities,
-        clearTorchConstraints,
         capturePhotoWithFlash,
     };
 }
diff --git a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx
index 30141fc6ada4..d6d77701a57e 100644
--- a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx
+++ b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx
@@ -9,6 +9,7 @@ import type {PhotoFile} from 'react-native-vision-camera';
 import ActivityIndicator from '@components/ActivityIndicator';
 import AttachmentPicker from '@components/AttachmentPicker';
 import Button from '@components/Button';
+import {useFullScreenLoaderActions} from '@components/FullScreenLoaderContext';
 import Icon from '@components/Icon';
 import ImageSVG from '@components/ImageSVG';
 import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
@@ -17,6 +18,7 @@ import Text from '@components/Text';
 import useFilesValidation from '@hooks/useFilesValidation';
 import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset';
 import useLocalize from '@hooks/useLocalize';
+import useNativeCamera from '@hooks/useNativeCamera';
 import useTheme from '@hooks/useTheme';
 import useThemeStyles from '@hooks/useThemeStyles';
 import getPhotoSource from '@libs/fileDownload/getPhotoSource';
@@ -27,7 +29,6 @@ import Navigation from '@libs/Navigation/Navigation';
 import NavigationAwareCamera from '@pages/iou/request/step/IOURequestStepScan/components/NavigationAwareCamera/Camera';
 import {cropImageToAspectRatio} from '@pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio';
 import type {ImageObject} from '@pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio';
-import useNativeCamera from '@pages/iou/request/step/IOURequestStepScan/hooks/useNativeCamera';
 import StepScreenWrapper from '@pages/iou/request/step/StepScreenWrapper';
 import withFullTransactionOrNotFound from '@pages/iou/request/step/withFullTransactionOrNotFound';
 import type {WithFullTransactionOrNotFoundProps} from '@pages/iou/request/step/withFullTransactionOrNotFound';
@@ -70,8 +71,8 @@ function IOURequestStepOdometerImage({
         tapGesture,
         cameraFocusIndicatorAnimatedStyle,
         cameraLoadingReasonAttributes,
-        setIsLoaderVisible,
     } = useNativeCamera({context: 'IOURequestStepOdometerImage'});
+    const {setIsLoaderVisible} = useFullScreenLoaderActions();
 
     const title = imageType === 'start' ? translate('distance.odometer.startTitle') : translate('distance.odometer.endTitle');
     const snapPhotoText = imageType === CONST.IOU.ODOMETER_IMAGE_TYPE.START ? translate('distance.odometer.snapPhotoStart') : translate('distance.odometer.snapPhotoEnd');
diff --git a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx
index ab4fb31391b2..3bd6dbae1023 100644
--- a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx
+++ b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx
@@ -16,6 +16,7 @@ import useLocalize from '@hooks/useLocalize';
 import useResponsiveLayout from '@hooks/useResponsiveLayout';
 import useTheme from '@hooks/useTheme';
 import useThemeStyles from '@hooks/useThemeStyles';
+import useWebCamera from '@hooks/useWebCamera';
 import {isMobile} from '@libs/Browser';
 import {base64ToFile} from '@libs/fileDownload/FileUtils';
 import {shouldUseTransactionDraft} from '@libs/IOUUtils';
@@ -24,7 +25,6 @@ import Navigation from '@libs/Navigation/Navigation';
 import NavigationAwareCamera from '@pages/iou/request/step/IOURequestStepScan/components/NavigationAwareCamera/WebCamera';
 import {cropImageToAspectRatio} from '@pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio';
 import type {ImageObject} from '@pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio';
-import useWebCamera from '@pages/iou/request/step/IOURequestStepScan/hooks/useWebCamera';
 import StepScreenDragAndDropWrapper from '@pages/iou/request/step/StepScreenDragAndDropWrapper';
 import withFullTransactionOrNotFound from '@pages/iou/request/step/withFullTransactionOrNotFound';
 import type {WithFullTransactionOrNotFoundProps} from '@pages/iou/request/step/withFullTransactionOrNotFound';
@@ -72,8 +72,6 @@ function IOURequestStepOdometerImage({
         capturePhotoWithFlash,
     } = useWebCamera();
 
-    const [isInitialPermissionQueried, setIsInitialPermissionQueried] = useState(false);
-
     const lazyIcons = useMemoizedLazyExpensifyIcons(['OdometerStart', 'OdometerEnd', 'Bolt', 'Gallery']);
     const lazyIllustrations = useMemoizedLazyIllustrations(['Hand', 'Shutter']);
     const title = imageType === CONST.IOU.ODOMETER_IMAGE_TYPE.START ? translate('distance.odometer.startTitle') : translate('distance.odometer.endTitle');
@@ -105,14 +103,6 @@ function IOURequestStepOdometerImage({
         handleImageSelected(file);
     });
 
-    // The odometer's own initial permission query for non-mobile (useWebCamera handles mobile)
-    useEffect(() => {
-        if (!isMobile() || isInitialPermissionQueried) {
-            return;
-        }
-        setIsInitialPermissionQueried(true);
-    }, [isInitialPermissionQueried]);
-
     const getScreenshot = () => {
         if (!cameraRef.current) {
             requestCameraPermission();
diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/MobileWebCameraView.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/MobileWebCameraView.tsx
index 24272392fd12..87e9a2303784 100644
--- a/src/pages/iou/request/step/IOURequestStepScan/components/MobileWebCameraView.tsx
+++ b/src/pages/iou/request/step/IOURequestStepScan/components/MobileWebCameraView.tsx
@@ -14,13 +14,13 @@ import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hook
 import useLocalize from '@hooks/useLocalize';
 import useTheme from '@hooks/useTheme';
 import useThemeStyles from '@hooks/useThemeStyles';
+import useWebCamera from '@hooks/useWebCamera';
 import {base64ToFile} from '@libs/fileDownload/FileUtils';
 import {cancelSpan, endSpan, getSpan, startSpan} from '@libs/telemetry/activeSpans';
 import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan';
 import {cropImageToAspectRatio} from '@pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio';
 import type {ImageObject} from '@pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio';
 import useMobileReceiptScan from '@pages/iou/request/step/IOURequestStepScan/hooks/useMobileReceiptScan';
-import useWebCamera from '@pages/iou/request/step/IOURequestStepScan/hooks/useWebCamera';
 import type {ReceiptFile} from '@pages/iou/request/step/IOURequestStepScan/types';
 import StepScreenWrapper from '@pages/iou/request/step/StepScreenWrapper';
 import variables from '@styles/variables';
diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx
index 014b6969123a..7e17b722d3ca 100644
--- a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx
+++ b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx
@@ -10,6 +10,7 @@ import ActivityIndicator from '@components/ActivityIndicator';
 import AttachmentPicker from '@components/AttachmentPicker';
 import Button from '@components/Button';
 import FeatureTrainingModal from '@components/FeatureTrainingModal';
+import {useFullScreenLoaderActions} from '@components/FullScreenLoaderContext';
 import Icon from '@components/Icon';
 import ImageSVG from '@components/ImageSVG';
 import LocationPermissionModal from '@components/LocationPermissionModal';
@@ -18,6 +19,7 @@ import Text from '@components/Text';
 import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails';
 import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset';
 import useLocalize from '@hooks/useLocalize';
+import useNativeCamera from '@hooks/useNativeCamera';
 import useOnyx from '@hooks/useOnyx';
 import usePolicy from '@hooks/usePolicy';
 import useStyleUtils from '@hooks/useStyleUtils';
@@ -45,7 +47,6 @@ import captureReceipt from './captureReceipt';
 import NavigationAwareCamera from './components/NavigationAwareCamera/Camera';
 import ReceiptPreviews from './components/ReceiptPreviews';
 import useMobileReceiptScan from './hooks/useMobileReceiptScan';
-import useNativeCamera from './hooks/useNativeCamera';
 import useReceiptScan from './hooks/useReceiptScan';
 import type IOURequestStepScanProps from './types';
 
@@ -94,8 +95,8 @@ function IOURequestStepScan({
         tapGesture,
         cameraFocusIndicatorAnimatedStyle,
         cameraLoadingReasonAttributes,
-        setIsLoaderVisible,
     } = useNativeCamera({context: 'IOURequestStepScan', onFocusStart, onFocusCleanup});
+    const {setIsLoaderVisible} = useFullScreenLoaderActions();
 
     const {windowWidth, windowHeight} = useWindowDimensions();
 

From eb6f6b249797e892457278fdb48c841c47442940 Mon Sep 17 00:00:00 2001
From: DylanDylann <dylandylan9112@gmail.com>
Date: Wed, 1 Apr 2026 13:55:02 +0700
Subject: [PATCH 08/10] resolve comment

---
 .../iou/request/step/IOURequestStepOdometerImage/index.tsx      | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx
index 3bd6dbae1023..b44428d51497 100644
--- a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx
+++ b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx
@@ -1,4 +1,4 @@
-import React, {useCallback, useEffect, useRef, useState} from 'react';
+import React, {useCallback, useEffect, useRef} from 'react';
 import {PanResponder, View} from 'react-native';
 import ActivityIndicator from '@components/ActivityIndicator';
 import AttachmentPicker from '@components/AttachmentPicker';

From 4cf9f04c9b03926c3dab09e3a3463bb5761b726b Mon Sep 17 00:00:00 2001
From: DylanDylann <dylandylan9112@gmail.com>
Date: Fri, 3 Apr 2026 16:34:10 +0700
Subject: [PATCH 09/10] fix loader problem

---
 src/hooks/useWebCamera.ts | 4 +---
 1 file changed, 1 insertion(+), 3 deletions(-)

diff --git a/src/hooks/useWebCamera.ts b/src/hooks/useWebCamera.ts
index 2c3ca75611da..6763de195a24 100644
--- a/src/hooks/useWebCamera.ts
+++ b/src/hooks/useWebCamera.ts
@@ -138,15 +138,13 @@ function useWebCamera({onUnmount}: UseWebCameraOptions = {}) {
             .finally(() => {
                 setIsQueriedPermissionState(true);
             });
-        return () => {
-            setDeviceConstraints(undefined);
-        };
         // eslint-disable-next-line react-hooks/exhaustive-deps
     }, [isTabActive]);
 
     useEffect(
         () => () => {
             onUnmount?.();
+            setDeviceConstraints(undefined);
             if (!getScreenshotTimeoutRef.current) {
                 return;
             }

From b5415de99fc45b42a3f7421370171ca64795a393 Mon Sep 17 00:00:00 2001
From: DylanDylann <dylandylan9112@gmail.com>
Date: Fri, 3 Apr 2026 23:55:28 +0700
Subject: [PATCH 10/10] resolve comments

---
 src/hooks/useNativeCamera.ts                  | 23 ++++++++-----------
 src/hooks/useWebCamera.ts                     | 18 +++++++++++++--
 .../step/IOURequestStepScan/index.native.tsx  | 12 +++++-----
 3 files changed, 32 insertions(+), 21 deletions(-)

diff --git a/src/hooks/useNativeCamera.ts b/src/hooks/useNativeCamera.ts
index 924a83c8463c..f09d5a01fde0 100644
--- a/src/hooks/useNativeCamera.ts
+++ b/src/hooks/useNativeCamera.ts
@@ -76,21 +76,18 @@ function useNativeCamera({context, onFocusStart, onFocusCleanup}: UseNativeCamer
         transform: [{translateX: focusIndicatorPosition.get().x}, {translateY: focusIndicatorPosition.get().y}, {scale: focusIndicatorScale.get()}],
     }));
 
-    const focusCamera = useCallback(
-        (point: Point) => {
-            if (!camera.current) {
+    const focusCamera = useCallback((point: Point) => {
+        if (!camera.current) {
+            return;
+        }
+
+        camera.current.focus(point).catch((error: Record<string, unknown>) => {
+            if (error.message === '[unknown/unknown] Cancelled by another startFocusAndMetering()') {
                 return;
             }
-
-            camera.current.focus(point).catch((error: Record<string, unknown>) => {
-                if (error.message === '[unknown/unknown] Cancelled by another startFocusAndMetering()') {
-                    return;
-                }
-                Log.warn('Error focusing camera', error);
-            });
-        },
-        [camera],
-    );
+            Log.warn('Error focusing camera', error);
+        });
+    }, []);
 
     const tapGesture = Gesture.Tap()
         .enabled(device?.supportsFocus ?? false)
diff --git a/src/hooks/useWebCamera.ts b/src/hooks/useWebCamera.ts
index 6763de195a24..387128beb1af 100644
--- a/src/hooks/useWebCamera.ts
+++ b/src/hooks/useWebCamera.ts
@@ -125,20 +125,34 @@ function useWebCamera({onUnmount}: UseWebCameraOptions = {}) {
         if (!isTabActive) {
             return;
         }
+        let ignore = false;
         queryCameraPermission()
             .then((state) => {
-                setCameraPermissionState(state);
+                if (ignore) {
+                    return;
+                }
                 if (state === 'granted') {
                     requestCameraPermission();
+                } else {
+                    setCameraPermissionState(state);
                 }
             })
             .catch(() => {
+                if (ignore) {
+                    return;
+                }
                 setCameraPermissionState('denied');
             })
             .finally(() => {
+                if (ignore) {
+                    return;
+                }
                 setIsQueriedPermissionState(true);
             });
-        // eslint-disable-next-line react-hooks/exhaustive-deps
+        return () => {
+            ignore = true;
+        };
+        // eslint-disable-next-line react-hooks/exhaustive-deps -- requestCameraPermission is defined inline so including it would cause an infinite loop
     }, [isTabActive]);
 
     useEffect(
diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx
index 7e17b722d3ca..d508b438e18b 100644
--- a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx
+++ b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx
@@ -70,14 +70,14 @@ function IOURequestStepScan({
     // Ref for double-tap protection (doesn't trigger re-render)
     const isCapturingPhoto = useRef(false);
 
-    const onFocusStart = useCallback(() => {
+    const onFocusStart = () => {
         isCapturingPhoto.current = false;
-    }, []);
+    };
 
-    const onFocusCleanup = useCallback(() => {
+    const onFocusCleanup = () => {
         cancelSpan(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE);
         cancelSpan(CONST.TELEMETRY.SPAN_SHUTTER_TO_CONFIRMATION);
-    }, []);
+    };
 
     const {
         camera,
@@ -113,9 +113,9 @@ function IOURequestStepScan({
     const cameraAspectRatio = format ? format.photoHeight / format.photoWidth : undefined;
     const fps = useMemo(() => (format ? Math.min(Math.max(30, format.minFps), format.maxFps) : 30), [format]);
 
-    const navigateBack = useCallback(() => {
+    const navigateBack = () => {
         Navigation.goBack(backTo);
-    }, [backTo]);
+    };
     const lazyIllustrations = useMemoizedLazyIllustrations(['MultiScan', 'Hand', 'Shutter']);
     const lazyIcons = useMemoizedLazyExpensifyIcons(['Bolt', 'Gallery', 'ReceiptMultiple', 'boltSlash']);
     const policy = usePolicy(report?.policyID);
