From 5bfe7bbcdc5709bfa3a215b0df5d2280461f0b85 Mon Sep 17 00:00:00 2001 From: DylanDylann 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; -function focusCamera(cameraRef: React.RefObject, point: Point) { - if (!cameraRef.current) { - return; - } - - cameraRef.current.focus(point).catch((error: Record) => { - 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>>()] = useOnyx(ONYXKEYS.NVP_MUTED_PLATFORMS); - const isPlatformMuted = mutedPlatforms[platform]; - - const [cameraPermissionStatus, setCameraPermissionStatus] = useState(null); - const hasFlash = !!device?.hasFlash; - const [flash, setFlash] = useState(false); - const [didCapturePhoto, setDidCapturePhoto] = useState(false); - const camera = useRef(null); const viewfinderLayout = useRef(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>>()] = useOnyx(ONYXKEYS.NVP_MUTED_PLATFORMS); + const isPlatformMuted = mutedPlatforms[platform]; + + const [cameraPermissionStatus, setCameraPermissionStatus] = useState(null); + const hasFlash = !!device?.hasFlash; + const [flash, setFlash] = useState(false); + const [didCapturePhoto, setDidCapturePhoto] = useState(false); + const [isAttachmentPickerActive, setIsAttachmentPickerActive] = useState(false); + const camera = useRef(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) => { + 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(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>>()] = useOnyx(ONYXKEYS.NVP_MUTED_PLATFORMS); - const isPlatformMuted = mutedPlatforms[platform]; - const [cameraPermissionStatus, setCameraPermissionStatus] = useState(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) => { - 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)} > @@ -591,8 +509,8 @@ function IOURequestStepScan({ }} > @@ -623,8 +541,8 @@ function IOURequestStepScan({ onPress={toggleMultiScan} > @@ -639,8 +557,8 @@ function IOURequestStepScan({ onPress={() => setFlash((prevFlash) => !prevFlash)} > @@ -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 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('prompt'); - const [isFlashLightOn, toggleFlashlight] = useReducer((state) => !state, false); - const [isTorchAvailable, setIsTorchAvailable] = useState(false); - const cameraRef = useRef(null); - const trackRef = useRef(null); - const [isQueriedPermissionState, setIsQueriedPermissionState] = useState(false); - const getScreenshotTimeoutRef = useRef(null); - const [videoConstraints, setVideoConstraints] = useState(); - 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(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 { - 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(() => 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(); - const videoConstraints = isTabActive ? deviceConstraints : undefined; - const cameraRef = useRef(null); - const trackRef = useRef(null); - const viewfinderLayout = useRef(null); - const getScreenshotTimeoutRef = useRef(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} > @@ -468,8 +318,8 @@ function MobileWebCameraView({ sentryLabel={shouldAcceptMultipleFiles ? CONST.SENTRY_LABEL.REQUEST_STEP.SCAN.CHOOSE_FILES : CONST.SENTRY_LABEL.REQUEST_STEP.SCAN.CHOOSE_FILE} > @@ -499,8 +349,8 @@ function MobileWebCameraView({ sentryLabel={CONST.SENTRY_LABEL.REQUEST_STEP.SCAN.MULTI_SCAN} > @@ -515,8 +365,8 @@ function MobileWebCameraView({ sentryLabel={CONST.SENTRY_LABEL.REQUEST_STEP.SCAN.FLASH} > 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 { + 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(() => 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(); + const videoConstraints = isTabActive ? deviceConstraints : undefined; + const cameraRef = useRef(null); + const trackRef = useRef(null); + const viewfinderLayout = useRef(null); + const getScreenshotTimeoutRef = useRef(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 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 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({ 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} )} 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 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 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 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 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 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 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) => { + if (error.message === '[unknown/unknown] Cancelled by another startFocusAndMetering()') { return; } - - camera.current.focus(point).catch((error: Record) => { - 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);