Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/hooks/useRestartOnOdometerImagesFailure/index.native.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// On native blob:// URLs don't exist, so there is nothing to check
// On native blob:// URLs don't exist, so there is nothing to check —
// callers can always proceed with blob-dependent side-effects (like stitching)

const useRestartOnOdometerImagesFailure = () => {};
const useRestartOnOdometerImagesFailure = (): {hasVerifiedBlobs: boolean} => ({hasVerifiedBlobs: true});

export default useRestartOnOdometerImagesFailure;
28 changes: 26 additions & 2 deletions src/hooks/useRestartOnOdometerImagesFailure/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {useEffect, useRef} from 'react';
import {useEffect, useRef, useState} from 'react';
import type {OnyxEntry} from 'react-native-onyx';
import useOnyx from '@hooks/useOnyx';
import {checkIfLocalFileIsAccessible} from '@libs/actions/IOU/Receipt';
Expand All @@ -17,9 +17,27 @@ import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue';
// This is because until the request is saved, the image files are only stored in the browser's memory as blob:// URLs
// and if the browser is refreshed, then the images cease to exist.
// The best way for the user to recover from this is to start over from the start of the request process.
const useRestartOnOdometerImagesFailure = (transaction: OnyxEntry<Transaction>, reportID: string, iouType: IOUType, backToReport: string | undefined, onBackupHandled?: () => void) => {
// Returns `hasVerifiedBlobs` so callers can gate dependent side-effects (e.g. odometer image stitching)
// until this check has confirmed the blobs are still readable. When there are no blob URLs to verify
// (e.g. native file:// paths or remote URLs), `hasVerifiedBlobs` is `true` as soon as Onyx has loaded.
const useRestartOnOdometerImagesFailure = (
transaction: OnyxEntry<Transaction>,
reportID: string,
iouType: IOUType,
backToReport: string | undefined,
onBackupHandled?: () => void,
): {hasVerifiedBlobs: boolean} => {
const [, draftTransactionsMetadata] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftIDsSelector});
const hasCheckedRef = useRef(false);
const [asyncVerificationPassed, setAsyncVerificationPassed] = useState(false);

Comment thread
jakubkalinski0 marked this conversation as resolved.
const hasBlobUrls = (() => {
if (!transaction) {
return false;
}
const paths = [getOdometerImageUri(transaction.comment?.odometerStartImage), getOdometerImageUri(transaction.comment?.odometerEndImage), transaction.receipt?.source?.toString()];
return paths.some((path) => !!path && path.startsWith('blob:'));
})();

useEffect(() => {
if (!transaction || isLoadingOnyxValue(draftTransactionsMetadata)) {
Expand Down Expand Up @@ -76,6 +94,7 @@ const useRestartOnOdometerImagesFailure = (transaction: OnyxEntry<Transaction>,
).then((results) => {
const canBeRead = results.every(Boolean);
if (canBeRead) {
setAsyncVerificationPassed(true);
return;
}

Expand All @@ -84,6 +103,11 @@ const useRestartOnOdometerImagesFailure = (transaction: OnyxEntry<Transaction>,
navigateToStartMoneyRequestStep(CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER, iouType, transaction.transactionID, reportID, CONST.IOU.ACTION.CREATE, backToReport);
});
}, [draftTransactionsMetadata, transaction, iouType, reportID, backToReport, onBackupHandled]);

const isOnyxLoading = isLoadingOnyxValue(draftTransactionsMetadata);
const hasVerifiedBlobs = !!transaction && !isOnyxLoading && (!hasBlobUrls || asyncVerificationPassed);

return {hasVerifiedBlobs};
};

export default useRestartOnOdometerImagesFailure;
8 changes: 3 additions & 5 deletions src/pages/iou/request/step/IOURequestStepConfirmation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ function IOURequestStepConfirmation({

const odometerStartImage = transaction?.comment?.odometerStartImage;
const odometerEndImage = transaction?.comment?.odometerEndImage;
useRestartOnOdometerImagesFailure(isOdometerDistanceRequest ? transaction : undefined, reportID, iouType, backToReport);
const {hasVerifiedBlobs} = useRestartOnOdometerImagesFailure(isOdometerDistanceRequest ? transaction : undefined, reportID, iouType, backToReport);

// Pre-insert Search is only useful for flows whose submit ends in handleNavigateAfterExpenseCreate
// (which navigates to Search). Flows that use dismissModalAndOpenReportInInboxTab (PAY,
Expand Down Expand Up @@ -553,9 +553,7 @@ function IOURequestStepConfirmation({
odometerStartImage={odometerStartImage}
odometerEndImage={odometerEndImage}
transaction={transaction}
reportID={reportID}
backToReport={backToReport}
iouType={iouType}
hasVerifiedBlobs={hasVerifiedBlobs}
onStitchingChange={setIsStitchingReceipt}
onStitchError={setStitchError}
/>
Expand Down Expand Up @@ -648,7 +646,7 @@ function IOURequestStepConfirmation({
isPolicyExpenseChat={isPolicyExpenseChat}
policyID={policyID}
isOdometerDistanceRequest={isOdometerDistanceRequest}
isLoadingReceipt={isStitchingReceipt}
isLoadingReceipt={isStitchingReceipt || (isOdometerDistanceRequest && !hasVerifiedBlobs)}
isPerDiemRequest={isPerDiemRequest}
shouldShowSmartScanFields={shouldShowSmartScanFields}
action={action}
Expand Down
106 changes: 34 additions & 72 deletions src/pages/iou/request/step/confirmation/OdometerReceiptStitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,12 @@ import {useIsFocused} from '@react-navigation/native';
import {useEffect, useRef} from 'react';
import type {OnyxEntry} from 'react-native-onyx';
import useLocalize from '@hooks/useLocalize';
import clearOdometerDraftTransactionState from '@libs/actions/OdometerTransactionUtils';
import {navigateToStartMoneyRequestStep} from '@libs/IOUUtils';
import Log from '@libs/Log';
import {getOdometerImageName, getOdometerImageType, getOdometerImageUri} from '@libs/OdometerImageUtils';
import stitchOdometerImages from '@libs/stitchOdometerImages';
import {cancelSpan, endSpan, startSpan} from '@libs/telemetry/activeSpans';
import {checkIfLocalFileIsAccessible, setMoneyRequestReceipt} from '@userActions/IOU/Receipt';
import {setMoneyRequestReceipt} from '@userActions/IOU/Receipt';
import CONST from '@src/CONST';
import type {IOUType} from '@src/CONST';
import type {Transaction} from '@src/types/onyx';
import type {FileObject} from '@src/types/utils/Attachment';

Expand All @@ -19,9 +16,7 @@ type OdometerReceiptStitcherProps = {
odometerStartImage: FileObject | string | null | undefined;
odometerEndImage: FileObject | string | null | undefined;
transaction: OnyxEntry<Transaction>;
reportID: string;
backToReport: string | undefined;
iouType: IOUType;
hasVerifiedBlobs: boolean;
onStitchingChange: (isStitching: boolean) => void;
onStitchError: (error: string) => void;
};
Expand All @@ -37,9 +32,7 @@ function OdometerReceiptStitcher({
odometerStartImage,
odometerEndImage,
transaction,
reportID,
backToReport,
iouType,
hasVerifiedBlobs,
onStitchingChange,
onStitchError,
}: OdometerReceiptStitcherProps) {
Expand All @@ -51,7 +44,10 @@ function OdometerReceiptStitcher({
} | null>(null);

useEffect(() => {
if (!isOdometerDistanceRequest || !isFocused || !transaction) {
// Wait until useRestartOnOdometerImagesFailure has confirmed the blob URLs are still
// readable. Stitching a dead blob after a browser refresh would race with that hook's
// redirect and leave the UI stuck on the E screen
if (!isOdometerDistanceRequest || !isFocused || !transaction || !hasVerifiedBlobs) {
return;
Comment thread
jakubkalinski0 marked this conversation as resolved.
}

Expand Down Expand Up @@ -83,76 +79,42 @@ function OdometerReceiptStitcher({
onStitchingChange(true);
onStitchError('');

const runStitch = () => {
startSpan(CONST.TELEMETRY.SPAN_ODOMETER_IMAGE_STITCH, {
name: CONST.TELEMETRY.SPAN_ODOMETER_IMAGE_STITCH,
op: CONST.TELEMETRY.SPAN_ODOMETER_IMAGE_STITCH,
});
startSpan(CONST.TELEMETRY.SPAN_ODOMETER_IMAGE_STITCH, {
name: CONST.TELEMETRY.SPAN_ODOMETER_IMAGE_STITCH,
op: CONST.TELEMETRY.SPAN_ODOMETER_IMAGE_STITCH,
});

stitchOdometerImages(odometerStartImage, odometerEndImage)
.then((stitchedImage) => {
if (ignore || !stitchedImage) {
cancelSpan(CONST.TELEMETRY.SPAN_ODOMETER_IMAGE_STITCH);
return;
}
setMoneyRequestReceipt(transaction.transactionID, getOdometerImageUri(stitchedImage), getOdometerImageName(stitchedImage), true, getOdometerImageType(stitchedImage));
lastStitchedImages.current = {startImage: odometerStartImage, endImage: odometerEndImage};
endSpan(CONST.TELEMETRY.SPAN_ODOMETER_IMAGE_STITCH);
})
.catch((error: unknown) => {
stitchOdometerImages(odometerStartImage, odometerEndImage)
.then((stitchedImage) => {
if (ignore || !stitchedImage) {
cancelSpan(CONST.TELEMETRY.SPAN_ODOMETER_IMAGE_STITCH);
if (ignore) {
return;
}
Log.warn('stitchOdometerImages failed', {error});
onStitchError(translate('iou.error.stitchOdometerImagesFailed'));
})
.finally(() => {
if (ignore) {
return;
}
onStitchingChange(false);
});
};

// Pre-flight: verify blob URLs haven't expired before attempting to stitch.
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:'));

let hasExpiredImages = false;
Promise.all(
localImages.map(({uri, image}) =>
checkIfLocalFileIsAccessible(
getOdometerImageName(image),
uri,
typeof image === 'object' ? image?.type : undefined,
() => {},
() => {
hasExpiredImages = true;
},
),
),
).then(() => {
if (ignore) {
return;
}
if (hasExpiredImages) {
return;
}
setMoneyRequestReceipt(transaction.transactionID, getOdometerImageUri(stitchedImage), getOdometerImageName(stitchedImage), true, getOdometerImageType(stitchedImage));
lastStitchedImages.current = {startImage: odometerStartImage, endImage: odometerEndImage};
endSpan(CONST.TELEMETRY.SPAN_ODOMETER_IMAGE_STITCH);
})
.catch((error: unknown) => {
cancelSpan(CONST.TELEMETRY.SPAN_ODOMETER_IMAGE_STITCH);
if (ignore) {
return;
}
Log.warn('stitchOdometerImages failed', {error});
onStitchError(translate('iou.error.stitchOdometerImagesFailed'));
})
.finally(() => {
if (ignore) {
return;
}
onStitchingChange(false);
clearOdometerDraftTransactionState(transaction);
navigateToStartMoneyRequestStep(CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER, iouType, transaction.transactionID, reportID, CONST.IOU.ACTION.CREATE, backToReport);
return;
}
runStitch();
});
});

return () => {
ignore = true;
onStitchingChange(false);
cancelSpan(CONST.TELEMETRY.SPAN_ODOMETER_IMAGE_STITCH);
};
}, [isOdometerDistanceRequest, isFocused, odometerStartImage, odometerEndImage, transaction, reportID, backToReport, translate, iouType, onStitchingChange, onStitchError]);
}, [isOdometerDistanceRequest, isFocused, odometerStartImage, odometerEndImage, transaction, hasVerifiedBlobs, translate, onStitchingChange, onStitchError]);

return null;
}
Expand Down
Loading