diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 9f6cbbba2d5c4..24d7781b63807 100644 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -164,6 +164,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; @@ -237,6 +240,7 @@ function MoneyRequestConfirmationList({ isDistanceRequest, isManualDistanceRequest, isOdometerDistanceRequest = false, + isLoadingReceipt = false, isGPSDistanceRequest, isPerDiemRequest = false, isPolicyExpenseChat = false, @@ -1315,6 +1319,7 @@ function MoneyRequestConfirmationList({ isDistanceRequest={isDistanceRequest} isManualDistanceRequest={isManualDistanceRequest} isOdometerDistanceRequest={isOdometerDistanceRequest} + isLoadingReceipt={isLoadingReceipt} isGPSDistanceRequest={isGPSDistanceRequest} isPerDiemRequest={isPerDiemRequest} isTimeRequest={isTimeRequest} @@ -1410,5 +1415,6 @@ export default memo( prevProps.isTimeRequest === nextProps.isTimeRequest && prevProps.iouTimeCount === nextProps.iouTimeCount && prevProps.iouTimeRate === nextProps.iouTimeRate && - prevProps.shouldHideToSection === nextProps.shouldHideToSection, + prevProps.shouldHideToSection === nextProps.shouldHideToSection && + prevProps.isLoadingReceipt === nextProps.isLoadingReceipt, ); diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx index 7819c64f58ad4..4aab8702774dc 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, @@ -1089,91 +1094,92 @@ function MoneyRequestConfirmationListFooter({ return ( - {isLocalFile && Str.isPDF(receiptFilename) ? ( - { - if (!transactionID) { - return; - } + {isLoadingReceipt && } + {!isLoadingReceipt && + (isLocalFile && Str.isPDF(receiptFilename) ? ( + { + if (!transactionID) { + return; + } - Navigation.navigate( - isReceiptEditable - ? ROUTES.MONEY_REQUEST_RECEIPT_PREVIEW.getRoute(reportID, transactionID, action, iouType) - : ROUTES.TRANSACTION_RECEIPT.getRoute(reportID, transactionID), - ); - }} - accessibilityRole={CONST.ROLE.BUTTON} - accessibilityLabel={translate('accessibilityHints.viewAttachment')} - sentryLabel={CONST.SENTRY_LABEL.REQUEST_CONFIRMATION_LIST.PDF_RECEIPT_THUMBNAIL} - disabled={!shouldDisplayReceipt} - disabledStyle={styles.cursorDefault} - style={styles.h100} - > - - - ) : ( - { - if (!transactionID) { - return; - } + > + + + ) : ( + { + if (!transactionID) { + return; + } - Navigation.navigate( - isReceiptEditable - ? ROUTES.MONEY_REQUEST_RECEIPT_PREVIEW.getRoute(reportID, transactionID, action, iouType) - : ROUTES.TRANSACTION_RECEIPT.getRoute(reportID, transactionID), - ); - }} - disabled={!shouldDisplayReceipt || isThumbnail} - accessibilityRole={CONST.ROLE.BUTTON} - accessibilityLabel={translate('accessibilityHints.viewAttachment')} - sentryLabel={CONST.SENTRY_LABEL.REQUEST_CONFIRMATION_LIST.RECEIPT_THUMBNAIL} - disabledStyle={styles.cursorDefault} - style={receiptThumbnailStyle} - > - - - )} + Navigation.navigate( + isReceiptEditable + ? ROUTES.MONEY_REQUEST_RECEIPT_PREVIEW.getRoute(reportID, transactionID, action, iouType) + : ROUTES.TRANSACTION_RECEIPT.getRoute(reportID, transactionID), + ); + }} + disabled={!shouldDisplayReceipt || isThumbnail} + accessibilityRole={CONST.ROLE.BUTTON} + accessibilityLabel={translate('accessibilityHints.viewAttachment')} + sentryLabel={CONST.SENTRY_LABEL.REQUEST_CONFIRMATION_LIST.RECEIPT_THUMBNAIL} + disabledStyle={styles.cursorDefault} + style={receiptThumbnailStyle} + > + + + ))} ); }, [ isCompactMode, compactReceiptContainerStyle, styles.expenseViewImageSmall, - styles.moneyRequestImage, - styles.flex1, styles.h100, + styles.flex1, + styles.moneyRequestImage, + styles.justifyContentCenter, + styles.alignItemsCenter, styles.cursorDefault, + isLoadingReceipt, + handleCompactReceiptContainerLayout, isLocalFile, receiptFilename, - transactionID, - isReceiptEditable, - reportID, - action, - iouType, translate, shouldDisplayReceipt, resolvedReceiptImage, @@ -1184,9 +1190,13 @@ function MoneyRequestConfirmationListFooter({ receiptThumbnail, fileExtension, isDistanceRequest, - isOdometerDistanceRequest, handleReceiptLoad, - handleCompactReceiptContainerLayout, + isOdometerDistanceRequest, + transactionID, + isReceiptEditable, + reportID, + action, + iouType, ]); const visibleFields = fields.filter((field) => field.shouldShow); @@ -1270,7 +1280,7 @@ function MoneyRequestConfirmationListFooter({ )} {(!shouldShowMap || isManualDistanceRequest || isOdometerDistanceRequest) && - (hasReceiptImageOrThumbnail + (hasReceiptImageOrThumbnail || isLoadingReceipt ? receiptThumbnailContent : showReceiptEmptyState && ( , reportID: string, iouType: IOUType, action: IOUAction) => { + const [draftTransactionIDs, draftTransactionsMetadata] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftIDsSelector}); + + useEffect(() => { + if (Platform.OS !== 'web' || !transaction || action !== CONST.IOU.ACTION.CREATE || isLoadingOnyxValue(draftTransactionsMetadata)) { + return; + } + + const startImage = transaction.comment?.odometerStartImage; + const endImage = transaction.comment?.odometerEndImage; + const stitchedUri = transaction.receipt?.source?.toString(); + + const urlsToCheck = [ + { + filename: typeof startImage === 'object' ? startImage?.name : undefined, + path: getOdometerImageUri(startImage), + type: typeof startImage === 'object' ? startImage?.type : undefined, + }, + { + filename: typeof endImage === 'object' ? endImage?.name : undefined, + path: getOdometerImageUri(endImage), + type: typeof endImage === 'object' ? endImage?.type : undefined, + }, + { + filename: transaction.receipt?.filename, + path: stitchedUri, + type: undefined, + }, + ].filter(({path}) => !!path && path.startsWith('blob:')); + + if (urlsToCheck.length === 0) { + return; + } + + let canBeRead = true; + + Promise.all( + urlsToCheck.map(({filename, path, type}) => + checkIfLocalFileIsAccessible( + filename, + path, + type, + () => {}, + () => { + canBeRead = false; + }, + ), + ), + )?.then(() => { + const requestType = getRequestType(transaction); + if (canBeRead || requestType !== CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER) { + return; + } + + setMoneyRequestReceipt(transaction.transactionID, '', '', true); + setMoneyRequestOdometerReading(transaction.transactionID, null, null, true); + removeMoneyRequestOdometerImage(transaction.transactionID, CONST.IOU.ODOMETER_IMAGE_TYPE.START, true); + removeMoneyRequestOdometerImage(transaction.transactionID, CONST.IOU.ODOMETER_IMAGE_TYPE.END, true); + removeDraftTransactionsByIDs(draftTransactionIDs, true); + navigateToStartMoneyRequestStep(requestType, iouType, transaction.transactionID, reportID); + }); + + // We want this hook to run on mounting only + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [draftTransactionsMetadata]); +}; + +export default useRestartOnOdometerImagesFailure; diff --git a/src/hooks/useRestartOnReceiptFailure.ts b/src/hooks/useRestartOnReceiptFailure.ts index 73f36e0e334df..a3a8186d95e47 100644 --- a/src/hooks/useRestartOnReceiptFailure.ts +++ b/src/hooks/useRestartOnReceiptFailure.ts @@ -1,6 +1,6 @@ import {useEffect} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; -import {checkIfScanFileCanBeRead, setMoneyRequestReceipt} from '@libs/actions/IOU'; +import {checkIfLocalFileIsAccessible, setMoneyRequestReceipt} from '@libs/actions/IOU'; import {removeDraftTransactionsByIDs} from '@libs/actions/TransactionEdit'; import {isLocalFile as isLocalFileUtil} from '@libs/fileDownload/FileUtils'; import {navigateToStartMoneyRequestStep} from '@libs/IOUUtils'; @@ -40,7 +40,7 @@ const useRestartOnReceiptFailure = (transaction: OnyxEntry, reportI setMoneyRequestReceipt(transaction.transactionID, '', '', true); }; - checkIfScanFileCanBeRead(itemReceiptFilename, itemReceiptPath, itemReceiptType, () => {}, onFailure)?.then(() => { + checkIfLocalFileIsAccessible(itemReceiptFilename, itemReceiptPath, itemReceiptType, () => {}, onFailure)?.then(() => { const requestType = getRequestType(transaction); if (isScanFilesCanBeRead || requestType !== CONST.IOU.REQUEST_TYPE.SCAN) { return; diff --git a/src/libs/OdometerImageUtils.ts b/src/libs/OdometerImageUtils.ts new file mode 100644 index 0000000000000..721bd2c19e6bb --- /dev/null +++ b/src/libs/OdometerImageUtils.ts @@ -0,0 +1,33 @@ +import type {FileObject} from '@src/types/utils/Attachment'; + +function getOdometerImageUri(image: FileObject | string | null | undefined): string | undefined { + return typeof image === 'string' ? image : image?.uri; +} + +/** + * Revokes a blob URL previously associated with an odometer image, but only when + * the image has actually changed (i.e. the old URL differs from the new one). + * + * Skips revocation when: + * - The `URL` API is not available (non-browser environments / native) + * - The URI is not a blob: URL (e.g. file:// on native, https:// for uploaded images) + * - The old and new URIs are identical (image was not replaced) + */ +function revokeOdometerImageUri(image: FileObject | string | null | undefined, nextImage?: FileObject | string | null): void { + if (typeof URL === 'undefined') { + return; + } + + const currentUri = getOdometerImageUri(image); + if (!currentUri?.startsWith('blob:')) { + return; + } + const nextUri = getOdometerImageUri(nextImage); + if (currentUri === nextUri) { + return; + } + URL.revokeObjectURL(currentUri); +} + +export {getOdometerImageUri}; +export default revokeOdometerImageUri; diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index fdc5fe67e3d32..06f0127190be4 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -67,6 +67,7 @@ import {isOffline} from '@libs/Network/NetworkStore'; import {buildNextStepNew, buildOptimisticNextStep} from '@libs/NextStepUtils'; import {roundToTwoDecimalPlaces} from '@libs/NumberUtils'; import * as NumberUtils from '@libs/NumberUtils'; +import revokeOdometerImageUri from '@libs/OdometerImageUtils'; import {getManagerMcTestParticipant, getPersonalDetailsForAccountIDs} from '@libs/OptionsListUtils'; import Parser from '@libs/Parser'; import {getCustomUnitID} from '@libs/PerDiemRequestUtils'; @@ -1651,7 +1652,7 @@ function setMoneyRequestDistance(transactionID: string, distanceAsFloat: number, /** * Set the odometer readings for a transaction */ -function setMoneyRequestOdometerReading(transactionID: string, startReading: number, endReading: number, isDraft: boolean) { +function setMoneyRequestOdometerReading(transactionID: string, startReading: number | null, endReading: number | null, isDraft: boolean) { Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { comment: { odometerStart: startReading, @@ -1660,30 +1661,15 @@ function setMoneyRequestOdometerReading(transactionID: string, startReading: num }); } -function revokeOdometerImageUri(image: FileObject | string | null | undefined, nextImage?: FileObject | string | null): void { - if (typeof URL === 'undefined') { - return; - } - - const currentUri = typeof image === 'string' ? image : image?.uri; - if (!currentUri?.startsWith('blob:')) { - return; - } - const nextUri = typeof nextImage === 'string' ? nextImage : nextImage?.uri; - if (currentUri === nextUri) { - return; - } - URL.revokeObjectURL(currentUri); -} - /** * Set odometer image for a transaction * @param transactionID - The transaction ID * @param imageType - 'start' or 'end' * @param file - The image file (File object on web, URI string on native) * @param isDraft - Whether this is a draft transaction + * @param shouldRevokeOldImage - Whether to revoke the previous blob URL immediately (false when a backup transaction exists making the caller responsible for revoking) */ -function setMoneyRequestOdometerImage(transactionID: string, imageType: OdometerImageType, file: FileObject | string, isDraft: boolean) { +function setMoneyRequestOdometerImage(transactionID: string, imageType: OdometerImageType, file: FileObject | string, isDraft: boolean, shouldRevokeOldImage = true) { const imageKey = imageType === CONST.IOU.ODOMETER_IMAGE_TYPE.START ? 'odometerStartImage' : 'odometerEndImage'; const normalizedFile: FileObject | string = typeof file === 'string' @@ -1696,7 +1682,9 @@ function setMoneyRequestOdometerImage(transactionID: string, imageType: Odometer }; const transaction = isDraft ? allTransactionDrafts[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`] : allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; const existingImage = transaction?.comment?.[imageKey]; - revokeOdometerImageUri(existingImage, normalizedFile); + if (shouldRevokeOldImage) { + revokeOdometerImageUri(existingImage, normalizedFile); + } Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { comment: { [imageKey]: normalizedFile, @@ -1709,12 +1697,15 @@ function setMoneyRequestOdometerImage(transactionID: string, imageType: Odometer * @param transactionID - The transaction ID * @param imageType - 'start' or 'end' * @param isDraft - Whether this is a draft transaction + * @param shouldRevokeOldImage - Whether to revoke the previous blob URL immediately (false when a backup transaction exists making the caller responsible for revoking) */ -function removeMoneyRequestOdometerImage(transactionID: string, imageType: OdometerImageType, isDraft: boolean) { +function removeMoneyRequestOdometerImage(transactionID: string, imageType: OdometerImageType, isDraft: boolean, shouldRevokeOldImage = true) { const imageKey = imageType === CONST.IOU.ODOMETER_IMAGE_TYPE.START ? 'odometerStartImage' : 'odometerEndImage'; const transaction = isDraft ? allTransactionDrafts[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`] : allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; const existingImage = transaction?.comment?.[imageKey]; - revokeOdometerImageUri(existingImage); + if (shouldRevokeOldImage) { + revokeOdometerImageUri(existingImage); + } Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { comment: { [imageKey]: null, @@ -11763,7 +11754,7 @@ function navigateToStartStepIfScanFileCannotBeRead( readFileAsync(receiptPath.toString(), receiptFilename, onSuccess, onFailure, receiptType); } -function checkIfScanFileCanBeRead( +function checkIfLocalFileIsAccessible( receiptFilename: string | undefined, receiptPath: ReceiptSource | undefined, receiptType: string | undefined, @@ -13145,7 +13136,7 @@ export { getIOURequestPolicyID, getReportOriginalCreationTimestamp, initMoneyRequest, - checkIfScanFileCanBeRead, + checkIfLocalFileIsAccessible, dismissModalAndOpenReportInInboxTab, navigateToStartStepIfScanFileCannotBeRead, completePaymentOnboarding, diff --git a/src/libs/actions/TransactionEdit.ts b/src/libs/actions/TransactionEdit.ts index 4ad7d1248f726..1c8cbe1a0ffd4 100644 --- a/src/libs/actions/TransactionEdit.ts +++ b/src/libs/actions/TransactionEdit.ts @@ -2,6 +2,7 @@ import {format} from 'date-fns'; import Onyx from 'react-native-onyx'; import type {Connection, OnyxEntry} from 'react-native-onyx'; import {formatCurrentUserToAttendee} from '@libs/IOUUtils'; +import revokeOdometerImageUri from '@libs/OdometerImageUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetails, Transaction} from '@src/types/onyx'; @@ -12,7 +13,7 @@ let connection: Connection; /** * Makes a backup copy of a transaction object that can be restored when the user cancels editing a transaction. */ -function createBackupTransaction(transaction: OnyxEntry, isDraft: boolean) { +function createBackupTransaction(transaction: OnyxEntry, isDraft: boolean, shouldAlwaysCreateFreshBackup = false) { if (!transaction) { return; } @@ -24,6 +25,15 @@ function createBackupTransaction(transaction: OnyxEntry, isDraft: b const newTransaction = { ...transaction, }; + + // When shouldAlwaysCreateFreshBackup is true, skip reading the existing backup entirely and directly overwrite it. + // This avoids a race condition where connectWithoutView would fall back to reading from AsyncStorage (which may still + // contain a stale backup from a previous session even if the cache entry was dropped), restoring corrupted data. + if (shouldAlwaysCreateFreshBackup) { + Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${transaction.transactionID}`, newTransaction); + return; + } + // We need to read the old transaction backup first before writing a new one, otherwise we might overwrite an existing backup. It does not update impact UI rendering since this function is called on page mount. const conn = Onyx.connectWithoutView({ key: `${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${transaction.transactionID}`, @@ -171,10 +181,57 @@ function buildOptimisticTransactionAndCreateDraft({initialTransaction, currentUs return newTransaction; } +function removeBackupTransactionWithImageCleanup(transactionID: string | undefined, isDraft: boolean, onComplete?: () => void) { + if (!transactionID) { + return; + } + const backupConn = Onyx.connectWithoutView({ + key: `${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${transactionID}`, + callback: (backupTransaction) => { + Onyx.disconnect(backupConn); + const currentConn = Onyx.connectWithoutView({ + key: `${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + callback: (currentTransaction) => { + Onyx.disconnect(currentConn); + revokeOdometerImageUri(backupTransaction?.comment?.odometerStartImage, currentTransaction?.comment?.odometerStartImage); + revokeOdometerImageUri(backupTransaction?.comment?.odometerEndImage, currentTransaction?.comment?.odometerEndImage); + removeBackupTransaction(transactionID); + onComplete?.(); + }, + }); + }, + }); +} + +function restoreOriginalTransactionFromBackupWithImageCleanup(transactionID: string | undefined, isDraft: boolean, onComplete?: () => void) { + if (!transactionID) { + return; + } + connection = Onyx.connectWithoutView({ + key: `${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${transactionID}`, + callback: (backupTransaction) => { + Onyx.disconnect(connection); + const currentConn = Onyx.connectWithoutView({ + key: `${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + callback: (currentTransaction) => { + Onyx.disconnect(currentConn); + revokeOdometerImageUri(currentTransaction?.comment?.odometerStartImage, backupTransaction?.comment?.odometerStartImage); + revokeOdometerImageUri(currentTransaction?.comment?.odometerEndImage, backupTransaction?.comment?.odometerEndImage); + Onyx.set(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, backupTransaction ?? null); + removeBackupTransaction(transactionID); + onComplete?.(); + }, + }); + }, + }); +} + export { createBackupTransaction, removeBackupTransaction, + removeBackupTransactionWithImageCleanup, restoreOriginalTransactionFromBackup, + restoreOriginalTransactionFromBackupWithImageCleanup, createDraftTransaction, removeDraftTransaction, removeTransactionReceipt, diff --git a/src/libs/fileDownload/validateReceiptFile/index.ts b/src/libs/fileDownload/validateReceiptFile/index.ts index e0bb020a441bf..5bb725d828af9 100644 --- a/src/libs/fileDownload/validateReceiptFile/index.ts +++ b/src/libs/fileDownload/validateReceiptFile/index.ts @@ -1,4 +1,4 @@ -import {checkIfScanFileCanBeRead} from '@libs/actions/IOU'; +import {checkIfLocalFileIsAccessible} from '@libs/actions/IOU'; import type {ReceiptSource} from '@src/types/onyx/Transaction'; /** @@ -12,7 +12,7 @@ function validateReceiptFile( onSuccess: (file: File) => void, onFailure: () => void, ): Promise | undefined { - return checkIfScanFileCanBeRead(receiptFilename, receiptPath, receiptType, onSuccess, onFailure); + return checkIfLocalFileIsAccessible(receiptFilename, receiptPath, receiptType, onSuccess, onFailure); } export default validateReceiptFile; 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 2e86a4bc5c39f..839e8b0a4bab4 100644 --- a/src/libs/stitchOdometerImages/index.native.ts +++ b/src/libs/stitchOdometerImages/index.native.ts @@ -1,12 +1,14 @@ import {ImageFormat, Skia} from '@shopify/react-native-skia'; import RNFS from 'react-native-fs'; import Log from '@libs/Log'; +import {getOdometerImageUri} from '@libs/OdometerImageUtils'; 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 { - const source1 = typeof image1 === 'string' ? image1 : (image1?.uri ?? null); - const source2 = typeof image2 === 'string' ? image2 : (image2?.uri ?? null); + const source1 = getOdometerImageUri(image1) ?? null; + const source2 = getOdometerImageUri(image2) ?? null; if (!source1 || !source2) { return null; @@ -46,13 +48,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..88ec343efc9c6 100644 --- a/src/libs/stitchOdometerImages/index.ts +++ b/src/libs/stitchOdometerImages/index.ts @@ -1,12 +1,14 @@ +import {getOdometerImageUri} from '@libs/OdometerImageUtils'; 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 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); - const source2 = typeof image2 === 'string' ? image2 : (image2?.uri ?? null); + const source1 = getOdometerImageUri(image1) ?? null; + const source2 = getOdometerImageUri(image2) ?? null; if (!source1 || !source2) { return Promise.resolve(null); @@ -45,7 +47,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'); }); }); diff --git a/src/pages/iou/request/DistanceRequestStartPage.tsx b/src/pages/iou/request/DistanceRequestStartPage.tsx index 7ca4b5f0bd070..8d553b3c8afef 100644 --- a/src/pages/iou/request/DistanceRequestStartPage.tsx +++ b/src/pages/iou/request/DistanceRequestStartPage.tsx @@ -14,6 +14,7 @@ import usePersonalPolicy from '@hooks/usePersonalPolicy'; import usePolicy from '@hooks/usePolicy'; import usePrevious from '@hooks/usePrevious'; import useThemeStyles from '@hooks/useThemeStyles'; +import {removeBackupTransaction} from '@libs/actions/TransactionEdit'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Navigation from '@libs/Navigation/Navigation'; @@ -142,6 +143,14 @@ function DistanceRequestStartPage({ ], ); + // When the tab creation view is focused, any existing backup for this transaction is stale - + // backups are only meaningful for the standalone odometer step (accessed from confirmation). + useFocusEffect( + useCallback(() => { + removeBackupTransaction(route.params.transactionID); + }, [route.params.transactionID]), + ); + // Clear out the temporary expense if the reportID in the URL has changed from the transaction's reportID. useFocusEffect( useCallback(() => { diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index 9d09be321b9a0..fa9147d926a29 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -1,3 +1,4 @@ +import {useIsFocused} from '@react-navigation/native'; import {hasSeenTourSelector} from '@selectors/Onboarding'; import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; @@ -5,6 +6,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'; @@ -30,6 +32,7 @@ import usePermissions from '@hooks/usePermissions'; import usePolicyForTransaction from '@hooks/usePolicyForTransaction'; import usePrivateIsArchivedMap from '@hooks/usePrivateIsArchivedMap'; import useReportAttributes from '@hooks/useReportAttributes'; +import useRestartOnOdometerImagesFailure from '@hooks/useRestartOnOdometerImagesFailure'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {completeTestDriveTask} from '@libs/actions/Task'; @@ -37,7 +40,7 @@ import {getCurrencySymbol} from '@libs/CurrencyUtils'; import DateUtils from '@libs/DateUtils'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; -import {isLocalFile as isLocalFileFileUtils} from '@libs/fileDownload/FileUtils'; +import {getMimeTypeFromUri, isLocalFile as isLocalFileFileUtils} from '@libs/fileDownload/FileUtils'; import validateReceiptFile from '@libs/fileDownload/validateReceiptFile'; import getCurrentPosition from '@libs/getCurrentPosition'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; @@ -53,6 +56,7 @@ import Log from '@libs/Log'; import navigateAfterInteraction from '@libs/Navigation/navigateAfterInteraction'; import Navigation from '@libs/Navigation/Navigation'; import {rand64, roundToTwoDecimalPlaces} from '@libs/NumberUtils'; +import {getOdometerImageUri} from '@libs/OdometerImageUtils'; import {getParticipantsOption, getReportOption} from '@libs/OptionsListUtils'; import {isPaidGroupPolicy} from '@libs/PolicyUtils'; import { @@ -65,6 +69,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'; @@ -84,6 +89,7 @@ import { } from '@libs/TransactionUtils'; import type {GpsPoint} from '@userActions/IOU'; import { + checkIfLocalFileIsAccessible, createDistanceRequest as createDistanceRequestIOUActions, getIOURequestPolicyID, requestMoney as requestMoneyIOUActions, @@ -283,6 +289,7 @@ function IOURequestStepConfirmation({ const isDistanceRequest = isDistanceRequestTransactionUtils(transaction); const isManualDistanceRequest = isManualDistanceRequestTransactionUtils(transaction); const isOdometerDistanceRequest = isOdometerDistanceRequestTransactionUtils(transaction); + const isFocused = useIsFocused(); const isGPSDistanceRequest = isGPSDistanceRequestTransactionUtils(transaction); const transactionDistance = isManualDistanceRequest || isOdometerDistanceRequest || isGPSDistanceRequest ? (transaction?.comment?.customUnit?.quantity ?? undefined) : undefined; const isTimeRequest = requestType === CONST.IOU.REQUEST_TYPE.TIME; @@ -311,6 +318,12 @@ function IOURequestStepConfirmation({ const gpsRequired = transaction?.amount === 0 && iouType !== CONST.IOU.TYPE.SPLIT && Object.values(receiptFiles).length && !isTestTransaction && isScanRequest(transaction); const [isConfirmed, setIsConfirmed] = useState(false); const [isConfirming, setIsConfirming] = useState(false); + const [isStitchingReceipt, setIsStitchingReceipt] = useState(false); + const [stitchError, setStitchError] = useState(''); + const lastStitchedImages = useRef<{ + startImage: FileObject | string | undefined; + endImage: FileObject | string | undefined; + } | null>(null); const headerTitle = useMemo(() => { if (isCategorizingTrackExpense) { @@ -409,6 +422,123 @@ function IOURequestStepConfirmation({ } }, [isOffline, policy?.pendingAction, policyExpenseChatPolicyID, senderPolicyID]); + useRestartOnOdometerImagesFailure(isOdometerDistanceRequest ? transaction : undefined, reportID, iouType, action); + + const odometerStartImage = transaction?.comment?.odometerStartImage; + const odometerEndImage = transaction?.comment?.odometerEndImage; + + useEffect(() => { + if (!isOdometerDistanceRequest || !isFocused) { + return; + } + + // Skip stitching when source images haven't changed (compare by URI not reference + // because Onyx may create new object instances when restoring a backup transaction) + const startUri = getOdometerImageUri(odometerStartImage); + const endUri = getOdometerImageUri(odometerEndImage); + if ( + lastStitchedImages.current !== null && + getOdometerImageUri(lastStitchedImages.current.startImage) === startUri && + getOdometerImageUri(lastStitchedImages.current.endImage) === endUri + ) { + return; + } + + if (!odometerStartImage || !odometerEndImage) { + const singleImage = odometerStartImage ?? odometerEndImage; + + if (!singleImage) { + return; + } + + const getImageName = (img: typeof singleImage): string => (typeof img === 'string' ? (img.split('/').pop() ?? '') : (img.name ?? '')); + + setMoneyRequestReceipt(currentTransactionID, getOdometerImageUri(singleImage) ?? '', getImageName(singleImage), shouldUseTransactionDraft(action)); + lastStitchedImages.current = {startImage: odometerStartImage, endImage: odometerEndImage}; + return; + } + + let ignore = false; + setIsStitchingReceipt(true); + setStitchError(''); + + const runStitch = () => { + stitchOdometerImages(odometerStartImage, odometerEndImage) + .then((stitchedImage) => { + if (ignore || !stitchedImage) { + return; + } + const uri = stitchedImage?.uri ?? startUri ?? endUri ?? ''; + const name = + stitchedImage?.name ?? + (typeof odometerStartImage !== 'string' ? odometerStartImage?.name : odometerStartImage?.split('/').pop()) ?? + (typeof odometerEndImage !== 'string' ? odometerEndImage?.name : odometerEndImage?.split('/').pop()) ?? + ''; + const type = + stitchedImage?.type ?? + (typeof odometerStartImage !== 'string' ? odometerStartImage?.type : getMimeTypeFromUri(odometerStartImage)) ?? + (typeof odometerEndImage !== 'string' ? odometerEndImage?.type : getMimeTypeFromUri(odometerEndImage)) ?? + 'image/jpeg'; + setMoneyRequestReceipt(currentTransactionID, uri, name, shouldUseTransactionDraft(action), type); + lastStitchedImages.current = {startImage: odometerStartImage, endImage: odometerEndImage}; + }) + .catch((error: unknown) => { + if (ignore) { + return; + } + Log.warn('stitchOdometerImages failed', {error}); + setStitchError(translate('iou.error.stitchOdometerImagesFailed')); + }) + .finally(() => { + if (ignore) { + return; + } + setIsStitchingReceipt(false); + }); + }; + + // Pre-flight: verify blob URLs haven't expired before attempting to stitch (edge case guard) + const getOdometerFilename = (img: typeof odometerStartImage): string => (typeof img === 'object' ? img?.name : undefined) ?? 'odometer-image.jpg'; + const localImages = [ + {uri: startUri, image: odometerStartImage}, + {uri: endUri, image: odometerEndImage}, + ].filter((item): item is {uri: string; image: typeof odometerStartImage} => !!item.uri && item.uri.startsWith('blob:')); + if (localImages.length > 0) { + let hasExpiredImages = false; + Promise.all( + localImages.map(({uri, image}) => + checkIfLocalFileIsAccessible( + getOdometerFilename(image), + uri, + typeof image === 'object' ? image?.type : undefined, + () => {}, + () => { + hasExpiredImages = true; + }, + ), + ), + ).then(() => { + if (ignore) { + return; + } + if (hasExpiredImages) { + setIsStitchingReceipt(false); + setMoneyRequestReceipt(currentTransactionID, '', '', shouldUseTransactionDraft(action)); + removeDraftTransactionsByIDs(draftTransactionIDs, true); + navigateToStartMoneyRequestStep(requestType, iouType, currentTransactionID, reportID); + return; + } + runStitch(); + }); + } else { + runStitch(); + } + + return () => { + ignore = true; + }; + }, [isOdometerDistanceRequest, isFocused, currentTransactionID, odometerStartImage, odometerEndImage, action, translate, draftTransactionIDs, requestType, iouType, reportID]); + const defaultBillable = !!policy?.defaultBillable; useEffect(() => { if (isMovingTransactionFromTrackExpense) { @@ -1591,6 +1721,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); @@ -151,6 +149,8 @@ function IOURequestStepDistanceOdometer({ setShouldEnableDiscardConfirmation(!isEditingConfirmation && !isEditing); }, [isEditing, isEditingConfirmation]); + useRestartOnOdometerImagesFailure(transaction, reportID, iouType, action); + // Get odometer images from transaction (only for display, not for initialization) const odometerStartImage = transaction?.comment?.odometerStartImage; const odometerEndImage = transaction?.comment?.odometerEndImage; @@ -231,6 +231,14 @@ function IOURequestStepDistanceOdometer({ } }, [currentTransaction?.comment?.odometerStart, currentTransaction?.comment?.odometerEnd, isEditing]); + useEffect(() => { + if (!isEditingConfirmation) { + return; + } + createBackupTransaction(currentTransaction, isTransactionDraft, true); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + // Calculate total distance - updated live after every input change const totalDistance = (() => { const start = parseFloat(DistanceRequestUtils.normalizeOdometerText(startReading, fromLocaleDigit)); @@ -348,11 +356,14 @@ function IOURequestStepDistanceOdometer({ const navigateBack = useCallback(() => { if (isEditingConfirmation) { - Navigation.goBack(confirmationRoute); + // User didn't saved - we restore original from backup and cleanup blob urls of new images + restoreOriginalTransactionFromBackupWithImageCleanup(transactionID, isTransactionDraft, () => { + Navigation.goBack(confirmationRoute); + }); return; } Navigation.goBack(); - }, [isEditingConfirmation, confirmationRoute]); + }, [isEditingConfirmation, confirmationRoute, transactionID, isTransactionDraft]); const handlePressStartImage = useCallback(() => { if (odometerStartImage) { @@ -373,7 +384,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); @@ -381,30 +392,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()) ?? - ''; - const type = - stitchedImage?.type ?? - (typeof odometerStartImage !== 'string' ? odometerStartImage?.type : getMimeTypeFromUri(odometerStartImage)) ?? - (typeof odometerEndImage !== 'string' ? odometerEndImage?.type : getMimeTypeFromUri(odometerEndImage)) ?? - 'image/jpeg'; - setMoneyRequestReceipt(transactionID, uri, name, isTransactionDraft, type); - } - if (isEditing) { // In the split flow, when editing we use SPLIT_TRANSACTION_DRAFT to save draft value if (isEditingSplit && transaction) { @@ -454,7 +441,10 @@ function IOURequestStepDistanceOdometer({ } if (isEditingConfirmation) { - Navigation.goBack(confirmationRoute); + // User saved - we cleanup blob urls of original images from backup + removeBackupTransactionWithImageCleanup(transactionID, isTransactionDraft, () => { + Navigation.goBack(confirmationRoute); + }); return; } @@ -505,10 +495,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')); @@ -541,13 +527,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(); }; useDiscardChangesConfirmation({ @@ -676,7 +656,6 @@ function IOURequestStepDistanceOdometer({ success allowBubble={!isEditing} pressOnEnter - isLoading={isSubmitting} medium={isExtraSmallScreenHeight} large={!isExtraSmallScreenHeight} style={[styles.w100]} diff --git a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx index d0c9a512fb852..29c8c71db982f 100644 --- a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx @@ -23,7 +23,7 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import {showCameraPermissionsAlert} from '@libs/fileDownload/FileUtils'; +import {getMimeTypeFromUri, showCameraPermissionsAlert} from '@libs/fileDownload/FileUtils'; import getPhotoSource from '@libs/fileDownload/getPhotoSource'; import getPlatform from '@libs/getPlatform'; import type Platform from '@libs/getPlatform/types'; @@ -186,7 +186,14 @@ function IOURequestStepOdometerImage({ if (!file) { return; } - setMoneyRequestOdometerImage(transactionID, imageType, file, isTransactionDraft); + const imageUri = (file as {uri?: string}).uri ?? ''; + setMoneyRequestOdometerImage( + transactionID, + imageType, + {uri: imageUri, name: imageUri.split('/').pop() ?? '', type: getMimeTypeFromUri(imageUri) ?? 'image/jpeg'}, + isTransactionDraft, + isEditingConfirmation !== 'true', + ); navigateBack(); }; @@ -246,10 +253,11 @@ function IOURequestStepOdometerImage({ { uri: source, name: filename, - type: (file as FileObject | undefined)?.type ?? 'image/jpeg', + type: (file as FileObject | undefined)?.type ?? getMimeTypeFromUri(source) ?? 'image/jpeg', size: (file as FileObject | undefined)?.size, }, isTransactionDraft, + isEditingConfirmation !== 'true', ); navigateBack(); }) @@ -363,7 +371,7 @@ function IOURequestStepOdometerImage({ diff --git a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx index 3c5d9a94e4ec9..04a85d0b97c72 100644 --- a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx @@ -17,6 +17,7 @@ import useFilesValidation from '@hooks/useFilesValidation'; import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useRestartOnOdometerImagesFailure from '@hooks/useRestartOnOdometerImagesFailure'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {isMobile, isMobileWebKit} from '@libs/Browser'; @@ -43,6 +44,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; type IOURequestStepOdometerImageProps = WithFullTransactionOrNotFoundProps; function IOURequestStepOdometerImage({ + transaction, route: { params: {action, iouType, transactionID, reportID, backToReport, imageType, isEditingConfirmation}, }, @@ -54,6 +56,8 @@ function IOURequestStepOdometerImage({ const actionValue: IOUAction = action ?? CONST.IOU.ACTION.CREATE; const iouTypeValue: IOUType = iouType ?? CONST.IOU.TYPE.REQUEST; const isTransactionDraft = shouldUseTransactionDraft(actionValue, iouTypeValue); + + useRestartOnOdometerImagesFailure(transaction, reportID, iouTypeValue, actionValue); const dropBlobUrlsRef = useRef([]); const shouldRevokeOnUnmountRef = useRef(true); // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout because drag and drop is not supported on mobile. @@ -88,7 +92,7 @@ function IOURequestStepOdometerImage({ }; const handleImageSelected = (file: FileObject) => { - setMoneyRequestOdometerImage(transactionID, imageType, file as File, isTransactionDraft); + setMoneyRequestOdometerImage(transactionID, imageType, file as File, isTransactionDraft, isEditingConfirmation !== 'true'); shouldRevokeOnUnmountRef.current = false; navigateBack(); }; @@ -230,7 +234,7 @@ function IOURequestStepOdometerImage({ if (source !== imageObject.source) { URL.revokeObjectURL(imageObject.source); } - setMoneyRequestOdometerImage(transactionID, imageType, file ?? source, isTransactionDraft); + setMoneyRequestOdometerImage(transactionID, imageType, file ?? source, isTransactionDraft, isEditingConfirmation !== 'true'); navigateBack(); }) .catch((error: unknown) => { diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.tsx index 1e291b658d86c..74f522e87816d 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/index.tsx @@ -12,7 +12,7 @@ import Navigation from '@libs/Navigation/Navigation'; import {endSpan} from '@libs/telemetry/activeSpans'; import withFullTransactionOrNotFound from '@pages/iou/request/step/withFullTransactionOrNotFound'; import withWritableReportOrNotFound from '@pages/iou/request/step/withWritableReportOrNotFound'; -import {checkIfScanFileCanBeRead, replaceReceipt, updateLastLocationPermissionPrompt} from '@userActions/IOU'; +import {checkIfLocalFileIsAccessible, replaceReceipt, updateLastLocationPermissionPrompt} from '@userActions/IOU'; import {removeDraftTransactionsByIDs, removeTransactionReceipt} from '@userActions/TransactionEdit'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -114,7 +114,7 @@ function IOURequestStepScan({ isAllScanFilesCanBeRead = false; }; - return checkIfScanFileCanBeRead(item.receipt?.filename, itemReceiptPath, item.receipt?.type, () => {}, onFailure); + return checkIfLocalFileIsAccessible(item.receipt?.filename, itemReceiptPath, item.receipt?.type, () => {}, onFailure); }), ).then(() => { if (isAllScanFilesCanBeRead) { diff --git a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx index ca25690930bde..31c3b9f9a4ab5 100644 --- a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx +++ b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx @@ -11,6 +11,7 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; +import useRestartOnOdometerImagesFailure from '@hooks/useRestartOnOdometerImagesFailure'; import useThemeStyles from '@hooks/useThemeStyles'; import { detachReceipt, @@ -94,6 +95,13 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre return transactionMain; }, [isDraftTransaction, mergeTransaction, mergeTransactionID, transactionDraft, transactionMain]); + useRestartOnOdometerImagesFailure( + isDraftTransaction && isOdometerDistanceRequest(transaction) ? transaction : undefined, + reportID, + iouTypeParam ?? CONST.IOU.TYPE.SUBMIT, + action ?? CONST.IOU.ACTION.CREATE, + ); + const [transactionReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transaction?.reportID}`); const receiptURIs = getThumbnailAndImageURIs(transaction); const isLocalFile = receiptURIs.isLocalFile; @@ -266,9 +274,9 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre if (!transaction?.transactionID || !imageType) { return; } - removeMoneyRequestOdometerImage(transaction.transactionID, imageType, isDraftTransaction); + removeMoneyRequestOdometerImage(transaction.transactionID, imageType, isDraftTransaction, !isEditingConfirmation); navigation.goBack(); - }, [transaction?.transactionID, imageType, isDraftTransaction, navigation]); + }, [transaction?.transactionID, imageType, isDraftTransaction, isEditingConfirmation, navigation]); const onDownloadAttachment = useDownloadAttachment({ isAuthTokenRequired, @@ -304,7 +312,7 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre const rotatedFilename = file.name ?? receiptFilename; if (isOdometerImage) { - setMoneyRequestOdometerImage(transaction.transactionID, imageType, file, isDraftTransaction); + setMoneyRequestOdometerImage(transaction.transactionID, imageType, file, isDraftTransaction, !isEditingConfirmation); } else if (isDraftTransaction) { setMoneyRequestReceipt(transaction.transactionID, imageUriResult, rotatedFilename, isDraftTransaction, fileType); } else { @@ -391,7 +399,7 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre const croppedFilename = file.name ?? receiptFilename; if (isOdometerImage) { - setMoneyRequestOdometerImage(transaction.transactionID, imageType, file, isDraftTransaction); + setMoneyRequestOdometerImage(transaction.transactionID, imageType, file, isDraftTransaction, !isEditingConfirmation); } else if (isDraftTransaction) { setMoneyRequestReceipt(transaction.transactionID, imageUriResult, croppedFilename, isDraftTransaction, fileType); } else {