From 40425d1303336993a023ba67213e3b5d9835fc76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Thu, 26 Feb 2026 12:04:09 +0100 Subject: [PATCH 01/21] Initial implementation of image stitching for Odometer --- src/hooks/useOdometerImageStitching.native.ts | 60 +++++++++++++++++++ src/hooks/useOdometerImageStitching.ts | 60 +++++++++++++++++++ .../step/IOURequestStepDistanceOdometer.tsx | 24 +++++--- 3 files changed, 135 insertions(+), 9 deletions(-) create mode 100644 src/hooks/useOdometerImageStitching.native.ts create mode 100644 src/hooks/useOdometerImageStitching.ts diff --git a/src/hooks/useOdometerImageStitching.native.ts b/src/hooks/useOdometerImageStitching.native.ts new file mode 100644 index 0000000000000..1fd75e9ca5164 --- /dev/null +++ b/src/hooks/useOdometerImageStitching.native.ts @@ -0,0 +1,60 @@ +import {Skia} from '@shopify/react-native-skia'; +import {useCallback} from 'react'; +import RNFS from 'react-native-fs'; +import type {FileObject} from '@src/types/utils/Attachment'; + +function useOdometerImageStitching(image1: FileObject | string | undefined, image2: FileObject | string | undefined): () => Promise { + return useCallback(async () => { + const source1 = typeof image1 === 'string' ? image1 : (image1?.uri ?? null); + const source2 = typeof image2 === 'string' ? image2 : (image2?.uri ?? null); + + if (!source1 || !source2) { + return null; + } + + const [buffer1, buffer2] = await Promise.all([fetch(source1).then((r) => r.arrayBuffer()), fetch(source2).then((r) => r.arrayBuffer())]); + + const skImage1 = Skia.Image.MakeImageFromEncoded(Skia.Data.fromBytes(new Uint8Array(buffer1))); + const skImage2 = Skia.Image.MakeImageFromEncoded(Skia.Data.fromBytes(new Uint8Array(buffer2))); + + if (!skImage1 || !skImage2) { + return null; + } + + let width: number; + let height: number; + let horizontal = true; + + if (skImage1.width() > skImage1.height() || skImage2.width() > skImage2.height()) { + width = Math.max(skImage1.width(), skImage2.width()); + height = skImage1.height() + skImage2.height(); + horizontal = false; + } else { + width = skImage1.width() + skImage2.width(); + height = Math.max(skImage1.height(), skImage2.height()); + } + + const surface = Skia.Surface.MakeOffscreen(width, height); + if (!surface) { + return null; + } + + const canvas = surface.getCanvas(); + canvas.drawImage(skImage1, 0, 0); + canvas.drawImage(skImage2, horizontal ? skImage1.width() : 0, horizontal ? 0 : skImage1.height()); + surface.flush(); + + const result = surface.makeImageSnapshot(); + const base64 = result.encodeToBase64(); + + // Write to a temp file so the receipt is treated as a local file (same as camera receipts), + // ensuring compatibility with file system validation and display utilities. + const filename = `stitched_odometer_${Date.now()}.jpg`; + const tempPath = `${RNFS.TemporaryDirectoryPath}/${filename}`; + await RNFS.writeFile(tempPath, base64, 'base64'); + + return {uri: `file://${tempPath}`, name: filename, type: 'image/jpeg'}; + }, [image1, image2]); +} + +export default useOdometerImageStitching; diff --git a/src/hooks/useOdometerImageStitching.ts b/src/hooks/useOdometerImageStitching.ts new file mode 100644 index 0000000000000..65ccf6c5a18e1 --- /dev/null +++ b/src/hooks/useOdometerImageStitching.ts @@ -0,0 +1,60 @@ +import {useCallback} from 'react'; +import type {FileObject} from '@src/types/utils/Attachment'; + +function useOdometerImageStitching(image1: FileObject | string | undefined, image2: FileObject | string | undefined): () => Promise { + return useCallback(async () => { + const source1 = typeof image1 === 'string' ? image1 : (image1?.uri ?? null); + const source2 = typeof image2 === 'string' ? image2 : (image2?.uri ?? null); + + if (!source1 || !source2) { + return null; + } + + const loadImage = (src: string): Promise => + new Promise((resolve, reject) => { + const img = new window.Image(); + img.onload = () => resolve(img); + img.onerror = reject; + img.src = src; + }); + + const [img1, img2] = await Promise.all([loadImage(source1), loadImage(source2)]); + + let width: number; + let height: number; + let horizontal = true; + + if (img1.width > img1.height || img2.width > img2.height) { + width = Math.max(img1.width, img2.width); + height = img1.height + img2.height; + horizontal = false; + } else { + width = img1.width + img2.width; + height = Math.max(img1.height, img2.height); + } + + const offscreenCanvas = document.createElement('canvas'); + offscreenCanvas.width = width; + offscreenCanvas.height = height; + const ctx = offscreenCanvas.getContext('2d'); + if (!ctx) { + return null; + } + + ctx.drawImage(img1, 0, 0); + ctx.drawImage(img2, horizontal ? img1.width : 0, horizontal ? 0 : img1.height); + + const blob = await new Promise((resolve) => { + offscreenCanvas.toBlob((b) => resolve(b), 'image/jpeg', 0.9); + }); + + if (!blob) { + return null; + } + + const uri = URL.createObjectURL(blob); + return {uri, name: 'stitched_image.jpg', type: 'image/jpeg'}; + }, [image1, image2]); +} + +export default useOdometerImageStitching; diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 53e331bc4504a..e121d3dde215e 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -14,6 +14,7 @@ import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentU import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; import useDefaultExpensePolicy from '@hooks/useDefaultExpensePolicy'; import useLocalize from '@hooks/useLocalize'; +import useOdometerImageStitching from '@hooks/useOdometerImageStitching'; import useOnyx from '@hooks/useOnyx'; import usePersonalPolicy from '@hooks/usePersonalPolicy'; import usePolicy from '@hooks/usePolicy'; @@ -24,7 +25,7 @@ import useSelfDMReport from '@hooks/useSelfDMReport'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import {setMoneyRequestDistance, setMoneyRequestOdometerReading, updateMoneyRequestDistance} from '@libs/actions/IOU'; +import {setMoneyRequestDistance, setMoneyRequestOdometerReading, setMoneyRequestReceipt, updateMoneyRequestDistance} from '@libs/actions/IOU'; import {handleMoneyRequestStepDistanceNavigation} from '@libs/actions/IOU/MoneyRequest'; import {setDraftSplitTransaction} from '@libs/actions/IOU/Split'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; @@ -77,6 +78,7 @@ function IOURequestStepDistanceOdometer({ const [startReading, setStartReading] = useState(''); const [endReading, setEndReading] = useState(''); const [formError, setFormError] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); // Key to force TextInput remount when resetting state after tab switch const [inputKey, setInputKey] = useState(0); @@ -344,20 +346,18 @@ function IOURequestStepDistanceOdometer({ const [recentWaypoints] = useOnyx(ONYXKEYS.NVP_RECENT_WAYPOINTS); const [betas] = useOnyx(ONYXKEYS.BETAS); + const stitchImages = useOdometerImageStitching(odometerStartImage, odometerEndImage); // Navigate to next page following Manual tab pattern - const navigateToNextPage = () => { + const navigateToNextPage = async () => { const start = parseFloat(startReading); const end = parseFloat(endReading); - - // Store odometer readings in transaction.comment.odometerStart/odometerEnd - setMoneyRequestOdometerReading(transactionID, start, end, isTransactionDraft); - - // Calculate total distance (endReading - startReading) const distance = end - start; const calculatedDistance = roundToTwoDecimalPlaces(distance); - // Store total distance in transaction.comment.customUnit.quantity + setMoneyRequestOdometerReading(transactionID, start, end, isTransactionDraft); setMoneyRequestDistance(transactionID, calculatedDistance, isTransactionDraft, unit); + const stitchedImage = await stitchImages(); + setMoneyRequestReceipt(transactionID, stitchedImage?.uri, stitchedImage?.name, isTransactionDraft); if (isEditing) { // In the split flow, when editing we use SPLIT_TRANSACTION_DRAFT to save draft value @@ -456,6 +456,10 @@ function IOURequestStepDistanceOdometer({ // Handle form submission with validation const handleNext = () => { + if (isSubmitting) { + return; + } + // Validation: Start and end readings must not be empty if (!startReading || !endReading) { setFormError(translate('iou.error.invalidReadings')); @@ -478,7 +482,8 @@ function IOURequestStepDistanceOdometer({ } // When validation passes, call navigateToNextPage - navigateToNextPage(); + setIsSubmitting(true); + navigateToNextPage().finally(() => setIsSubmitting(false)); }; return ( @@ -610,6 +615,7 @@ function IOURequestStepDistanceOdometer({ success allowBubble={!isEditing} pressOnEnter + isLoading={isSubmitting} medium={isExtraSmallScreenHeight} large={!isExtraSmallScreenHeight} style={[styles.w100]} From d1f1717fdf4cc6b722f8fef69031c74a34bac9aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Tue, 3 Mar 2026 16:22:27 +0100 Subject: [PATCH 02/21] improvement: moved imageStitching feature from hooks to lib --- src/hooks/useOdometerImageStitching.native.ts | 60 ------------------ src/hooks/useOdometerImageStitching.ts | 60 ------------------ src/libs/stitchOdometerImages/index.native.ts | 57 +++++++++++++++++ src/libs/stitchOdometerImages/index.ts | 61 +++++++++++++++++++ .../step/IOURequestStepDistanceOdometer.tsx | 11 ++-- 5 files changed, 124 insertions(+), 125 deletions(-) delete mode 100644 src/hooks/useOdometerImageStitching.native.ts delete mode 100644 src/hooks/useOdometerImageStitching.ts create mode 100644 src/libs/stitchOdometerImages/index.native.ts create mode 100644 src/libs/stitchOdometerImages/index.ts diff --git a/src/hooks/useOdometerImageStitching.native.ts b/src/hooks/useOdometerImageStitching.native.ts deleted file mode 100644 index 1fd75e9ca5164..0000000000000 --- a/src/hooks/useOdometerImageStitching.native.ts +++ /dev/null @@ -1,60 +0,0 @@ -import {Skia} from '@shopify/react-native-skia'; -import {useCallback} from 'react'; -import RNFS from 'react-native-fs'; -import type {FileObject} from '@src/types/utils/Attachment'; - -function useOdometerImageStitching(image1: FileObject | string | undefined, image2: FileObject | string | undefined): () => Promise { - return useCallback(async () => { - const source1 = typeof image1 === 'string' ? image1 : (image1?.uri ?? null); - const source2 = typeof image2 === 'string' ? image2 : (image2?.uri ?? null); - - if (!source1 || !source2) { - return null; - } - - const [buffer1, buffer2] = await Promise.all([fetch(source1).then((r) => r.arrayBuffer()), fetch(source2).then((r) => r.arrayBuffer())]); - - const skImage1 = Skia.Image.MakeImageFromEncoded(Skia.Data.fromBytes(new Uint8Array(buffer1))); - const skImage2 = Skia.Image.MakeImageFromEncoded(Skia.Data.fromBytes(new Uint8Array(buffer2))); - - if (!skImage1 || !skImage2) { - return null; - } - - let width: number; - let height: number; - let horizontal = true; - - if (skImage1.width() > skImage1.height() || skImage2.width() > skImage2.height()) { - width = Math.max(skImage1.width(), skImage2.width()); - height = skImage1.height() + skImage2.height(); - horizontal = false; - } else { - width = skImage1.width() + skImage2.width(); - height = Math.max(skImage1.height(), skImage2.height()); - } - - const surface = Skia.Surface.MakeOffscreen(width, height); - if (!surface) { - return null; - } - - const canvas = surface.getCanvas(); - canvas.drawImage(skImage1, 0, 0); - canvas.drawImage(skImage2, horizontal ? skImage1.width() : 0, horizontal ? 0 : skImage1.height()); - surface.flush(); - - const result = surface.makeImageSnapshot(); - const base64 = result.encodeToBase64(); - - // Write to a temp file so the receipt is treated as a local file (same as camera receipts), - // ensuring compatibility with file system validation and display utilities. - const filename = `stitched_odometer_${Date.now()}.jpg`; - const tempPath = `${RNFS.TemporaryDirectoryPath}/${filename}`; - await RNFS.writeFile(tempPath, base64, 'base64'); - - return {uri: `file://${tempPath}`, name: filename, type: 'image/jpeg'}; - }, [image1, image2]); -} - -export default useOdometerImageStitching; diff --git a/src/hooks/useOdometerImageStitching.ts b/src/hooks/useOdometerImageStitching.ts deleted file mode 100644 index 65ccf6c5a18e1..0000000000000 --- a/src/hooks/useOdometerImageStitching.ts +++ /dev/null @@ -1,60 +0,0 @@ -import {useCallback} from 'react'; -import type {FileObject} from '@src/types/utils/Attachment'; - -function useOdometerImageStitching(image1: FileObject | string | undefined, image2: FileObject | string | undefined): () => Promise { - return useCallback(async () => { - const source1 = typeof image1 === 'string' ? image1 : (image1?.uri ?? null); - const source2 = typeof image2 === 'string' ? image2 : (image2?.uri ?? null); - - if (!source1 || !source2) { - return null; - } - - const loadImage = (src: string): Promise => - new Promise((resolve, reject) => { - const img = new window.Image(); - img.onload = () => resolve(img); - img.onerror = reject; - img.src = src; - }); - - const [img1, img2] = await Promise.all([loadImage(source1), loadImage(source2)]); - - let width: number; - let height: number; - let horizontal = true; - - if (img1.width > img1.height || img2.width > img2.height) { - width = Math.max(img1.width, img2.width); - height = img1.height + img2.height; - horizontal = false; - } else { - width = img1.width + img2.width; - height = Math.max(img1.height, img2.height); - } - - const offscreenCanvas = document.createElement('canvas'); - offscreenCanvas.width = width; - offscreenCanvas.height = height; - const ctx = offscreenCanvas.getContext('2d'); - if (!ctx) { - return null; - } - - ctx.drawImage(img1, 0, 0); - ctx.drawImage(img2, horizontal ? img1.width : 0, horizontal ? 0 : img1.height); - - const blob = await new Promise((resolve) => { - offscreenCanvas.toBlob((b) => resolve(b), 'image/jpeg', 0.9); - }); - - if (!blob) { - return null; - } - - const uri = URL.createObjectURL(blob); - return {uri, name: 'stitched_image.jpg', type: 'image/jpeg'}; - }, [image1, image2]); -} - -export default useOdometerImageStitching; diff --git a/src/libs/stitchOdometerImages/index.native.ts b/src/libs/stitchOdometerImages/index.native.ts new file mode 100644 index 0000000000000..c87a9c85413fa --- /dev/null +++ b/src/libs/stitchOdometerImages/index.native.ts @@ -0,0 +1,57 @@ +import {Skia} from '@shopify/react-native-skia'; +import RNFS from 'react-native-fs'; +import type {FileObject} from '@src/types/utils/Attachment'; + +async function stitchOdometerImages(image1: FileObject | string | undefined, image2: FileObject | string | undefined): Promise { + const source1 = typeof image1 === 'string' ? image1 : (image1?.uri ?? null); + const source2 = typeof image2 === 'string' ? image2 : (image2?.uri ?? null); + + if (!source1 || !source2) { + return null; + } + + const [buffer1, buffer2] = await Promise.all([fetch(source1).then((r) => r.arrayBuffer()), fetch(source2).then((r) => r.arrayBuffer())]); + + const skImage1 = Skia.Image.MakeImageFromEncoded(Skia.Data.fromBytes(new Uint8Array(buffer1))); + const skImage2 = Skia.Image.MakeImageFromEncoded(Skia.Data.fromBytes(new Uint8Array(buffer2))); + + if (!skImage1 || !skImage2) { + return null; + } + + let width: number; + let height: number; + let horizontal = true; + + if (skImage1.width() > skImage1.height() || skImage2.width() > skImage2.height()) { + width = Math.max(skImage1.width(), skImage2.width()); + height = skImage1.height() + skImage2.height(); + horizontal = false; + } else { + width = skImage1.width() + skImage2.width(); + height = Math.max(skImage1.height(), skImage2.height()); + } + + const surface = Skia.Surface.MakeOffscreen(width, height); + if (!surface) { + return null; + } + + const canvas = surface.getCanvas(); + canvas.drawImage(skImage1, 0, 0); + canvas.drawImage(skImage2, horizontal ? skImage1.width() : 0, horizontal ? 0 : skImage1.height()); + surface.flush(); + + const result = surface.makeImageSnapshot(); + const base64 = result.encodeToBase64(); + + // Write to a temp file so the receipt is treated as a local file (same as camera receipts), + // ensuring compatibility with file system validation and display utilities. + const filename = `stitched_odometer_${Date.now()}.jpg`; + const tempPath = `${RNFS.TemporaryDirectoryPath}/${filename}`; + await RNFS.writeFile(tempPath, base64, 'base64'); + + return {uri: `file://${tempPath}`, name: filename, type: 'image/jpeg'}; +} + +export default stitchOdometerImages; diff --git a/src/libs/stitchOdometerImages/index.ts b/src/libs/stitchOdometerImages/index.ts new file mode 100644 index 0000000000000..3a7845de8a6d4 --- /dev/null +++ b/src/libs/stitchOdometerImages/index.ts @@ -0,0 +1,61 @@ +import type {FileObject} from '@src/types/utils/Attachment'; + +function stitchOdometerImages(image1: FileObject | string | undefined, image2: FileObject | string | undefined): Promise { + const source1 = typeof image1 === 'string' ? image1 : (image1?.uri ?? null); + const source2 = typeof image2 === 'string' ? image2 : (image2?.uri ?? null); + + if (!source1 || !source2) { + return Promise.resolve(null); + } + + const loadImage = (src: string): Promise => + new Promise((resolve, reject) => { + const img = new window.Image(); + img.onload = () => resolve(img); + img.onerror = reject; + img.src = src; + }); + + return Promise.all([loadImage(source1), loadImage(source2)]).then(([img1, img2]) => { + let width: number; + let height: number; + let horizontal = true; + + if (img1.width > img1.height || img2.width > img2.height) { + width = Math.max(img1.width, img2.width); + height = img1.height + img2.height; + horizontal = false; + } else { + width = img1.width + img2.width; + height = Math.max(img1.height, img2.height); + } + + const offscreenCanvas = document.createElement('canvas'); + offscreenCanvas.width = width; + offscreenCanvas.height = height; + const ctx = offscreenCanvas.getContext('2d'); + if (!ctx) { + return null; + } + + ctx.drawImage(img1, 0, 0); + ctx.drawImage(img2, horizontal ? img1.width : 0, horizontal ? 0 : img1.height); + + return new Promise((resolve) => { + offscreenCanvas.toBlob( + (blob) => { + if (!blob) { + resolve(null); + return; + } + const uri = URL.createObjectURL(blob); + resolve({uri, name: 'stitched_image.jpg', type: 'image/jpeg'}); + }, + 'image/jpeg', + 0.9, + ); + }); + }); +} + +export default stitchOdometerImages; diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index e4223bd41d69b..a20c197e00afb 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -14,7 +14,6 @@ import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalD import useDefaultExpensePolicy from '@hooks/useDefaultExpensePolicy'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; -import useOdometerImageStitching from '@hooks/useOdometerImageStitching'; import useOnyx from '@hooks/useOnyx'; import usePersonalPolicy from '@hooks/usePersonalPolicy'; import usePolicy from '@hooks/usePolicy'; @@ -35,6 +34,7 @@ import Navigation from '@libs/Navigation/Navigation'; import {roundToTwoDecimalPlaces} from '@libs/NumberUtils'; import {isArchivedReport, isPolicyExpenseChat as isPolicyExpenseChatUtils} from '@libs/ReportUtils'; import shouldUseDefaultExpensePolicyUtil from '@libs/shouldUseDefaultExpensePolicy'; +import stitchOdometerImages from '@libs/stitchOdometerImages'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import type {OdometerImageType} from '@src/CONST'; @@ -363,9 +363,8 @@ function IOURequestStepDistanceOdometer({ const [recentWaypoints] = useOnyx(ONYXKEYS.NVP_RECENT_WAYPOINTS); const [betas] = useOnyx(ONYXKEYS.BETAS); const icons = useMemoizedLazyExpensifyIcons(['GalleryPlus'] as const); - const stitchImages = useOdometerImageStitching(odometerStartImage, odometerEndImage); // Navigate to next page following Manual tab pattern - const navigateToNextPage = () => { + const navigateToNextPage = async () => { const start = parseFloat(DistanceRequestUtils.normalizeOdometerText(startReading, fromLocaleDigit)); const end = parseFloat(DistanceRequestUtils.normalizeOdometerText(endReading, fromLocaleDigit)); @@ -378,8 +377,10 @@ function IOURequestStepDistanceOdometer({ setMoneyRequestOdometerReading(transactionID, start, end, isTransactionDraft); setMoneyRequestDistance(transactionID, calculatedDistance, isTransactionDraft, unit); - const stitchedImage = await stitchImages(); - setMoneyRequestReceipt(transactionID, stitchedImage?.uri, stitchedImage?.name, isTransactionDraft); + const stitchedImage = await stitchOdometerImages(odometerStartImage, odometerEndImage); + if (stitchedImage) { + setMoneyRequestReceipt(transactionID, stitchedImage.uri, stitchedImage.name, isTransactionDraft); + } if (isEditing) { // In the split flow, when editing we use SPLIT_TRANSACTION_DRAFT to save draft value From e32a3ee96d33960be47417dfe1c93e5a74b4c0b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Tue, 3 Mar 2026 18:00:49 +0100 Subject: [PATCH 03/21] feature: when only 1 odometer image exist set it as a receipt --- .../request/step/IOURequestStepDistanceOdometer.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index a20c197e00afb..66d4aa8029aaf 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -378,8 +378,14 @@ function IOURequestStepDistanceOdometer({ setMoneyRequestOdometerReading(transactionID, start, end, isTransactionDraft); setMoneyRequestDistance(transactionID, calculatedDistance, isTransactionDraft, unit); const stitchedImage = await stitchOdometerImages(odometerStartImage, odometerEndImage); - if (stitchedImage) { - setMoneyRequestReceipt(transactionID, stitchedImage.uri, stitchedImage.name, isTransactionDraft); + if (stitchedImage ?? odometerStartImage ?? odometerEndImage) { + const uri = stitchedImage?.uri ?? startImageSource ?? endImageSource ?? ''; + const name = + stitchedImage?.name ?? + (typeof odometerStartImage !== 'string' ? odometerStartImage?.name : odometerStartImage?.split('/').pop()) ?? + (typeof odometerEndImage !== 'string' ? odometerEndImage?.name : odometerEndImage?.split('/').pop()) ?? + ''; + setMoneyRequestReceipt(transactionID, uri, name, isTransactionDraft); } if (isEditing) { From b021cc8d4a02d97348264403a5bb0fc01066e167 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Tue, 3 Mar 2026 18:52:04 +0100 Subject: [PATCH 04/21] improvement: added stitchLayout to make code DRY --- src/libs/stitchOdometerImages/index.native.ts | 14 ++------------ src/libs/stitchOdometerImages/index.ts | 14 ++------------ src/libs/stitchOdometerImages/stitchLayout.ts | 16 ++++++++++++++++ 3 files changed, 20 insertions(+), 24 deletions(-) create mode 100644 src/libs/stitchOdometerImages/stitchLayout.ts diff --git a/src/libs/stitchOdometerImages/index.native.ts b/src/libs/stitchOdometerImages/index.native.ts index c87a9c85413fa..0c6d9bdd6af88 100644 --- a/src/libs/stitchOdometerImages/index.native.ts +++ b/src/libs/stitchOdometerImages/index.native.ts @@ -1,6 +1,7 @@ import {Skia} from '@shopify/react-native-skia'; import RNFS from 'react-native-fs'; import type {FileObject} from '@src/types/utils/Attachment'; +import calculateStitchLayout from './stitchLayout'; async function stitchOdometerImages(image1: FileObject | string | undefined, image2: FileObject | string | undefined): Promise { const source1 = typeof image1 === 'string' ? image1 : (image1?.uri ?? null); @@ -19,18 +20,7 @@ async function stitchOdometerImages(image1: FileObject | string | undefined, ima return null; } - let width: number; - let height: number; - let horizontal = true; - - if (skImage1.width() > skImage1.height() || skImage2.width() > skImage2.height()) { - width = Math.max(skImage1.width(), skImage2.width()); - height = skImage1.height() + skImage2.height(); - horizontal = false; - } else { - width = skImage1.width() + skImage2.width(); - height = Math.max(skImage1.height(), skImage2.height()); - } + const {width, height, horizontal} = calculateStitchLayout(skImage1.width(), skImage1.height(), skImage2.width(), skImage2.height()); const surface = Skia.Surface.MakeOffscreen(width, height); if (!surface) { diff --git a/src/libs/stitchOdometerImages/index.ts b/src/libs/stitchOdometerImages/index.ts index 3a7845de8a6d4..b27b08bb84be9 100644 --- a/src/libs/stitchOdometerImages/index.ts +++ b/src/libs/stitchOdometerImages/index.ts @@ -1,4 +1,5 @@ import type {FileObject} from '@src/types/utils/Attachment'; +import calculateStitchLayout from './stitchLayout'; function stitchOdometerImages(image1: FileObject | string | undefined, image2: FileObject | string | undefined): Promise { const source1 = typeof image1 === 'string' ? image1 : (image1?.uri ?? null); @@ -17,18 +18,7 @@ function stitchOdometerImages(image1: FileObject | string | undefined, image2: F }); return Promise.all([loadImage(source1), loadImage(source2)]).then(([img1, img2]) => { - let width: number; - let height: number; - let horizontal = true; - - if (img1.width > img1.height || img2.width > img2.height) { - width = Math.max(img1.width, img2.width); - height = img1.height + img2.height; - horizontal = false; - } else { - width = img1.width + img2.width; - height = Math.max(img1.height, img2.height); - } + const {width, height, horizontal} = calculateStitchLayout(img1.width, img1.height, img2.width, img2.height); const offscreenCanvas = document.createElement('canvas'); offscreenCanvas.width = width; diff --git a/src/libs/stitchOdometerImages/stitchLayout.ts b/src/libs/stitchOdometerImages/stitchLayout.ts new file mode 100644 index 0000000000000..6407bc76434dc --- /dev/null +++ b/src/libs/stitchOdometerImages/stitchLayout.ts @@ -0,0 +1,16 @@ +type StitchLayout = { + width: number; + height: number; + horizontal: boolean; +}; + +function calculateStitchLayout(w1: number, h1: number, w2: number, h2: number): StitchLayout { + const horizontal = !(w1 > h1 || w2 > h2); + return { + width: horizontal ? w1 + w2 : Math.max(w1, w2), + height: horizontal ? Math.max(h1, h2) : h1 + h2, + horizontal, + }; +} + +export default calculateStitchLayout; From 81b3f51bd9510ed5f7bf55684158253a5690d877 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Wed, 4 Mar 2026 13:26:41 +0100 Subject: [PATCH 05/21] fix: added catch error handling to image stitching --- src/libs/stitchOdometerImages/index.native.ts | 61 +++++++++++------- src/libs/stitchOdometerImages/index.ts | 62 +++++++++++-------- .../step/IOURequestStepDistanceOdometer.tsx | 14 ++--- 3 files changed, 79 insertions(+), 58 deletions(-) diff --git a/src/libs/stitchOdometerImages/index.native.ts b/src/libs/stitchOdometerImages/index.native.ts index 0c6d9bdd6af88..b721f48ca6d90 100644 --- a/src/libs/stitchOdometerImages/index.native.ts +++ b/src/libs/stitchOdometerImages/index.native.ts @@ -1,5 +1,6 @@ import {Skia} from '@shopify/react-native-skia'; import RNFS from 'react-native-fs'; +import Log from '@libs/Log'; import type {FileObject} from '@src/types/utils/Attachment'; import calculateStitchLayout from './stitchLayout'; @@ -11,37 +12,51 @@ async function stitchOdometerImages(image1: FileObject | string | undefined, ima return null; } - const [buffer1, buffer2] = await Promise.all([fetch(source1).then((r) => r.arrayBuffer()), fetch(source2).then((r) => r.arrayBuffer())]); + let skImage1 = null; + let skImage2 = null; + let surface = null; - const skImage1 = Skia.Image.MakeImageFromEncoded(Skia.Data.fromBytes(new Uint8Array(buffer1))); - const skImage2 = Skia.Image.MakeImageFromEncoded(Skia.Data.fromBytes(new Uint8Array(buffer2))); + try { + const [buffer1, buffer2] = await Promise.all([fetch(source1).then((r) => r.arrayBuffer()), fetch(source2).then((r) => r.arrayBuffer())]); - if (!skImage1 || !skImage2) { - return null; - } + skImage1 = Skia.Image.MakeImageFromEncoded(Skia.Data.fromBytes(new Uint8Array(buffer1))); + skImage2 = Skia.Image.MakeImageFromEncoded(Skia.Data.fromBytes(new Uint8Array(buffer2))); - const {width, height, horizontal} = calculateStitchLayout(skImage1.width(), skImage1.height(), skImage2.width(), skImage2.height()); + if (!skImage1 || !skImage2) { + return null; + } - const surface = Skia.Surface.MakeOffscreen(width, height); - if (!surface) { - return null; - } + const {width, height, horizontal} = calculateStitchLayout(skImage1.width(), skImage1.height(), skImage2.width(), skImage2.height()); - const canvas = surface.getCanvas(); - canvas.drawImage(skImage1, 0, 0); - canvas.drawImage(skImage2, horizontal ? skImage1.width() : 0, horizontal ? 0 : skImage1.height()); - surface.flush(); + surface = Skia.Surface.MakeOffscreen(width, height); + if (!surface) { + return null; + } - const result = surface.makeImageSnapshot(); - const base64 = result.encodeToBase64(); + const canvas = surface.getCanvas(); + canvas.drawImage(skImage1, 0, 0); + canvas.drawImage(skImage2, horizontal ? skImage1.width() : 0, horizontal ? 0 : skImage1.height()); + surface.flush(); - // Write to a temp file so the receipt is treated as a local file (same as camera receipts), - // ensuring compatibility with file system validation and display utilities. - const filename = `stitched_odometer_${Date.now()}.jpg`; - const tempPath = `${RNFS.TemporaryDirectoryPath}/${filename}`; - await RNFS.writeFile(tempPath, base64, 'base64'); + const base64 = surface.makeImageSnapshot().encodeToBase64(); - return {uri: `file://${tempPath}`, name: filename, type: 'image/jpeg'}; + // Write to a temp file so the receipt is treated as a local file (same as camera receipts), + // ensuring compatibility with file system validation and display utilities. + // Fixed filename: RNFS.writeFile overwrites existing content, so at most 1 stitched + // temp file ever exists - no accumulation even across app restarts. + const filename = 'stitched_odometer.jpg'; + const tempPath = `${RNFS.TemporaryDirectoryPath}/${filename}`; + await RNFS.writeFile(tempPath, base64, 'base64'); + + return {uri: `file://${tempPath}`, name: filename, type: 'image/jpeg'}; + } catch (error) { + Log.warn('stitchOdometerImages failed', {error}); + return null; + } finally { + skImage1?.dispose?.(); + skImage2?.dispose?.(); + surface?.dispose?.(); + } } export default stitchOdometerImages; diff --git a/src/libs/stitchOdometerImages/index.ts b/src/libs/stitchOdometerImages/index.ts index b27b08bb84be9..5a6ec5eb60e5f 100644 --- a/src/libs/stitchOdometerImages/index.ts +++ b/src/libs/stitchOdometerImages/index.ts @@ -1,6 +1,9 @@ +import Log from '@libs/Log'; import type {FileObject} from '@src/types/utils/Attachment'; import calculateStitchLayout from './stitchLayout'; +const JPEG_QUALITY = 0.9; + function stitchOdometerImages(image1: FileObject | string | undefined, image2: FileObject | string | undefined): Promise { const source1 = typeof image1 === 'string' ? image1 : (image1?.uri ?? null); const source2 = typeof image2 === 'string' ? image2 : (image2?.uri ?? null); @@ -17,35 +20,40 @@ function stitchOdometerImages(image1: FileObject | string | undefined, image2: F img.src = src; }); - return Promise.all([loadImage(source1), loadImage(source2)]).then(([img1, img2]) => { - const {width, height, horizontal} = calculateStitchLayout(img1.width, img1.height, img2.width, img2.height); - - const offscreenCanvas = document.createElement('canvas'); - offscreenCanvas.width = width; - offscreenCanvas.height = height; - const ctx = offscreenCanvas.getContext('2d'); - if (!ctx) { + return Promise.all([loadImage(source1), loadImage(source2)]) + .then(([img1, img2]) => { + const {width, height, horizontal} = calculateStitchLayout(img1.width, img1.height, img2.width, img2.height); + + const offscreenCanvas = document.createElement('canvas'); + offscreenCanvas.width = width; + offscreenCanvas.height = height; + const ctx = offscreenCanvas.getContext('2d'); + if (!ctx) { + return null; + } + + ctx.drawImage(img1, 0, 0); + ctx.drawImage(img2, horizontal ? img1.width : 0, horizontal ? 0 : img1.height); + + return new Promise((resolve) => { + offscreenCanvas.toBlob( + (blob) => { + if (!blob) { + resolve(null); + return; + } + const uri = URL.createObjectURL(blob); + resolve({uri, name: 'stitched_image.jpg', type: 'image/jpeg'}); + }, + 'image/jpeg', + JPEG_QUALITY, + ); + }); + }) + .catch((error) => { + Log.warn('stitchOdometerImages (web) failed', {error}); return null; - } - - ctx.drawImage(img1, 0, 0); - ctx.drawImage(img2, horizontal ? img1.width : 0, horizontal ? 0 : img1.height); - - return new Promise((resolve) => { - offscreenCanvas.toBlob( - (blob) => { - if (!blob) { - resolve(null); - return; - } - const uri = URL.createObjectURL(blob); - resolve({uri, name: 'stitched_image.jpg', type: 'image/jpeg'}); - }, - 'image/jpeg', - 0.9, - ); }); - }); } export default stitchOdometerImages; diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index cf426eee40267..11b6dcf29f9c1 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -30,6 +30,7 @@ import {setDraftSplitTransaction} from '@libs/actions/IOU/Split'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {shouldUseTransactionDraft} from '@libs/IOUUtils'; +import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import {roundToTwoDecimalPlaces} from '@libs/NumberUtils'; import {isArchivedReport, isPolicyExpenseChat as isPolicyExpenseChatUtils} from '@libs/ReportUtils'; @@ -352,19 +353,12 @@ function IOURequestStepDistanceOdometer({ const [recentWaypoints] = useOnyx(ONYXKEYS.NVP_RECENT_WAYPOINTS); const [betas] = useOnyx(ONYXKEYS.BETAS); const icons = useMemoizedLazyExpensifyIcons(['GalleryPlus'] as const); - // Navigate to next page following Manual tab pattern const navigateToNextPage = async () => { const start = parseFloat(DistanceRequestUtils.normalizeOdometerText(startReading, fromLocaleDigit)); const end = parseFloat(DistanceRequestUtils.normalizeOdometerText(endReading, fromLocaleDigit)); - - // Store odometer readings in transaction.comment.odometerStart/odometerEnd setMoneyRequestOdometerReading(transactionID, start, end, isTransactionDraft); - - // Calculate total distance (endReading - startReading) const distance = end - start; const calculatedDistance = roundToTwoDecimalPlaces(distance); - - setMoneyRequestOdometerReading(transactionID, start, end, isTransactionDraft); setMoneyRequestDistance(transactionID, calculatedDistance, isTransactionDraft, unit); const stitchedImage = await stitchOdometerImages(odometerStartImage, odometerEndImage); if (stitchedImage ?? odometerStartImage ?? odometerEndImage) { @@ -511,7 +505,11 @@ function IOURequestStepDistanceOdometer({ // When validation passes, call navigateToNextPage setIsSubmitting(true); - navigateToNextPage().finally(() => setIsSubmitting(false)); + navigateToNextPage() + .catch((error) => { + Log.warn('navigateToNextPage failed', {error}); + }) + .finally(() => setIsSubmitting(false)); }; const hasUnsavedChanges = useMemo( From 93bec81f9f0f883220398d4aea44e0c03571fdbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Wed, 4 Mar 2026 15:01:33 +0100 Subject: [PATCH 06/21] fix: added revokeObjectURL of a stitched image --- src/libs/stitchOdometerImages/index.native.ts | 11 ++++---- src/libs/stitchOdometerImages/index.ts | 28 ++++++++++--------- 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/src/libs/stitchOdometerImages/index.native.ts b/src/libs/stitchOdometerImages/index.native.ts index b721f48ca6d90..8ddb4bd87d4c8 100644 --- a/src/libs/stitchOdometerImages/index.native.ts +++ b/src/libs/stitchOdometerImages/index.native.ts @@ -15,6 +15,7 @@ async function stitchOdometerImages(image1: FileObject | string | undefined, ima let skImage1 = null; let skImage2 = null; let surface = null; + let snapshot = null; try { const [buffer1, buffer2] = await Promise.all([fetch(source1).then((r) => r.arrayBuffer()), fetch(source2).then((r) => r.arrayBuffer())]); @@ -38,12 +39,11 @@ async function stitchOdometerImages(image1: FileObject | string | undefined, ima canvas.drawImage(skImage2, horizontal ? skImage1.width() : 0, horizontal ? 0 : skImage1.height()); surface.flush(); - const base64 = surface.makeImageSnapshot().encodeToBase64(); + snapshot = surface.makeImageSnapshot(); + const base64 = snapshot.encodeToBase64(); - // Write to a temp file so the receipt is treated as a local file (same as camera receipts), - // ensuring compatibility with file system validation and display utilities. - // Fixed filename: RNFS.writeFile overwrites existing content, so at most 1 stitched - // temp file ever exists - no accumulation even across app restarts. + // Write to a temp file so the receipt is treated as a local file + // We use a fixed filename, so that RNFS.writeFile overwrites existing content, so at most 1 stitched temp file ever exists const filename = 'stitched_odometer.jpg'; const tempPath = `${RNFS.TemporaryDirectoryPath}/${filename}`; await RNFS.writeFile(tempPath, base64, 'base64'); @@ -55,6 +55,7 @@ async function stitchOdometerImages(image1: FileObject | string | undefined, ima } finally { skImage1?.dispose?.(); skImage2?.dispose?.(); + snapshot?.dispose?.(); surface?.dispose?.(); } } diff --git a/src/libs/stitchOdometerImages/index.ts b/src/libs/stitchOdometerImages/index.ts index 5a6ec5eb60e5f..40b7b5a6c0d08 100644 --- a/src/libs/stitchOdometerImages/index.ts +++ b/src/libs/stitchOdometerImages/index.ts @@ -2,7 +2,9 @@ import Log from '@libs/Log'; import type {FileObject} from '@src/types/utils/Attachment'; import calculateStitchLayout from './stitchLayout'; -const JPEG_QUALITY = 0.9; +// Tracks the single active stitched blob URL, so that we can revoke it on the next call so at most one +// blob URL exists at a time - mirrors the native strategy of a single overwritten temp file +let previousBlobUrl: string | null = null; function stitchOdometerImages(image1: FileObject | string | undefined, image2: FileObject | string | undefined): Promise { const source1 = typeof image1 === 'string' ? image1 : (image1?.uri ?? null); @@ -36,18 +38,18 @@ function stitchOdometerImages(image1: FileObject | string | undefined, image2: F ctx.drawImage(img2, horizontal ? img1.width : 0, horizontal ? 0 : img1.height); return new Promise((resolve) => { - offscreenCanvas.toBlob( - (blob) => { - if (!blob) { - resolve(null); - return; - } - const uri = URL.createObjectURL(blob); - resolve({uri, name: 'stitched_image.jpg', type: 'image/jpeg'}); - }, - 'image/jpeg', - JPEG_QUALITY, - ); + offscreenCanvas.toBlob((blob) => { + if (!blob) { + resolve(null); + return; + } + if (previousBlobUrl) { + URL.revokeObjectURL(previousBlobUrl); + } + const uri = URL.createObjectURL(blob); + previousBlobUrl = uri; + resolve({uri, name: 'stitched_odometer.jpg', type: 'image/jpeg'}); + }, 'image/jpeg'); }); }) .catch((error) => { From dff279184aae238fd9970ead1b3cbf00b5dedfa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Wed, 4 Mar 2026 15:02:20 +0100 Subject: [PATCH 07/21] improvement: added error to display for user when navigateToNextPage fails --- src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 11b6dcf29f9c1..038ce9dba9434 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -508,6 +508,7 @@ function IOURequestStepDistanceOdometer({ navigateToNextPage() .catch((error) => { Log.warn('navigateToNextPage failed', {error}); + setFormError(translate('common.genericErrorMessage')); }) .finally(() => setIsSubmitting(false)); }; From c77e15337ce1b80985d154163c570be38abb676f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Wed, 4 Mar 2026 15:19:38 +0100 Subject: [PATCH 08/21] improvement: small adjustments to comments in imageStitching --- src/libs/stitchOdometerImages/index.native.ts | 4 ++-- src/libs/stitchOdometerImages/index.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/libs/stitchOdometerImages/index.native.ts b/src/libs/stitchOdometerImages/index.native.ts index 8ddb4bd87d4c8..7b46a5691c271 100644 --- a/src/libs/stitchOdometerImages/index.native.ts +++ b/src/libs/stitchOdometerImages/index.native.ts @@ -42,8 +42,8 @@ async function stitchOdometerImages(image1: FileObject | string | undefined, ima snapshot = surface.makeImageSnapshot(); const base64 = snapshot.encodeToBase64(); - // Write to a temp file so the receipt is treated as a local file - // We use a fixed filename, so that RNFS.writeFile overwrites existing content, so at most 1 stitched temp file ever exists + // We use a fixed filename so that RNFS.writeFile overwrites existing content + // resulting in at most 1 stitched temp file ever existing at a time const filename = 'stitched_odometer.jpg'; const tempPath = `${RNFS.TemporaryDirectoryPath}/${filename}`; await RNFS.writeFile(tempPath, base64, 'base64'); diff --git a/src/libs/stitchOdometerImages/index.ts b/src/libs/stitchOdometerImages/index.ts index 40b7b5a6c0d08..0cfbe7c9d58ca 100644 --- a/src/libs/stitchOdometerImages/index.ts +++ b/src/libs/stitchOdometerImages/index.ts @@ -2,8 +2,8 @@ import Log from '@libs/Log'; import type {FileObject} from '@src/types/utils/Attachment'; import calculateStitchLayout from './stitchLayout'; -// Tracks the single active stitched blob URL, so that we can revoke it on the next call so at most one -// blob URL exists at a time - mirrors the native strategy of a single overwritten temp file +// Tracks the single active stitched blob URL, so that we can revoke it on the next call so at most +// one blob URL exists at a time - mirrors the native strategy of a single overwritten temp file let previousBlobUrl: string | null = null; function stitchOdometerImages(image1: FileObject | string | undefined, image2: FileObject | string | undefined): Promise { From bb6b7746dbdd6ee5474f9095e0af7cdb62b47b45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Wed, 4 Mar 2026 16:39:10 +0100 Subject: [PATCH 09/21] fix: added file cleanup to native image stitching --- src/libs/stitchOdometerImages/index.native.ts | 15 +++++++++++---- src/libs/stitchOdometerImages/index.ts | 3 +-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/libs/stitchOdometerImages/index.native.ts b/src/libs/stitchOdometerImages/index.native.ts index 7b46a5691c271..296a5b9941a50 100644 --- a/src/libs/stitchOdometerImages/index.native.ts +++ b/src/libs/stitchOdometerImages/index.native.ts @@ -42,15 +42,22 @@ async function stitchOdometerImages(image1: FileObject | string | undefined, ima snapshot = surface.makeImageSnapshot(); const base64 = snapshot.encodeToBase64(); - // We use a fixed filename so that RNFS.writeFile overwrites existing content - // resulting in at most 1 stitched temp file ever existing at a time - const filename = 'stitched_odometer.jpg'; + // Delete any previously stitched files before creating a new one + try { + const tempDirContents = await RNFS.readDir(RNFS.TemporaryDirectoryPath); + const oldStitchedFiles = tempDirContents.filter((f) => f.name.startsWith('stitched_odometer_') && f.name.endsWith('.jpg')); + await Promise.all(oldStitchedFiles.map((f) => RNFS.unlink(f.path))); + } catch (error) { + Log.warn('stitchOdometerImages (native) failed to clean up old stitched files', {error}); + } + + const filename = `stitched_odometer_${Date.now()}.jpg`; const tempPath = `${RNFS.TemporaryDirectoryPath}/${filename}`; await RNFS.writeFile(tempPath, base64, 'base64'); return {uri: `file://${tempPath}`, name: filename, type: 'image/jpeg'}; } catch (error) { - Log.warn('stitchOdometerImages failed', {error}); + Log.warn('stitchOdometerImages (native) failed', {error}); return null; } finally { skImage1?.dispose?.(); diff --git a/src/libs/stitchOdometerImages/index.ts b/src/libs/stitchOdometerImages/index.ts index 0cfbe7c9d58ca..54a83aaaef799 100644 --- a/src/libs/stitchOdometerImages/index.ts +++ b/src/libs/stitchOdometerImages/index.ts @@ -2,8 +2,7 @@ import Log from '@libs/Log'; import type {FileObject} from '@src/types/utils/Attachment'; import calculateStitchLayout from './stitchLayout'; -// Tracks the single active stitched blob URL, so that we can revoke it on the next call so at most -// one blob URL exists at a time - mirrors the native strategy of a single overwritten temp file +// Tracks the single active stitched blob URL so that we can revoke it on the next call so at most one blob URL exists at a time let previousBlobUrl: string | null = null; function stitchOdometerImages(image1: FileObject | string | undefined, image2: FileObject | string | undefined): Promise { From df136c7ec12dd19c676e952ef636b31bad5339f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Tue, 10 Mar 2026 11:26:15 +0100 Subject: [PATCH 10/21] fix: added async to navigateToNextPage --- src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 952fc916e8ae9..4c1562a39e015 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -366,7 +366,7 @@ function IOURequestStepDistanceOdometer({ const [recentWaypoints] = useOnyx(ONYXKEYS.NVP_RECENT_WAYPOINTS); const [betas] = useOnyx(ONYXKEYS.BETAS); // Navigate to next page following Manual tab pattern - const navigateToNextPage = () => { + const navigateToNextPage = async () => { const start = parseFloat(DistanceRequestUtils.normalizeOdometerText(startReading, fromLocaleDigit)); const end = parseFloat(DistanceRequestUtils.normalizeOdometerText(endReading, fromLocaleDigit)); setMoneyRequestOdometerReading(transactionID, start, end, isTransactionDraft); From c831f96407bc1c10ed8b78589ae17de461fc95e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Tue, 10 Mar 2026 12:32:48 +0100 Subject: [PATCH 11/21] fix: remove fallback to receipt source to avoid receipt flash on odometer image removal --- .../routes/TransactionReceiptModalContent.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx index 415e3e611bd95..6b5b7aeb5e53f 100644 --- a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx +++ b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx @@ -122,7 +122,7 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre // Use odometer image if imageType is provided (it's present only when we display odometer image) otherwise use receipt const receiptSource = isDraftTransaction ? transactionDraft?.receipt?.source : tryResolveUrlFromApiRoot(receiptURIs.image ?? ''); - const source = isOdometerImage && odometerImage ? (odometerImageSource ?? receiptSource) : receiptSource; + const source = isOdometerImage ? odometerImageSource : receiptSource; const [sourceUri, setSourceUri] = useState(''); const parentReportAction = getReportAction(report?.parentReportID, report?.parentReportActionID); From f74f11316e6a262af78fe702b82e93c366def897 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Tue, 10 Mar 2026 13:42:31 +0100 Subject: [PATCH 12/21] fix: preserve filename for camera-captured odometer image on mWeb --- src/libs/actions/IOU/index.ts | 4 ++-- .../iou/request/step/IOURequestStepOdometerImage/index.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index c449ca8168fb5..9cef9bf84f01c 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -1815,13 +1815,13 @@ function revokeOdometerImageUri(image: FileObject | string | null | undefined, n * @param file - The image file (File object on web, URI string on native) * @param isDraft - Whether this is a draft transaction */ -function setMoneyRequestOdometerImage(transactionID: string, imageType: OdometerImageType, file: File | string, isDraft: boolean) { +function setMoneyRequestOdometerImage(transactionID: string, imageType: OdometerImageType, file: FileObject | string, isDraft: boolean) { const imageKey = imageType === CONST.IOU.ODOMETER_IMAGE_TYPE.START ? 'odometerStartImage' : 'odometerEndImage'; const normalizedFile: FileObject | string = typeof file === 'string' ? file : { - uri: file.uri ?? (typeof URL !== 'undefined' ? URL.createObjectURL(file) : undefined), + uri: file.uri ?? (typeof URL !== 'undefined' ? URL.createObjectURL(file as Blob) : undefined), name: file.name, type: file.type, size: file.size, diff --git a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx index 4662a86faaf34..5053f3ae78efa 100644 --- a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx @@ -228,11 +228,11 @@ function IOURequestStepOdometerImage({ const viewFinderHeight = viewfinderLayout.current?.height ?? NaN; const shouldAlignTop = videoHeight > viewFinderHeight; cropImageToAspectRatio(imageObject, viewfinderLayout.current?.width, viewfinderLayout.current?.height, shouldAlignTop) - .then(({source}) => { + .then(({file, source}) => { if (source !== imageObject.source) { URL.revokeObjectURL(imageObject.source); } - setMoneyRequestOdometerImage(transactionID, imageType, source, isTransactionDraft); + setMoneyRequestOdometerImage(transactionID, imageType, file ?? source, isTransactionDraft); navigateBack(); }) .catch((error: unknown) => { From ea7bd646fa65358f0bb758fec31e49cfad39df53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Wed, 11 Mar 2026 15:46:55 +0100 Subject: [PATCH 13/21] improvement: move error handling from stitchOdometerImages to caller --- src/languages/en.ts | 1 + src/languages/es.ts | 1 + src/libs/stitchOdometerImages/index.native.ts | 7 +-- src/libs/stitchOdometerImages/index.ts | 62 +++++++++---------- .../step/IOURequestStepDistanceOdometer.tsx | 11 +++- 5 files changed, 42 insertions(+), 40 deletions(-) diff --git a/src/languages/en.ts b/src/languages/en.ts index 86bdb5fc85c1c..c3744422df3bc 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1422,6 +1422,7 @@ const translations = { distanceAmountTooLargeReduceDistance: 'The total amount is too large. Reduce the distance.', distanceAmountTooLargeReduceRate: 'The total amount is too large. Lower the rate.', odometerReadingTooLarge: (formattedMax: string) => `Odometer readings cannot exceed ${formattedMax}.`, + stitchOdometerImagesFailed: 'Failed to combine odometer images. Please try again later.', invalidIntegerAmount: 'Please enter a whole dollar amount before continuing', invalidTaxAmount: (amount: string) => `Maximum tax amount is ${amount}`, invalidSplit: 'The sum of splits must equal the total amount', diff --git a/src/languages/es.ts b/src/languages/es.ts index 970f0232025e5..87a8c3e63ae78 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1262,6 +1262,7 @@ const translations: TranslationDeepObject = { distanceAmountTooLargeReduceDistance: 'El importe total es demasiado alto. Reduce la distancia.', distanceAmountTooLargeReduceRate: 'El importe total es demasiado alto. Disminuye la tarifa.', odometerReadingTooLarge: (formattedMax: string) => `Las lecturas del odómetro no pueden superar ${formattedMax}.`, + stitchOdometerImagesFailed: 'No se pudieron combinar las imágenes del odómetro. Por favor, inténtalo de nuevo más tarde.', invalidIntegerAmount: 'Por favor, introduce una cantidad entera en dólares antes de continuar', invalidTaxAmount: (amount) => `El importe máximo del impuesto es ${amount}`, invalidSplit: 'La suma de las partes debe ser igual al importe total', diff --git a/src/libs/stitchOdometerImages/index.native.ts b/src/libs/stitchOdometerImages/index.native.ts index 296a5b9941a50..ca5cfd73727e3 100644 --- a/src/libs/stitchOdometerImages/index.native.ts +++ b/src/libs/stitchOdometerImages/index.native.ts @@ -24,14 +24,14 @@ async function stitchOdometerImages(image1: FileObject | string | undefined, ima skImage2 = Skia.Image.MakeImageFromEncoded(Skia.Data.fromBytes(new Uint8Array(buffer2))); if (!skImage1 || !skImage2) { - return null; + throw new Error('Failed to decode odometer images'); } const {width, height, horizontal} = calculateStitchLayout(skImage1.width(), skImage1.height(), skImage2.width(), skImage2.height()); surface = Skia.Surface.MakeOffscreen(width, height); if (!surface) { - return null; + throw new Error('Failed to create Skia surface'); } const canvas = surface.getCanvas(); @@ -56,9 +56,6 @@ async function stitchOdometerImages(image1: FileObject | string | undefined, ima await RNFS.writeFile(tempPath, base64, 'base64'); return {uri: `file://${tempPath}`, name: filename, type: 'image/jpeg'}; - } catch (error) { - Log.warn('stitchOdometerImages (native) failed', {error}); - return null; } finally { skImage1?.dispose?.(); skImage2?.dispose?.(); diff --git a/src/libs/stitchOdometerImages/index.ts b/src/libs/stitchOdometerImages/index.ts index 54a83aaaef799..a3522604d4497 100644 --- a/src/libs/stitchOdometerImages/index.ts +++ b/src/libs/stitchOdometerImages/index.ts @@ -1,4 +1,3 @@ -import Log from '@libs/Log'; import type {FileObject} from '@src/types/utils/Attachment'; import calculateStitchLayout from './stitchLayout'; @@ -21,40 +20,35 @@ function stitchOdometerImages(image1: FileObject | string | undefined, image2: F img.src = src; }); - return Promise.all([loadImage(source1), loadImage(source2)]) - .then(([img1, img2]) => { - const {width, height, horizontal} = calculateStitchLayout(img1.width, img1.height, img2.width, img2.height); - - const offscreenCanvas = document.createElement('canvas'); - offscreenCanvas.width = width; - offscreenCanvas.height = height; - const ctx = offscreenCanvas.getContext('2d'); - if (!ctx) { - return null; - } - - ctx.drawImage(img1, 0, 0); - ctx.drawImage(img2, horizontal ? img1.width : 0, horizontal ? 0 : img1.height); - - return new Promise((resolve) => { - offscreenCanvas.toBlob((blob) => { - if (!blob) { - resolve(null); - return; - } - if (previousBlobUrl) { - URL.revokeObjectURL(previousBlobUrl); - } - const uri = URL.createObjectURL(blob); - previousBlobUrl = uri; - resolve({uri, name: 'stitched_odometer.jpg', type: 'image/jpeg'}); - }, 'image/jpeg'); - }); - }) - .catch((error) => { - Log.warn('stitchOdometerImages (web) failed', {error}); - return null; + return Promise.all([loadImage(source1), loadImage(source2)]).then(([img1, img2]) => { + const {width, height, horizontal} = calculateStitchLayout(img1.width, img1.height, img2.width, img2.height); + + const offscreenCanvas = document.createElement('canvas'); + offscreenCanvas.width = width; + offscreenCanvas.height = height; + const ctx = offscreenCanvas.getContext('2d'); + if (!ctx) { + throw new Error('Failed to get canvas context'); + } + + ctx.drawImage(img1, 0, 0); + ctx.drawImage(img2, horizontal ? img1.width : 0, horizontal ? 0 : img1.height); + + return new Promise((resolve, reject) => { + offscreenCanvas.toBlob((blob) => { + if (!blob) { + reject(new Error('Canvas toBlob returned null')); + return; + } + if (previousBlobUrl) { + URL.revokeObjectURL(previousBlobUrl); + } + const uri = URL.createObjectURL(blob); + previousBlobUrl = uri; + resolve({uri, name: 'stitched_odometer.jpg', type: 'image/jpeg'}); + }, 'image/jpeg'); }); + }); } export default stitchOdometerImages; diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 4c1562a39e015..1d73a0f9c51ed 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -373,7 +373,16 @@ function IOURequestStepDistanceOdometer({ const distance = end - start; const calculatedDistance = roundToTwoDecimalPlaces(distance); setMoneyRequestDistance(transactionID, calculatedDistance, isTransactionDraft, unit); - const stitchedImage = await stitchOdometerImages(odometerStartImage, odometerEndImage); + + let stitchedImage: FileObject | null = null; + try { + stitchedImage = await stitchOdometerImages(odometerStartImage, odometerEndImage); + } catch (error) { + Log.warn('stitchOdometerImages failed', {error}); + setFormError(translate('iou.error.stitchOdometerImagesFailed')); + return; + } + if (stitchedImage ?? odometerStartImage ?? odometerEndImage) { const uri = stitchedImage?.uri ?? startImageSource ?? endImageSource ?? ''; const name = From ce37aedf751133d671cfcba6f15d219dac1bdad0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Wed, 11 Mar 2026 20:24:58 +0100 Subject: [PATCH 14/21] fix: hide replace button for stitched odometer receipt --- .../routes/TransactionReceiptModalContent.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx index 6b5b7aeb5e53f..ff1923ddf57a6 100644 --- a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx +++ b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx @@ -129,16 +129,17 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre const canEditReceipt = canEditFieldOfMoneyRequest(parentReportAction, CONST.EDIT_REQUEST_FIELD.RECEIPT); const canDeleteReceipt = canEditFieldOfMoneyRequest(parentReportAction, CONST.EDIT_REQUEST_FIELD.RECEIPT, true); - const shouldShowReplaceReceiptButton = ((canEditReceipt && !readonly) || isDraftTransaction) && !transaction?.receipt?.isTestDriveReceipt; + const receiptFilename = transaction?.receipt?.filename; + const isImage = !!receiptFilename && Str.isImage(receiptFilename); + const isStitchedOdometerReceipt = !!receiptFilename?.startsWith('stitched_odometer'); + + const shouldShowReplaceReceiptButton = ((canEditReceipt && !readonly) || isDraftTransaction) && !transaction?.receipt?.isTestDriveReceipt && !isStitchedOdometerReceipt; const shouldShowDeleteReceiptButton = canDeleteReceipt && !readonly && !isDraftTransaction && !transaction?.receipt?.isTestDriveReceipt; const isEReceipt = transaction && !hasReceiptSource(transaction) && hasEReceipt(transaction); const isTrackExpenseActionValue = isTrackExpenseAction(parentReportAction); const iouType = useMemo(() => iouTypeParam ?? (isTrackExpenseActionValue ? CONST.IOU.TYPE.TRACK : CONST.IOU.TYPE.SUBMIT), [isTrackExpenseActionValue, iouTypeParam]); - const receiptFilename = transaction?.receipt?.filename; - const isImage = !!receiptFilename && Str.isImage(receiptFilename); - const [isDeleteReceiptConfirmModalVisible, setIsDeleteReceiptConfirmModalVisible] = useState(false); const [isRotating, setIsRotating] = useState(false); const [isCropping, setIsCropping] = useState(false); From d5f5d5d59cdb9cf388bdb72988a0a286050bc759 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Wed, 11 Mar 2026 20:36:39 +0100 Subject: [PATCH 15/21] improvement: scale images to match shared edge length when stitching --- src/libs/stitchOdometerImages/index.native.ts | 7 ++-- src/libs/stitchOdometerImages/index.ts | 6 +-- src/libs/stitchOdometerImages/stitchLayout.ts | 40 +++++++++++++++++-- 3 files changed, 44 insertions(+), 9 deletions(-) diff --git a/src/libs/stitchOdometerImages/index.native.ts b/src/libs/stitchOdometerImages/index.native.ts index ca5cfd73727e3..ed02089e95ce1 100644 --- a/src/libs/stitchOdometerImages/index.native.ts +++ b/src/libs/stitchOdometerImages/index.native.ts @@ -27,7 +27,7 @@ async function stitchOdometerImages(image1: FileObject | string | undefined, ima throw new Error('Failed to decode odometer images'); } - const {width, height, horizontal} = calculateStitchLayout(skImage1.width(), skImage1.height(), skImage2.width(), skImage2.height()); + const {width, height, img1Dest, img2Dest} = calculateStitchLayout(skImage1.width(), skImage1.height(), skImage2.width(), skImage2.height()); surface = Skia.Surface.MakeOffscreen(width, height); if (!surface) { @@ -35,8 +35,9 @@ async function stitchOdometerImages(image1: FileObject | string | undefined, ima } const canvas = surface.getCanvas(); - canvas.drawImage(skImage1, 0, 0); - canvas.drawImage(skImage2, horizontal ? skImage1.width() : 0, horizontal ? 0 : skImage1.height()); + const paint = Skia.Paint(); + canvas.drawImageRect(skImage1, Skia.XYWHRect(0, 0, skImage1.width(), skImage1.height()), Skia.XYWHRect(img1Dest.x, img1Dest.y, img1Dest.w, img1Dest.h), paint); + canvas.drawImageRect(skImage2, Skia.XYWHRect(0, 0, skImage2.width(), skImage2.height()), Skia.XYWHRect(img2Dest.x, img2Dest.y, img2Dest.w, img2Dest.h), paint); surface.flush(); snapshot = surface.makeImageSnapshot(); diff --git a/src/libs/stitchOdometerImages/index.ts b/src/libs/stitchOdometerImages/index.ts index a3522604d4497..f388673f5bffc 100644 --- a/src/libs/stitchOdometerImages/index.ts +++ b/src/libs/stitchOdometerImages/index.ts @@ -21,7 +21,7 @@ function stitchOdometerImages(image1: FileObject | string | undefined, image2: F }); return Promise.all([loadImage(source1), loadImage(source2)]).then(([img1, img2]) => { - const {width, height, horizontal} = calculateStitchLayout(img1.width, img1.height, img2.width, img2.height); + const {width, height, img1Dest, img2Dest} = calculateStitchLayout(img1.width, img1.height, img2.width, img2.height); const offscreenCanvas = document.createElement('canvas'); offscreenCanvas.width = width; @@ -31,8 +31,8 @@ function stitchOdometerImages(image1: FileObject | string | undefined, image2: F throw new Error('Failed to get canvas context'); } - ctx.drawImage(img1, 0, 0); - ctx.drawImage(img2, horizontal ? img1.width : 0, horizontal ? 0 : img1.height); + ctx.drawImage(img1, img1Dest.x, img1Dest.y, img1Dest.w, img1Dest.h); + ctx.drawImage(img2, img2Dest.x, img2Dest.y, img2Dest.w, img2Dest.h); return new Promise((resolve, reject) => { offscreenCanvas.toBlob((blob) => { diff --git a/src/libs/stitchOdometerImages/stitchLayout.ts b/src/libs/stitchOdometerImages/stitchLayout.ts index 6407bc76434dc..01ada70095a31 100644 --- a/src/libs/stitchOdometerImages/stitchLayout.ts +++ b/src/libs/stitchOdometerImages/stitchLayout.ts @@ -1,15 +1,49 @@ +type ImageRect = {x: number; y: number; w: number; h: number}; + type StitchLayout = { width: number; height: number; horizontal: boolean; + img1Dest: ImageRect; + img2Dest: ImageRect; }; +/** + * Calculates the layout for stitching two odometer images into a single combined image. + * + * Stitching rules: + * - Images are merged horizontally (side-by-side) by default + * - If either image is landscape, images are merged vertically (stacked) + * - The smaller image is resized so the shared edge matches the length of the larger image's shared edge + */ + function calculateStitchLayout(w1: number, h1: number, w2: number, h2: number): StitchLayout { const horizontal = !(w1 > h1 || w2 > h2); + + if (horizontal) { + // Side-by-side: scale both images to the same height so the shared vertical edges match + const targetH = Math.max(h1, h2); + const scaledW1 = Math.round((w1 * targetH) / h1); + const scaledW2 = Math.round((w2 * targetH) / h2); + return { + width: scaledW1 + scaledW2, + height: targetH, + horizontal: true, + img1Dest: {x: 0, y: 0, w: scaledW1, h: targetH}, + img2Dest: {x: scaledW1, y: 0, w: scaledW2, h: targetH}, + }; + } + + // Stacked: scale both images to the same width so the shared horizontal edges match + const targetW = Math.max(w1, w2); + const scaledH1 = Math.round((h1 * targetW) / w1); + const scaledH2 = Math.round((h2 * targetW) / w2); return { - width: horizontal ? w1 + w2 : Math.max(w1, w2), - height: horizontal ? Math.max(h1, h2) : h1 + h2, - horizontal, + width: targetW, + height: scaledH1 + scaledH2, + horizontal: false, + img1Dest: {x: 0, y: 0, w: targetW, h: scaledH1}, + img2Dest: {x: 0, y: scaledH1, w: targetW, h: scaledH2}, }; } From d36ad3a446fa51d219a665347b2fa6f62269d394 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Wed, 11 Mar 2026 21:01:48 +0100 Subject: [PATCH 16/21] fix: use contain resize mode for stitched odometer receipt preview --- src/components/MoneyRequestConfirmationListFooter.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx index f74d6111cccc3..4296ab5808e36 100644 --- a/src/components/MoneyRequestConfirmationListFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter.tsx @@ -445,6 +445,7 @@ function MoneyRequestConfirmationListFooter({ } = receiptPath && receiptFilename ? getThumbnailAndImageURIs(transaction, receiptPath, receiptFilename) : ({} as ThumbnailAndImageURI); const resolvedThumbnail = isLocalFile ? receiptThumbnail : tryResolveUrlFromApiRoot(receiptThumbnail ?? ''); const resolvedReceiptImage = isLocalFile ? receiptImage : tryResolveUrlFromApiRoot(receiptImage ?? ''); + const isStitchedOdometerReceipt = !!receiptFilename?.startsWith('stitched_odometer'); const shouldNavigateToUpgradePath = !policyForMovingExpensesID && !shouldSelectPolicy; // Time requests appear as regular expenses after they're created, with editable amount and merchant, not hours and rate @@ -1151,6 +1152,7 @@ function MoneyRequestConfirmationListFooter({ shouldUseInitialObjectPosition={isDistanceRequest} shouldUseFullHeight={isCompactMode} onLoad={handleReceiptLoad} + resizeMode={isStitchedOdometerReceipt ? 'contain' : undefined} /> )} @@ -1181,6 +1183,7 @@ function MoneyRequestConfirmationListFooter({ receiptThumbnail, fileExtension, isDistanceRequest, + isStitchedOdometerReceipt, handleReceiptLoad, handleCompactReceiptContainerLayout, ]); From e3810a040bc1f504285bbc33afaa5521edf3a598 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Thu, 12 Mar 2026 10:44:54 +0100 Subject: [PATCH 17/21] fix: added XYWH to cspell.json as an acceptable word --- cspell.json | 1 + 1 file changed, 1 insertion(+) diff --git a/cspell.json b/cspell.json index 4e4f03a01f23c..1e1382d4174f9 100644 --- a/cspell.json +++ b/cspell.json @@ -843,6 +843,7 @@ "xmlgateway", "Xours", "Xtheirs", + "XYWH", "yalc", "Yapl", "YAPL", From f59d7925d92b245d83e34b14f2acfafd66c9e800 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Thu, 12 Mar 2026 16:40:24 +0100 Subject: [PATCH 18/21] refactor: move odometer image stitching to confirmation page --- .../MoneyRequestConfirmationList.tsx | 5 +++ .../MoneyRequestConfirmationListFooter.tsx | 19 ++++++++ .../step/IOURequestStepConfirmation.tsx | 45 +++++++++++++++++++ .../step/IOURequestStepDistanceOdometer.tsx | 39 ++-------------- 4 files changed, 72 insertions(+), 36 deletions(-) diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 7287cd3038d0f..d1342b295fb32 100644 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -165,6 +165,9 @@ type MoneyRequestConfirmationListProps = { /** Whether the expense is an odometer distance expense */ isOdometerDistanceRequest?: boolean; + /** Whether the odometer receipt is currently being stitched */ + isLoadingReceipt?: boolean; + /** Whether the expense is a GPS distance expense */ isGPSDistanceRequest: boolean; @@ -236,6 +239,7 @@ function MoneyRequestConfirmationList({ isDistanceRequest, isManualDistanceRequest, isOdometerDistanceRequest = false, + isLoadingReceipt = false, isGPSDistanceRequest, isPerDiemRequest = false, isPolicyExpenseChat = false, @@ -1309,6 +1313,7 @@ function MoneyRequestConfirmationList({ isDistanceRequest={isDistanceRequest} isManualDistanceRequest={isManualDistanceRequest} isOdometerDistanceRequest={isOdometerDistanceRequest} + isLoadingReceipt={isLoadingReceipt} isGPSDistanceRequest={isGPSDistanceRequest} isPerDiemRequest={isPerDiemRequest} isTimeRequest={isTimeRequest} diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx index 4296ab5808e36..899bbf9d913b7 100644 --- a/src/components/MoneyRequestConfirmationListFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter.tsx @@ -55,6 +55,7 @@ import type * as OnyxTypes from '@src/types/onyx'; import type {Attendee, Participant} from '@src/types/onyx/IOU'; import type {Unit} from '@src/types/onyx/Policy'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import ActivityIndicator from './ActivityIndicator'; import Badge from './Badge'; import Button from './Button'; import ConfirmedRoute from './ConfirmedRoute'; @@ -139,6 +140,9 @@ type MoneyRequestConfirmationListFooterProps = { /** Flag indicating if it is an odometer distance request */ isOdometerDistanceRequest?: boolean; + /** Whether the receipt is currently being stitched */ + isLoadingReceipt?: boolean; + /** Flag indicating if it is a GPS distance request */ isGPSDistanceRequest: boolean; @@ -281,6 +285,7 @@ function MoneyRequestConfirmationListFooter({ isDistanceRequest, isManualDistanceRequest, isOdometerDistanceRequest = false, + isLoadingReceipt = false, isGPSDistanceRequest, isPerDiemRequest, isTimeRequest, @@ -1268,7 +1273,21 @@ function MoneyRequestConfirmationListFooter({ )} + {(!shouldShowMap || isManualDistanceRequest || isOdometerDistanceRequest) && !hasReceiptImageOrThumbnail && isLoadingReceipt && ( + + + + )} {(!shouldShowMap || isManualDistanceRequest || isOdometerDistanceRequest) && + !isLoadingReceipt && (hasReceiptImageOrThumbnail ? receiptThumbnailContent : showReceiptEmptyState && ( diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index f02a48fff78b5..4b26c7cffd8a0 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -5,6 +5,7 @@ import {View} from 'react-native'; import DragAndDropConsumer from '@components/DragAndDrop/Consumer'; import DragAndDropProvider from '@components/DragAndDrop/Provider'; import DropZoneUI from '@components/DropZone/DropZoneUI'; +import FormHelpMessage from '@components/FormHelpMessage'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import LocationPermissionModal from '@components/LocationPermissionModal'; @@ -64,6 +65,7 @@ import { isReportOutstanding, isSelectedManagerMcTest, } from '@libs/ReportUtils'; +import stitchOdometerImages from '@libs/stitchOdometerImages'; import {cancelSpan, endSpan, getSpan, startSpan} from '@libs/telemetry/activeSpans'; import getSubmitExpenseScenario from '@libs/telemetry/getSubmitExpenseScenario'; import markSubmitExpenseEnd from '@libs/telemetry/markSubmitExpenseEnd'; @@ -285,6 +287,8 @@ function IOURequestStepConfirmation({ const gpsRequired = transaction?.amount === 0 && iouType !== CONST.IOU.TYPE.SPLIT && Object.values(receiptFiles).length && !isTestTransaction; const [isConfirmed, setIsConfirmed] = useState(false); const [isConfirming, setIsConfirming] = useState(false); + const [isStitchingReceipt, setIsStitchingReceipt] = useState(false); + const [stitchError, setStitchError] = useState(''); const headerTitle = useMemo(() => { if (isCategorizingTrackExpense) { @@ -382,6 +386,45 @@ function IOURequestStepConfirmation({ } }, [isOffline, policy?.pendingAction, policyExpenseChatPolicyID, senderPolicyID]); + const odometerStartImage = transaction?.comment?.odometerStartImage; + const odometerEndImage = transaction?.comment?.odometerEndImage; + + useEffect(() => { + if (!isOdometerDistanceRequest) { + return; + } + + if (!odometerStartImage && !odometerEndImage) { + return; + } + + setIsStitchingReceipt(true); + setStitchError(''); + + stitchOdometerImages(odometerStartImage, odometerEndImage) + .then((stitchedImage) => { + if (!(stitchedImage ?? odometerStartImage ?? odometerEndImage)) { + return; + } + const uri = + stitchedImage?.uri ?? + (typeof odometerStartImage === 'string' ? odometerStartImage : odometerStartImage?.uri) ?? + (typeof odometerEndImage === 'string' ? odometerEndImage : odometerEndImage?.uri) ?? + ''; + const name = + stitchedImage?.name ?? + (typeof odometerStartImage !== 'string' ? odometerStartImage?.name : odometerStartImage?.split('/').pop()) ?? + (typeof odometerEndImage !== 'string' ? odometerEndImage?.name : odometerEndImage?.split('/').pop()) ?? + ''; + setMoneyRequestReceipt(currentTransactionID, uri, name, shouldUseTransactionDraft(action)); + }) + .catch((error: unknown) => { + Log.warn('stitchOdometerImages failed on confirmation page', {error}); + setStitchError(translate('iou.error.stitchOdometerImagesFailed')); + }) + .finally(() => setIsStitchingReceipt(false)); + }, [isOdometerDistanceRequest, currentTransactionID, odometerStartImage, odometerEndImage, action, translate]); + const defaultBillable = !!policy?.defaultBillable; useEffect(() => { if (isMovingTransactionFromTrackExpense) { @@ -1594,6 +1637,7 @@ function IOURequestStepConfirmation({ }} /> )} + {!!stitchError && } (''); const [endReading, setEndReading] = useState(''); const [formError, setFormError] = useState(''); - const [isSubmitting, setIsSubmitting] = useState(false); // Key to force TextInput remount when resetting state after tab switch const [inputKey, setInputKey] = useState(0); @@ -370,7 +367,7 @@ function IOURequestStepDistanceOdometer({ const [recentWaypoints] = useOnyx(ONYXKEYS.NVP_RECENT_WAYPOINTS); const [betas] = useOnyx(ONYXKEYS.BETAS); // Navigate to next page following Manual tab pattern - const navigateToNextPage = async () => { + const navigateToNextPage = () => { const start = parseFloat(DistanceRequestUtils.normalizeOdometerText(startReading, fromLocaleDigit)); const end = parseFloat(DistanceRequestUtils.normalizeOdometerText(endReading, fromLocaleDigit)); setMoneyRequestOdometerReading(transactionID, start, end, isTransactionDraft); @@ -378,25 +375,6 @@ function IOURequestStepDistanceOdometer({ const calculatedDistance = roundToTwoDecimalPlaces(distance); setMoneyRequestDistance(transactionID, calculatedDistance, isTransactionDraft, unit); - let stitchedImage: FileObject | null = null; - try { - stitchedImage = await stitchOdometerImages(odometerStartImage, odometerEndImage); - } catch (error) { - Log.warn('stitchOdometerImages failed', {error}); - setFormError(translate('iou.error.stitchOdometerImagesFailed')); - return; - } - - if (stitchedImage ?? odometerStartImage ?? odometerEndImage) { - const uri = stitchedImage?.uri ?? startImageSource ?? endImageSource ?? ''; - const name = - stitchedImage?.name ?? - (typeof odometerStartImage !== 'string' ? odometerStartImage?.name : odometerStartImage?.split('/').pop()) ?? - (typeof odometerEndImage !== 'string' ? odometerEndImage?.name : odometerEndImage?.split('/').pop()) ?? - ''; - setMoneyRequestReceipt(transactionID, uri, name, isTransactionDraft); - } - if (isEditing) { // In the split flow, when editing we use SPLIT_TRANSACTION_DRAFT to save draft value if (isEditingSplit && transaction) { @@ -497,10 +475,6 @@ function IOURequestStepDistanceOdometer({ // Handle form submission with validation const handleNext = () => { - if (isSubmitting) { - return; - } - // Validation: Start and end readings must not be empty if (!startReading || !endReading) { setFormError(translate('iou.error.invalidReadings')); @@ -533,13 +507,7 @@ function IOURequestStepDistanceOdometer({ } // When validation passes, call navigateToNextPage - setIsSubmitting(true); - navigateToNextPage() - .catch((error) => { - Log.warn('navigateToNextPage failed', {error}); - setFormError(translate('common.genericErrorMessage')); - }) - .finally(() => setIsSubmitting(false)); + navigateToNextPage(); }; return ( @@ -659,7 +627,6 @@ function IOURequestStepDistanceOdometer({ success allowBubble={!isEditing} pressOnEnter - isLoading={isSubmitting} medium={isExtraSmallScreenHeight} large={!isExtraSmallScreenHeight} style={[styles.w100]} From d39c77be7f3d12588c551158d899a0d6220dc183 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Fri, 13 Mar 2026 11:53:10 +0100 Subject: [PATCH 19/21] improvement: extract stitched odometer filename prefix to constants --- src/libs/stitchOdometerImages/constants.ts | 3 +++ src/libs/stitchOdometerImages/index.native.ts | 5 +++-- src/libs/stitchOdometerImages/index.ts | 3 ++- 3 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 src/libs/stitchOdometerImages/constants.ts diff --git a/src/libs/stitchOdometerImages/constants.ts b/src/libs/stitchOdometerImages/constants.ts new file mode 100644 index 0000000000000..cdee9e92d0375 --- /dev/null +++ b/src/libs/stitchOdometerImages/constants.ts @@ -0,0 +1,3 @@ +const STITCHED_ODOMETER_FILENAME_PREFIX = 'stitched_odometer'; + +export default STITCHED_ODOMETER_FILENAME_PREFIX; diff --git a/src/libs/stitchOdometerImages/index.native.ts b/src/libs/stitchOdometerImages/index.native.ts index ed02089e95ce1..4bef73052c8f8 100644 --- a/src/libs/stitchOdometerImages/index.native.ts +++ b/src/libs/stitchOdometerImages/index.native.ts @@ -2,6 +2,7 @@ import {Skia} from '@shopify/react-native-skia'; import RNFS from 'react-native-fs'; import Log from '@libs/Log'; import type {FileObject} from '@src/types/utils/Attachment'; +import STITCHED_ODOMETER_FILENAME_PREFIX from './constants'; import calculateStitchLayout from './stitchLayout'; async function stitchOdometerImages(image1: FileObject | string | undefined, image2: FileObject | string | undefined): Promise { @@ -46,13 +47,13 @@ async function stitchOdometerImages(image1: FileObject | string | undefined, ima // Delete any previously stitched files before creating a new one try { const tempDirContents = await RNFS.readDir(RNFS.TemporaryDirectoryPath); - const oldStitchedFiles = tempDirContents.filter((f) => f.name.startsWith('stitched_odometer_') && f.name.endsWith('.jpg')); + const oldStitchedFiles = tempDirContents.filter((f) => f.name.startsWith(`${STITCHED_ODOMETER_FILENAME_PREFIX}_`) && f.name.endsWith('.jpg')); await Promise.all(oldStitchedFiles.map((f) => RNFS.unlink(f.path))); } catch (error) { Log.warn('stitchOdometerImages (native) failed to clean up old stitched files', {error}); } - const filename = `stitched_odometer_${Date.now()}.jpg`; + const filename = `${STITCHED_ODOMETER_FILENAME_PREFIX}_${Date.now()}.jpg`; const tempPath = `${RNFS.TemporaryDirectoryPath}/${filename}`; await RNFS.writeFile(tempPath, base64, 'base64'); diff --git a/src/libs/stitchOdometerImages/index.ts b/src/libs/stitchOdometerImages/index.ts index f388673f5bffc..51bfac1c8a1d5 100644 --- a/src/libs/stitchOdometerImages/index.ts +++ b/src/libs/stitchOdometerImages/index.ts @@ -1,4 +1,5 @@ import type {FileObject} from '@src/types/utils/Attachment'; +import STITCHED_ODOMETER_FILENAME_PREFIX from './constants'; import calculateStitchLayout from './stitchLayout'; // Tracks the single active stitched blob URL so that we can revoke it on the next call so at most one blob URL exists at a time @@ -45,7 +46,7 @@ function stitchOdometerImages(image1: FileObject | string | undefined, image2: F } const uri = URL.createObjectURL(blob); previousBlobUrl = uri; - resolve({uri, name: 'stitched_odometer.jpg', type: 'image/jpeg'}); + resolve({uri, name: `${STITCHED_ODOMETER_FILENAME_PREFIX}.jpg`, type: 'image/jpeg'}); }, 'image/jpeg'); }); }); From 2307c1fb581774953a0bf67c7d38f47a3b075738 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Fri, 13 Mar 2026 12:00:36 +0100 Subject: [PATCH 20/21] fix: guard stale async state updates in odometer image stitching effect --- .../step/IOURequestStepConfirmation.tsx | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index 4b26c7cffd8a0..6bf5dce51179a 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -398,11 +398,15 @@ function IOURequestStepConfirmation({ return; } + let ignore = false; setIsStitchingReceipt(true); setStitchError(''); stitchOdometerImages(odometerStartImage, odometerEndImage) .then((stitchedImage) => { + if (ignore) { + return; + } if (!(stitchedImage ?? odometerStartImage ?? odometerEndImage)) { return; } @@ -419,10 +423,22 @@ function IOURequestStepConfirmation({ setMoneyRequestReceipt(currentTransactionID, uri, name, shouldUseTransactionDraft(action)); }) .catch((error: unknown) => { - Log.warn('stitchOdometerImages failed on confirmation page', {error}); + if (ignore) { + return; + } + Log.warn('stitchOdometerImages failed', {error}); setStitchError(translate('iou.error.stitchOdometerImagesFailed')); }) - .finally(() => setIsStitchingReceipt(false)); + .finally(() => { + if (ignore) { + return; + } + setIsStitchingReceipt(false); + }); + + return () => { + ignore = true; + }; }, [isOdometerDistanceRequest, currentTransactionID, odometerStartImage, odometerEndImage, action, translate]); const defaultBillable = !!policy?.defaultBillable; From 16b00646c51131a555c161b2b5314f7feec0ee75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Fri, 13 Mar 2026 12:03:44 +0100 Subject: [PATCH 21/21] feat: add util to detect stitched odometer receipt filenames --- src/components/MoneyRequestConfirmationListFooter.tsx | 4 ++-- src/libs/ReceiptUtils.ts | 7 ++++++- .../routes/TransactionReceiptModalContent.tsx | 4 ++-- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx index 899bbf9d913b7..250f5099ad690 100644 --- a/src/components/MoneyRequestConfirmationListFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter.tsx @@ -27,7 +27,7 @@ import Navigation from '@libs/Navigation/Navigation'; import {getDestinationForDisplay, getSubratesFields, getSubratesForDisplay, getTimeDifferenceIntervals, getTimeForDisplay} from '@libs/PerDiemRequestUtils'; import {canSendInvoice, getPerDiemCustomUnit} from '@libs/PolicyUtils'; import type {ThumbnailAndImageURI} from '@libs/ReceiptUtils'; -import {getThumbnailAndImageURIs} from '@libs/ReceiptUtils'; +import {getThumbnailAndImageURIs, isStitchedOdometerReceiptFilename} from '@libs/ReceiptUtils'; import {getReportName} from '@libs/ReportNameUtils'; import {generateReportID, getDefaultWorkspaceAvatar, getOutstandingReportsForUser, isMoneyRequestReport, isReportOutstanding} from '@libs/ReportUtils'; import {getTagVisibility, hasEnabledTags} from '@libs/TagsOptionsListUtils'; @@ -450,7 +450,7 @@ function MoneyRequestConfirmationListFooter({ } = receiptPath && receiptFilename ? getThumbnailAndImageURIs(transaction, receiptPath, receiptFilename) : ({} as ThumbnailAndImageURI); const resolvedThumbnail = isLocalFile ? receiptThumbnail : tryResolveUrlFromApiRoot(receiptThumbnail ?? ''); const resolvedReceiptImage = isLocalFile ? receiptImage : tryResolveUrlFromApiRoot(receiptImage ?? ''); - const isStitchedOdometerReceipt = !!receiptFilename?.startsWith('stitched_odometer'); + const isStitchedOdometerReceipt = isStitchedOdometerReceiptFilename(receiptFilename); const shouldNavigateToUpgradePath = !policyForMovingExpensesID && !shouldSelectPolicy; // Time requests appear as regular expenses after they're created, with editable amount and merchant, not hours and rate diff --git a/src/libs/ReceiptUtils.ts b/src/libs/ReceiptUtils.ts index dceabfe0ca4f6..57f13f5fa4046 100644 --- a/src/libs/ReceiptUtils.ts +++ b/src/libs/ReceiptUtils.ts @@ -7,6 +7,7 @@ import ROUTES from '@src/ROUTES'; import type {ShareTempFile, Transaction} from '@src/types/onyx'; import type {ReceiptError, ReceiptSource} from '@src/types/onyx/Transaction'; import {isLocalFile as isLocalFileUtils, splitExtensionFromFileName} from './fileDownload/FileUtils'; +import STITCHED_ODOMETER_FILENAME_PREFIX from './stitchOdometerImages/constants'; import {hasReceipt, hasReceiptSource, isFetchingWaypointsFromServer} from './TransactionUtils'; type ThumbnailAndImageURI = { @@ -94,6 +95,10 @@ const shouldValidateFile = (file: ShareTempFile | undefined) => { return file?.mimeType === CONST.SHARE_FILE_MIMETYPE.HEIC || file?.mimeType === CONST.SHARE_FILE_MIMETYPE.IMG; }; +function isStitchedOdometerReceiptFilename(filename: string | undefined): boolean { + return !!filename?.startsWith(STITCHED_ODOMETER_FILENAME_PREFIX); +} + // eslint-disable-next-line import/prefer-default-export -export {getThumbnailAndImageURIs, shouldValidateFile, constructReceiptSourceFromFilename}; +export {getThumbnailAndImageURIs, shouldValidateFile, constructReceiptSourceFromFilename, isStitchedOdometerReceiptFilename}; export type {ThumbnailAndImageURI}; diff --git a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx index ff1923ddf57a6..e82511cc58de4 100644 --- a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx +++ b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx @@ -19,7 +19,7 @@ import fetchImage from '@libs/fetchImage'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import getPlatform from '@libs/getPlatform'; import Navigation from '@libs/Navigation/Navigation'; -import {getThumbnailAndImageURIs} from '@libs/ReceiptUtils'; +import {getThumbnailAndImageURIs, isStitchedOdometerReceiptFilename} from '@libs/ReceiptUtils'; import {getReportAction, isTrackExpenseAction} from '@libs/ReportActionsUtils'; import {canEditFieldOfMoneyRequest, isMoneyRequestReport, isTrackExpenseReport} from '@libs/ReportUtils'; import {getRequestType, hasEReceipt, hasMissingSmartscanFields, hasReceipt, hasReceiptSource, isReceiptBeingScanned} from '@libs/TransactionUtils'; @@ -131,7 +131,7 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre const receiptFilename = transaction?.receipt?.filename; const isImage = !!receiptFilename && Str.isImage(receiptFilename); - const isStitchedOdometerReceipt = !!receiptFilename?.startsWith('stitched_odometer'); + const isStitchedOdometerReceipt = isStitchedOdometerReceiptFilename(receiptFilename); const shouldShowReplaceReceiptButton = ((canEditReceipt && !readonly) || isDraftTransaction) && !transaction?.receipt?.isTestDriveReceipt && !isStitchedOdometerReceipt; const shouldShowDeleteReceiptButton = canDeleteReceipt && !readonly && !isDraftTransaction && !transaction?.receipt?.isTestDriveReceipt;