From 772be86f202d0b734ddd249bde99ead08c67a915 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Mon, 23 Feb 2026 20:09:34 +0100 Subject: [PATCH 01/14] Initial implementation of odometer image capture for mWeb --- .../IOURequestStepOdometerImage/index.tsx | 360 +++++++++++++++++- 1 file changed, 343 insertions(+), 17 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx index 0600c037240c6..dda0a36a9f40a 100644 --- a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx @@ -1,5 +1,5 @@ -import React, {useCallback, useEffect, useRef} from 'react'; -import {View} from 'react-native'; +import React, {useCallback, useEffect, useRef, useState, useReducer} from 'react'; +import {PanResponder, StyleSheet, View} from 'react-native'; import AttachmentPicker from '@components/AttachmentPicker'; import Button from '@components/Button'; import DragAndDropConsumer from '@components/DragAndDrop/Consumer'; @@ -9,7 +9,7 @@ import Icon from '@components/Icon'; import RenderHTML from '@components/RenderHTML'; import Text from '@components/Text'; import useFilesValidation from '@hooks/useFilesValidation'; -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; @@ -26,6 +26,19 @@ import type {IOUAction, IOUType} from '@src/CONST'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {FileObject} from '@src/types/utils/Attachment'; +import type {LayoutRectangle} from 'react-native'; +import {isMobile, isMobileWebKit} from '@libs/Browser'; +import type Webcam from 'react-webcam'; +import {useIsFocused} from '@react-navigation/native'; +import {base64ToFile} from '@libs/fileDownload/FileUtils'; +import {cropImageToAspectRatio} from '@pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio'; +import type {ImageObject} from '@pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import ActivityIndicator from '@components/ActivityIndicator'; +import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; +import NavigationAwareCamera from '@pages/iou/request/step/IOURequestStepScan/NavigationAwareCamera/WebCamera'; +import Animated, {useAnimatedStyle, useSharedValue} from 'react-native-reanimated'; +import Log from '@libs/Log'; type IOURequestStepOdometerImageProps = WithFullTransactionOrNotFoundProps; @@ -38,7 +51,6 @@ function IOURequestStepOdometerImage({ const styles = useThemeStyles(); const theme = useTheme(); const {isDraggingOver} = useDragAndDropState(); - const lazyIcons = useMemoizedLazyExpensifyIcons(['OdometerStart', 'OdometerEnd']); const actionValue: IOUAction = action ?? CONST.IOU.ACTION.CREATE; const iouTypeValue: IOUType = iouType ?? CONST.IOU.TYPE.REQUEST; const isTransactionDraft = shouldUseTransactionDraft(actionValue, iouTypeValue); @@ -48,6 +60,18 @@ 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 lazyIllustrations = useMemoizedLazyIllustrations(['Hand', 'ReceiptStack', 'Shutter']); + const lazyIcons = useMemoizedLazyExpensifyIcons(['OdometerStart', 'OdometerEnd', 'Bolt', 'Gallery', 'ReceiptMultiple', 'boltSlash', 'ReplaceReceipt', 'SmartScan']); const title = imageType === CONST.IOU.ODOMETER_IMAGE_TYPE.START ? translate('distance.odometer.startTitle') : translate('distance.odometer.endTitle'); const message = imageType === CONST.IOU.ODOMETER_IMAGE_TYPE.START ? translate('distance.odometer.startMessageWeb') : translate('distance.odometer.endMessageWeb'); const icon = imageType === CONST.IOU.ODOMETER_IMAGE_TYPE.START ? lazyIcons.OdometerStart : lazyIcons.OdometerEnd; @@ -59,15 +83,6 @@ function IOURequestStepOdometerImage({ Navigation.goBack(odometerRoute); }, [odometerRoute]); - const revokeDropBlobUrls = useCallback(() => { - for (const url of dropBlobUrlsRef.current) { - if (url.startsWith('blob:')) { - URL.revokeObjectURL(url); - } - } - dropBlobUrlsRef.current = []; - }, []); - const handleImageSelected = useCallback( (file: FileObject) => { setMoneyRequestOdometerImage(transactionID, imageType, file as File, isTransactionDraft); @@ -89,6 +104,303 @@ function IOURequestStepOdometerImage({ 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); + setCameraPermissionState('denied'); + }); + }, []); + + useEffect(() => { + if (!isMobile() || !isTabActive) { + setVideoConstraints(undefined); + return; + } + navigator.permissions + .query({ + name: 'camera', + }) + .then((permissionState) => { + setCameraPermissionState(permissionState.state); + if (permissionState.state === 'granted') { + requestCameraPermission(); + } + }) + .catch(() => { + setCameraPermissionState('denied'); + }) + .finally(() => { + setIsQueriedPermissionState(true); + }); + // We only want to get the camera permission status when the component is mounted + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isTabActive]); + + 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); + + const getScreenshot = useCallback(() => { + if (!cameraRef.current) { + requestCameraPermission(); + return; + } + + const imageBase64 = cameraRef.current.getScreenshot(); + + if (imageBase64 === null) { + return; + } + + const originalFileName = `receipt_${Date.now()}.png`; + const originalFile = base64ToFile(imageBase64 ?? '', originalFileName); + const imageObject: ImageObject = {file: originalFile, filename: originalFile.name, source: URL.createObjectURL(originalFile)}; + // Some browsers center-crop the viewfinder inside the video element (due to object-position: center), + // while other browsers let the video element overflow and the container crops it from the top. + // We crop and algin the result image the same way. + const videoHeight = cameraRef.current.video?.getBoundingClientRect?.()?.height ?? NaN; + const viewFinderHeight = viewfinderLayout.current?.height ?? NaN; + const shouldAlignTop = videoHeight > viewFinderHeight; + cropImageToAspectRatio(imageObject, viewfinderLayout.current?.width, viewfinderLayout.current?.height, shouldAlignTop) + .then(({source}) => { + setMoneyRequestOdometerImage(transactionID, imageType, source, isTransactionDraft); + navigateBack(); + }) + .catch((error: unknown) => { + Log.warn('Error cropping photo', error instanceof Error ? error.message : String(error)); + }); + }, [requestCameraPermission]); + + const clearTorchConstraints = useCallback(() => { + if (!trackRef.current) { + return; + } + trackRef.current.applyConstraints({ + advanced: [{torch: false}], + }); + }, []); + + const capturePhoto = useCallback(() => { + if (trackRef.current && isFlashLightOn) { + trackRef.current + .applyConstraints({ + advanced: [{torch: true}], + }) + .then(() => { + getScreenshotTimeoutRef.current = setTimeout(() => { + getScreenshot(); + clearTorchConstraints(); + }, 2000); + }); + return; + } + + getScreenshot(); + }, [isFlashLightOn, getScreenshot, clearTorchConstraints]); + + useEffect( + () => () => { + if (!getScreenshotTimeoutRef.current) { + return; + } + clearTimeout(getScreenshotTimeoutRef.current); + }, + [], + ); + + const blinkOpacity = useSharedValue(0); + const blinkStyle = useAnimatedStyle(() => ({ + opacity: blinkOpacity.get(), + })); + + const mobileCameraView = () => ( + <> + + {((cameraPermissionState === 'prompt' && !isQueriedPermissionState) || (cameraPermissionState === 'granted' && isEmptyObject(videoConstraints))) && ( + + )} + {cameraPermissionState !== 'granted' && isQueriedPermissionState && ( + + + {translate('receipt.takePhoto')} + {cameraPermissionState === 'denied' ? ( + + + + ) : ( + {translate('receipt.cameraAccess')} + )} +