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 {