Skip to content

Commit 9856338

Browse files
authored
Merge pull request Expensify#86023 from software-mansion-labs/jakubkalinski0/Odometer_blob_url_loss_detection
[Odometer] Blob URL loss detection
2 parents c89c413 + 7fc6b9b commit 9856338

16 files changed

Lines changed: 300 additions & 146 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// On native blob:// URLs don't exist, so there is nothing to check
2+
3+
const useRestartOnOdometerImagesFailure = () => {};
4+
5+
export default useRestartOnOdometerImagesFailure;
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import {useEffect, useRef} from 'react';
2+
import type {OnyxEntry} from 'react-native-onyx';
3+
import useOnyx from '@hooks/useOnyx';
4+
import {checkIfLocalFileIsAccessible} from '@libs/actions/IOU/Receipt';
5+
import clearOdometerDraftTransactionState from '@libs/actions/OdometerTransactionUtils';
6+
import {navigateToStartMoneyRequestStep} from '@libs/IOUUtils';
7+
import {getOdometerImageUri} from '@libs/OdometerImageUtils';
8+
import type {IOUType} from '@src/CONST';
9+
import CONST from '@src/CONST';
10+
import ONYXKEYS from '@src/ONYXKEYS';
11+
import {validTransactionDraftIDsSelector} from '@src/selectors/TransactionDraft';
12+
import type {Transaction} from '@src/types/onyx';
13+
import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue';
14+
15+
// When the component mounts, if there are odometer images or a stitched receipt, see if the files can be read from the disk.
16+
// If not, redirect the user to the starting step of the flow.
17+
// This is because until the request is saved, the image files are only stored in the browser's memory as blob:// URLs
18+
// and if the browser is refreshed, then the images cease to exist.
19+
// The best way for the user to recover from this is to start over from the start of the request process.
20+
const useRestartOnOdometerImagesFailure = (transaction: OnyxEntry<Transaction>, reportID: string, iouType: IOUType, backToReport: string | undefined, onBackupHandled?: () => void) => {
21+
const [, draftTransactionsMetadata] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftIDsSelector});
22+
const hasCheckedRef = useRef(false);
23+
24+
useEffect(() => {
25+
if (!transaction || isLoadingOnyxValue(draftTransactionsMetadata)) {
26+
return;
27+
}
28+
29+
// Run only once after Onyx finishes loading — blob:// URLs are ephemeral and only need
30+
// to be verified on the first render after the data is available.
31+
// It has to be resolved this way in order to have a complete dependency array for the useEffect hook.
32+
if (hasCheckedRef.current) {
33+
return;
34+
}
35+
hasCheckedRef.current = true;
36+
37+
const startImage = transaction.comment?.odometerStartImage;
38+
const endImage = transaction.comment?.odometerEndImage;
39+
const stitchedUri = transaction.receipt?.source?.toString();
40+
41+
const urlsToCheck = [
42+
{
43+
filename: typeof startImage === 'object' ? startImage?.name : undefined,
44+
path: getOdometerImageUri(startImage),
45+
type: typeof startImage === 'object' ? startImage?.type : undefined,
46+
},
47+
{
48+
filename: typeof endImage === 'object' ? endImage?.name : undefined,
49+
path: getOdometerImageUri(endImage),
50+
type: typeof endImage === 'object' ? endImage?.type : undefined,
51+
},
52+
{
53+
filename: transaction.receipt?.filename,
54+
path: stitchedUri,
55+
type: undefined,
56+
},
57+
].filter(({path}) => !!path && path.startsWith('blob:'));
58+
59+
if (urlsToCheck.length === 0) {
60+
return;
61+
}
62+
63+
Promise.all(
64+
urlsToCheck.map(
65+
({filename, path, type}) =>
66+
new Promise<boolean>((resolve) => {
67+
checkIfLocalFileIsAccessible(
68+
filename,
69+
path,
70+
type,
71+
() => resolve(true),
72+
() => resolve(false),
73+
);
74+
}),
75+
),
76+
).then((results) => {
77+
const canBeRead = results.every(Boolean);
78+
if (canBeRead) {
79+
return;
80+
}
81+
82+
onBackupHandled?.();
83+
clearOdometerDraftTransactionState(transaction);
84+
navigateToStartMoneyRequestStep(CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER, iouType, transaction.transactionID, reportID, CONST.IOU.ACTION.CREATE, backToReport);
85+
});
86+
}, [draftTransactionsMetadata, transaction, iouType, reportID, backToReport, onBackupHandled]);
87+
};
88+
89+
export default useRestartOnOdometerImagesFailure;

src/hooks/useRestartOnReceiptFailure.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {useEffect} from 'react';
22
import type {OnyxEntry} from 'react-native-onyx';
3-
import {checkIfScanFileCanBeRead, setMoneyRequestReceipt} from '@libs/actions/IOU/Receipt';
3+
import {checkIfLocalFileIsAccessible, setMoneyRequestReceipt} from '@libs/actions/IOU/Receipt';
44
import {removeDraftTransactionsByIDs} from '@libs/actions/TransactionEdit';
55
import {isLocalFile as isLocalFileUtil} from '@libs/fileDownload/FileUtils';
66
import {navigateToStartMoneyRequestStep} from '@libs/IOUUtils';
@@ -40,7 +40,7 @@ const useRestartOnReceiptFailure = (transaction: OnyxEntry<Transaction>, reportI
4040
setMoneyRequestReceipt(transaction.transactionID, '', '', true);
4141
};
4242

43-
checkIfScanFileCanBeRead(itemReceiptFilename, itemReceiptPath, itemReceiptType, () => {}, onFailure)?.then(() => {
43+
checkIfLocalFileIsAccessible(itemReceiptFilename, itemReceiptPath, itemReceiptType, () => {}, onFailure)?.then(() => {
4444
const requestType = getRequestType(transaction);
4545
if (isScanFilesCanBeRead || requestType !== CONST.IOU.REQUEST_TYPE.SCAN) {
4646
return;
@@ -50,7 +50,7 @@ const useRestartOnReceiptFailure = (transaction: OnyxEntry<Transaction>, reportI
5050
navigateToStartMoneyRequestStep(requestType, iouType, transaction.transactionID, reportID);
5151
});
5252

53-
// We want this hook to run on mounting only
53+
// We want this hook to run once after Onyx finishes loading the draft transactions
5454
// eslint-disable-next-line react-hooks/exhaustive-deps
5555
}, [draftTransactionsMetadata]);
5656
};

src/libs/actions/IOU/Receipt.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -337,7 +337,7 @@ function navigateToStartStepIfScanFileCannotBeRead(
337337
readFileAsync(receiptPath.toString(), receiptFilename, onSuccess, onFailure, receiptType);
338338
}
339339

340-
function checkIfScanFileCanBeRead(
340+
function checkIfLocalFileIsAccessible(
341341
receiptFilename: string | undefined,
342342
receiptPath: ReceiptSource | undefined,
343343
receiptType: string | undefined,
@@ -352,4 +352,4 @@ function checkIfScanFileCanBeRead(
352352
return readFileAsync(receiptPath.toString(), receiptFilename, onSuccess, onFailure, receiptType);
353353
}
354354

355-
export {checkIfScanFileCanBeRead, detachReceipt, navigateToStartStepIfScanFileCannotBeRead, replaceReceipt, setMoneyRequestReceipt};
355+
export {checkIfLocalFileIsAccessible, detachReceipt, navigateToStartStepIfScanFileCannotBeRead, replaceReceipt, setMoneyRequestReceipt};

src/libs/actions/IOU/index.ts

Lines changed: 1 addition & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ import Navigation from '@libs/Navigation/Navigation';
2929
import {buildNextStepNew, buildOptimisticNextStep} from '@libs/NextStepUtils';
3030
import {roundToTwoDecimalPlaces} from '@libs/NumberUtils';
3131
import * as NumberUtils from '@libs/NumberUtils';
32-
import revokeOdometerImageUri from '@libs/OdometerImageUtils';
3332
import {getManagerMcTestParticipant} from '@libs/OptionsListUtils';
3433
import {getCustomUnitID} from '@libs/PerDiemRequestUtils';
3534
import {addSMSDomainIfPhoneNumber} from '@libs/PhoneNumber';
@@ -93,7 +92,7 @@ import {buildOptimisticPolicyRecentlyUsedTags} from '@userActions/Policy/Tag';
9392
import {notifyNewAction} from '@userActions/Report';
9493
import {mergeTransactionIdsHighlightOnSearchRoute, sanitizeWaypointsForAPI} from '@userActions/Transaction';
9594
import {getRemoveDraftTransactionsByIDsData, removeDraftTransaction, removeDraftTransactionsByIDs} from '@userActions/TransactionEdit';
96-
import type {IOUAction, IOUActionParams, OdometerImageType} from '@src/CONST';
95+
import type {IOUAction, IOUActionParams} from '@src/CONST';
9796
import CONST from '@src/CONST';
9897
import ONYXKEYS from '@src/ONYXKEYS';
9998
import ROUTES from '@src/ROUTES';
@@ -108,7 +107,6 @@ import type ReportAction from '@src/types/onyx/ReportAction';
108107
import type {OnyxData} from '@src/types/onyx/Request';
109108
import type {SearchDataTypes} from '@src/types/onyx/SearchResults';
110109
import type {Comment, Receipt, ReceiptSource, SplitShares, TransactionChanges, TransactionCustomUnit, WaypointCollection} from '@src/types/onyx/Transaction';
111-
import type {FileObject} from '@src/types/utils/Attachment';
112110
import {isEmptyObject} from '@src/types/utils/EmptyObject';
113111
import type BasePolicyParams from './types/BasePolicyParams';
114112
import type BaseTransactionParams from './types/BaseTransactionParams';
@@ -1112,78 +1110,6 @@ function setMoneyRequestDistance(transactionID: string, distanceAsFloat: number,
11121110
Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {comment: {customUnit: {quantity: distanceAsFloat, distanceUnit}}});
11131111
}
11141112

1115-
/**
1116-
* Set the odometer readings for a transaction
1117-
*/
1118-
function setMoneyRequestOdometerReading(transactionID: string, startReading: number, endReading: number, isDraft: boolean) {
1119-
Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {
1120-
comment: {
1121-
odometerStart: startReading,
1122-
odometerEnd: endReading,
1123-
},
1124-
});
1125-
}
1126-
1127-
/**
1128-
* Set odometer image for a transaction
1129-
* @param transaction - The transaction or transaction draft
1130-
* @param imageType - 'start' or 'end'
1131-
* @param file - The image file (File object on web, URI string on native)
1132-
* @param isDraft - Whether this is a draft transaction
1133-
* @param shouldRevokeOldImage - Whether to revoke the previous blob URL immediately (always false on native where blob URLs don't exist; false on web when a backup transaction exists making the caller responsible for revoking)
1134-
*/
1135-
function setMoneyRequestOdometerImage(
1136-
transaction: OnyxEntry<OnyxTypes.Transaction>,
1137-
imageType: OdometerImageType,
1138-
file: FileObject | string,
1139-
isDraft: boolean,
1140-
shouldRevokeOldImage: boolean,
1141-
) {
1142-
const imageKey = imageType === CONST.IOU.ODOMETER_IMAGE_TYPE.START ? 'odometerStartImage' : 'odometerEndImage';
1143-
const normalizedFile: FileObject | string =
1144-
typeof file === 'string'
1145-
? file
1146-
: {
1147-
uri: file.uri ?? (typeof URL !== 'undefined' ? URL.createObjectURL(file as Blob) : undefined),
1148-
name: file.name,
1149-
type: file.type,
1150-
size: file.size,
1151-
};
1152-
const transactionID = transaction?.transactionID;
1153-
const existingImage = transaction?.comment?.[imageKey];
1154-
if (shouldRevokeOldImage) {
1155-
revokeOdometerImageUri(existingImage, normalizedFile);
1156-
}
1157-
Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {
1158-
comment: {
1159-
[imageKey]: normalizedFile,
1160-
},
1161-
});
1162-
}
1163-
1164-
/**
1165-
* Remove odometer image from a transaction
1166-
* @param transaction - The transaction or transaction draft
1167-
* @param imageType - 'start' or 'end'
1168-
* @param isDraft - Whether this is a draft transaction
1169-
* @param shouldRevokeOldImage - Whether to revoke the previous blob URL immediately (always false on native where blob URLs don't exist; false on web when a backup transaction exists making the caller responsible for revoking)
1170-
*/
1171-
function removeMoneyRequestOdometerImage(transaction: OnyxEntry<OnyxTypes.Transaction>, imageType: OdometerImageType, isDraft: boolean, shouldRevokeOldImage: boolean) {
1172-
if (!transaction?.transactionID) {
1173-
return;
1174-
}
1175-
const imageKey = imageType === CONST.IOU.ODOMETER_IMAGE_TYPE.START ? 'odometerStartImage' : 'odometerEndImage';
1176-
const existingImage = transaction?.comment?.[imageKey];
1177-
if (shouldRevokeOldImage) {
1178-
revokeOdometerImageUri(existingImage);
1179-
}
1180-
Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transaction?.transactionID}`, {
1181-
comment: {
1182-
[imageKey]: null,
1183-
},
1184-
});
1185-
}
1186-
11871113
/**
11881114
* Set the distance rate of a transaction.
11891115
* Used when creating a new transaction or moving an existing one from Self DM
@@ -3753,9 +3679,6 @@ export {
37533679
setMoneyRequestDescription,
37543680
setMoneyRequestDistance,
37553681
setMoneyRequestDistanceRate,
3756-
setMoneyRequestOdometerReading,
3757-
setMoneyRequestOdometerImage,
3758-
removeMoneyRequestOdometerImage,
37593682
setMoneyRequestMerchant,
37603683
setMoneyRequestParticipants,
37613684
setMoneyRequestParticipantsFromReport,
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import Onyx from 'react-native-onyx';
2+
import type {OnyxEntry} from 'react-native-onyx';
3+
import revokeOdometerImageUri from '@libs/OdometerImageUtils';
4+
import CONST from '@src/CONST';
5+
import type {OdometerImageType} from '@src/CONST';
6+
import ONYXKEYS from '@src/ONYXKEYS';
7+
import type {Transaction} from '@src/types/onyx';
8+
import type {FileObject} from '@src/types/utils/Attachment';
9+
import {setMoneyRequestReceipt} from './IOU/Receipt';
10+
import {removeBackupTransaction} from './TransactionEdit';
11+
12+
/**
13+
* Set the odometer readings for a transaction
14+
*/
15+
function setMoneyRequestOdometerReading(transactionID: string, startReading: number | null, endReading: number | null, isDraft: boolean) {
16+
Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {
17+
comment: {
18+
odometerStart: startReading,
19+
odometerEnd: endReading,
20+
},
21+
});
22+
}
23+
24+
/**
25+
* Set odometer image for a transaction
26+
* @param transaction - The transaction or transaction draft
27+
* @param imageType - 'start' or 'end'
28+
* @param file - The image file (File object on web, URI string on native)
29+
* @param isDraft - Whether this is a draft transaction
30+
* @param shouldRevokeOldImage - Whether to revoke the previous blob URL immediately (always false on native where blob URLs don't exist; false on web when a backup transaction exists making the caller responsible for revoking)
31+
*/
32+
function setMoneyRequestOdometerImage(transaction: OnyxEntry<Transaction>, imageType: OdometerImageType, file: FileObject | string, isDraft: boolean, shouldRevokeOldImage: boolean) {
33+
const imageKey = imageType === CONST.IOU.ODOMETER_IMAGE_TYPE.START ? 'odometerStartImage' : 'odometerEndImage';
34+
const normalizedFile: FileObject | string =
35+
typeof file === 'string'
36+
? file
37+
: {
38+
uri: file.uri ?? (typeof URL !== 'undefined' ? URL.createObjectURL(file as Blob) : undefined),
39+
name: file.name,
40+
type: file.type,
41+
size: file.size,
42+
};
43+
const transactionID = transaction?.transactionID;
44+
const existingImage = transaction?.comment?.[imageKey];
45+
if (shouldRevokeOldImage) {
46+
revokeOdometerImageUri(existingImage, normalizedFile);
47+
}
48+
Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {
49+
comment: {
50+
[imageKey]: normalizedFile,
51+
},
52+
});
53+
}
54+
55+
/**
56+
* Remove odometer image from a transaction
57+
* @param transaction - The transaction or transaction draft
58+
* @param imageType - 'start' or 'end'
59+
* @param isDraft - Whether this is a draft transaction
60+
* @param shouldRevokeOldImage - Whether to revoke the previous blob URL immediately (always false on native where blob URLs don't exist; false on web when a backup transaction exists making the caller responsible for revoking)
61+
*/
62+
function removeMoneyRequestOdometerImage(transaction: OnyxEntry<Transaction>, imageType: OdometerImageType, isDraft: boolean, shouldRevokeOldImage: boolean) {
63+
if (!transaction?.transactionID) {
64+
return;
65+
}
66+
const imageKey = imageType === CONST.IOU.ODOMETER_IMAGE_TYPE.START ? 'odometerStartImage' : 'odometerEndImage';
67+
const existingImage = transaction?.comment?.[imageKey];
68+
if (shouldRevokeOldImage) {
69+
revokeOdometerImageUri(existingImage);
70+
}
71+
Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transaction?.transactionID}`, {
72+
comment: {
73+
[imageKey]: null,
74+
},
75+
});
76+
}
77+
78+
function clearOdometerDraftTransactionState(transaction: OnyxEntry<Transaction>): void {
79+
if (!transaction) {
80+
return;
81+
}
82+
setMoneyRequestReceipt(transaction.transactionID, '', '', true);
83+
setMoneyRequestOdometerReading(transaction.transactionID, null, null, true);
84+
removeMoneyRequestOdometerImage(transaction, CONST.IOU.ODOMETER_IMAGE_TYPE.START, true, true);
85+
removeMoneyRequestOdometerImage(transaction, CONST.IOU.ODOMETER_IMAGE_TYPE.END, true, true);
86+
removeBackupTransaction(transaction.transactionID);
87+
}
88+
89+
export {setMoneyRequestOdometerReading, setMoneyRequestOdometerImage, removeMoneyRequestOdometerImage};
90+
export default clearOdometerDraftTransactionState;

src/libs/fileDownload/validateReceiptFile/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {checkIfScanFileCanBeRead} from '@libs/actions/IOU/Receipt';
1+
import {checkIfLocalFileIsAccessible} from '@libs/actions/IOU/Receipt';
22
import type {ReceiptSource} from '@src/types/onyx/Transaction';
33

44
/**
@@ -12,7 +12,7 @@ function validateReceiptFile(
1212
onSuccess: (file: File) => void,
1313
onFailure: () => void,
1414
): Promise<void | File> | undefined {
15-
return checkIfScanFileCanBeRead(receiptFilename, receiptPath, receiptType, onSuccess, onFailure);
15+
return checkIfLocalFileIsAccessible(receiptFilename, receiptPath, receiptType, onSuccess, onFailure);
1616
}
1717

1818
export default validateReceiptFile;

src/pages/iou/request/step/IOURequestStepConfirmation.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import useOptimisticDraftTransactions from '@hooks/useOptimisticDraftTransaction
2424
import usePolicyForTransaction from '@hooks/usePolicyForTransaction';
2525
import usePrivateIsArchivedMap from '@hooks/usePrivateIsArchivedMap';
2626
import useReportAttributes from '@hooks/useReportAttributes';
27+
import useRestartOnOdometerImagesFailure from '@hooks/useRestartOnOdometerImagesFailure';
2728
import useTheme from '@hooks/useTheme';
2829
import useThemeStyles from '@hooks/useThemeStyles';
2930
import {isMobileSafari} from '@libs/Browser';
@@ -239,6 +240,7 @@ function IOURequestStepConfirmation({
239240

240241
const odometerStartImage = transaction?.comment?.odometerStartImage;
241242
const odometerEndImage = transaction?.comment?.odometerEndImage;
243+
useRestartOnOdometerImagesFailure(isOdometerDistanceRequest ? transaction : undefined, reportID, iouType, backToReport);
242244

243245
// Pre-insert Search is only useful for flows whose submit ends in handleNavigateAfterExpenseCreate
244246
// (which navigates to Search). Flows that use dismissModalAndOpenReportInInboxTab (PAY,
@@ -548,10 +550,11 @@ function IOURequestStepConfirmation({
548550
/>
549551
<OdometerReceiptStitcher
550552
isOdometerDistanceRequest={isOdometerDistanceRequest}
551-
currentTransactionID={currentTransactionID}
552553
odometerStartImage={odometerStartImage}
553554
odometerEndImage={odometerEndImage}
554-
action={action}
555+
transaction={transaction}
556+
reportID={reportID}
557+
backToReport={backToReport}
555558
iouType={iouType}
556559
onStitchingChange={setIsStitchingReceipt}
557560
onStitchError={setStitchError}
@@ -563,6 +566,7 @@ function IOURequestStepConfirmation({
563566
initialTransactionID={initialTransactionID}
564567
reportID={reportID}
565568
action={action}
569+
backToReport={backToReport}
566570
report={report}
567571
participants={participants}
568572
draftTransactionIDs={draftTransactionIDs}

0 commit comments

Comments
 (0)