From c0ce58a8b621664ffe2ff65958c8948bc2e68fb7 Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 8 Sep 2026 13:51:15 +0200 Subject: [PATCH 01/23] Improve scanned receipt quality without slowing the shutter --- src/libs/ReceiptStorage/index.native.ts | 70 +++++- src/libs/ReceiptStorage/index.ts | 3 + src/libs/ReceiptStorage/types.ts | 10 + .../components/Camera/index.native.tsx | 24 ++- .../components/Camera/types.ts | 6 + .../components/CameraViewport.tsx | 9 +- .../NavigationAwareCamera/Camera.tsx | 7 +- .../components/NavigationAwareCamera/types.ts | 1 + .../components/ScanSkipConfirmation.tsx | 3 + .../hooks/useStillPhotoUpgrade.ts | 175 +++++++++++++++ .../utils/rotateStillToUpright.ts | 77 +++++++ tests/unit/hooks/useStillPhotoUpgrade.test.ts | 204 ++++++++++++++++++ tests/unit/libs/receiptStorageTest.ts | 99 +++++++++ tests/unit/rotateStillToUprightTest.ts | 30 +++ 14 files changed, 712 insertions(+), 6 deletions(-) create mode 100644 src/pages/iou/request/step/IOURequestStepScan/hooks/useStillPhotoUpgrade.ts create mode 100644 src/pages/iou/request/step/IOURequestStepScan/utils/rotateStillToUpright.ts create mode 100644 tests/unit/hooks/useStillPhotoUpgrade.test.ts create mode 100644 tests/unit/rotateStillToUprightTest.ts diff --git a/src/libs/ReceiptStorage/index.native.ts b/src/libs/ReceiptStorage/index.native.ts index 5d283b6f902c..34904bb5e54f 100644 --- a/src/libs/ReceiptStorage/index.native.ts +++ b/src/libs/ReceiptStorage/index.native.ts @@ -1,6 +1,7 @@ import {cleanFileName, isLocalFile} from '@libs/fileDownload/FileUtils'; import fileURIToPath from '@libs/fileURIToPath'; import getReceiptsUploadFolderPath from '@libs/getReceiptsUploadFolderPath'; +import Log from '@libs/Log'; import {rand64} from '@libs/NumberUtils'; import RNFS from 'react-native-fs'; @@ -48,6 +49,73 @@ const adopt: ReceiptStorage['adopt'] = async (uriOrPath, fileName) => { return verify(dir, uniqueName); }; +const discard: ReceiptStorage['discard'] = async (uriOrPath) => { + const path = fileURIToPath(uriOrPath); + if (!(await RNFS.exists(path))) { + return; + } + await RNFS.unlink(path); +}; + +/** + * Deletes a file left over by a swap. Never rethrows, since the receipt is already in its final state and + * a stale copy beside it is not worth failing over. + */ +async function discardLeftover(path: string) { + try { + await discard(path); + } catch (error) { + Log.warn('[ReceiptStorage] could not delete a leftover receipt copy', {path, error: error instanceof Error ? error.message : String(error)}); + } +} + +const replace: ReceiptStorage['replace'] = async (durableName, uriOrPath) => { + const dir = getReceiptsUploadFolderPath(); + if (!dir) { + throw new Error('[ReceiptStorage] no receipts folder on this platform'); + } + + await verify(dir, durableName); + const target = `${dir}/${durableName}`; + + // Neither platform can move a file onto one that already exists. NSFileManager refuses and Android's + // `renameTo` is unreliable, so stage the new bytes beside the receipt, then swap them in with two + // renames inside the directory. The original keeps a second name until the swap succeeds. + const stagedPath = `${target}.staged`; + const backupPath = `${target}.backup`; + // An attempt that died mid-swap would leave these occupied, and iOS refuses an occupied destination. + await discard(stagedPath); + await discard(backupPath); + await RNFS.moveFile(fileURIToPath(uriOrPath), stagedPath); + + try { + await RNFS.moveFile(target, backupPath); + await RNFS.moveFile(stagedPath, target); + } catch (error) { + // A full disk is the likeliest reason the swap failed, so free the staged bytes before restoring. + await discardLeftover(stagedPath); + + if (await RNFS.exists(target)) { + throw error; + } + + // The receipt exists only under the backup name now, so a failed move back is what loses it. + try { + await RNFS.moveFile(backupPath, target); + } catch (restoreError) { + const reason = restoreError instanceof Error ? restoreError.message : String(restoreError); + throw new Error(`[ReceiptStorage] could not restore the receipt, it is left at ${backupPath}: ${reason}`); + } + + throw error; + } + + // The receipt is in place, so the copy set aside can go. + await discardLeftover(backupPath); + + return verify(dir, durableName); +}; + const toLocalUri: ReceiptStorage['toLocalUri'] = (durableName) => `file://${getReceiptsUploadFolderPath()}/${durableName}`; /** @@ -75,6 +143,6 @@ const resolve: ReceiptStorage['resolve'] = (source) => { return durableName ? toLocalUri(durableName) : source; }; -const receiptStorage: ReceiptStorage = {adopt, toLocalUri, resolve}; +const receiptStorage: ReceiptStorage = {adopt, replace, discard, toLocalUri, resolve}; export default receiptStorage; diff --git a/src/libs/ReceiptStorage/index.ts b/src/libs/ReceiptStorage/index.ts index caa760269fa3..c42b7489a617 100644 --- a/src/libs/ReceiptStorage/index.ts +++ b/src/libs/ReceiptStorage/index.ts @@ -3,6 +3,9 @@ import type ReceiptStorage from './types'; /** Web has no filesystem to move receipts into, and a blob URL already lives as long as the document. */ const receiptStorage: ReceiptStorage = { adopt: (uriOrPath) => Promise.resolve(uriOrPath), + // Web captures through a canvas at full resolution, so there is nothing to swap or clean up. + replace: (durableName) => Promise.resolve(durableName), + discard: () => Promise.resolve(), toLocalUri: (durableName) => durableName, resolve: (source) => (typeof source === 'string' ? source : undefined), }; diff --git a/src/libs/ReceiptStorage/types.ts b/src/libs/ReceiptStorage/types.ts index 217fbadca8b0..13cd77f75adf 100644 --- a/src/libs/ReceiptStorage/types.ts +++ b/src/libs/ReceiptStorage/types.ts @@ -5,6 +5,16 @@ type ReceiptStorage = { /** Moves a file into the receipts folder and returns its durable name. Rejects when the file did not land. */ adopt: (uriOrPath: string, fileName?: string) => Promise; + /** + * Swaps the bytes behind an adopted receipt, keeping its durable name so the confirmation preview, the + * upload and replace-receipt all read the new file. Rejects if the receipt is not in durable storage or + * the swap fails, leaving the original in place. + */ + replace: (durableName: string, uriOrPath: string) => Promise; + + /** Deletes a temporary file the app is done with. Resolves even when the file is already gone. */ + discard: (uriOrPath: string) => Promise; + /** Valid for this launch only, so never store the result. */ toLocalUri: (durableName: string) => string; diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx index 0f43073ebf27..149df2116c62 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx @@ -22,6 +22,7 @@ import ReceiptPreviews from '@pages/iou/request/step/IOURequestStepScan/componen import ScannerControlsBar from '@pages/iou/request/step/IOURequestStepScan/components/ScannerControlsBar'; import getCameraAspectRatio from '@pages/iou/request/step/IOURequestStepScan/getCameraAspectRatio'; import useCameraInitTelemetry from '@pages/iou/request/step/IOURequestStepScan/hooks/useCameraInitTelemetry'; +import useStillPhotoUpgrade from '@pages/iou/request/step/IOURequestStepScan/hooks/useStillPhotoUpgrade'; import startReceiptPrepareSpan from '@pages/iou/request/step/IOURequestStepScan/utils/startReceiptPrepareSpan'; import CONST from '@src/CONST'; @@ -44,7 +45,7 @@ const BLINK_DURATION_MS = 80; * Renders a react-native-vision-camera viewfinder with shutter, flash toggle, gallery picker, and focus gesture. * Calls `onCapture(file, source)` for each photo taken or file picked from the gallery. */ -function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayout, onAttachmentPickerStatusChange, onMultiScanSubmit}: CameraProps) { +function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayout, onAttachmentPickerStatusChange, onMultiScanSubmit, canUpgradeReceiptQuality = true}: CameraProps) { const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -112,6 +113,7 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou }; const {handleCameraInitialized} = useCameraInitTelemetry({cameraPermissionStatus, device}); + const {hasPendingStillCapture, captureStill, upgradeReceiptWithStill, discardPendingStill} = useStillPhotoUpgrade(); const maybeCancelShutterSpan = () => { if (isMultiScanEnabled) { @@ -171,7 +173,19 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou const path = getReceiptsUploadFolderPath(); - captureReceipt(camera.current, {flash, hasFlash, isPlatformMuted, path, isInLandscapeMode}) + // `takeSnapshot` saves a screen-sized screenshot of the preview, so on that path a full-resolution + // `takePhoto` runs alongside it and replaces the receipt file once it lands. Nothing below awaits it. + const shouldUpgradeToStill = canUpgradeReceiptQuality && !isMultiScanEnabled && !shouldTakePhoto({flash, hasFlash, isInLandscapeMode}); + + // The snapshot goes first so its request reaches the native queue ahead of the still. On iOS it + // reads the most recent video frame, which a photo capture can interrupt. + const receiptCapture = captureReceipt(camera.current, {flash, hasFlash, isPlatformMuted, path, isInLandscapeMode}); + + if (shouldUpgradeToStill) { + captureStill(camera.current); + } + + receiptCapture .then((photo: PhotoFile) => { endSpanWithAttributes(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE, { [CONST.TELEMETRY.ATTRIBUTE_PHOTO_WIDTH]: photo.width, @@ -197,12 +211,17 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou }; onCapture(cameraFile, source); + + if (shouldUpgradeToStill) { + upgradeReceiptWithStill(durableName); + } }) .catch((error: string) => { isCapturingPhoto.current = false; maybeCancelShutterSpan(); showCameraAlert(); Log.warn('Error taking photo', error); + discardPendingStill(); }); }; @@ -246,6 +265,7 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou blinkStyle={blinkStyle} isAttachmentPickerActive={isAttachmentPickerActive} didCapturePhoto={didCapturePhoto} + hasPendingStillCapture={hasPendingStillCapture} onInitialized={handleCameraInitialized} canUseMultiScan={canUseMultiScan} cameraPermissionStatus={cameraPermissionStatus} diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/types.ts b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/types.ts index 4bfae6924dfc..f76b8f3c787b 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/types.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/types.ts @@ -25,6 +25,12 @@ type CameraProps = { /** Called when the user taps the multi-scan submit button on the preview ribbon. Used by native and mobile web. */ onMultiScanSubmit?: () => void; + + /** + * Whether a receipt captured here may be swapped for the full-resolution still that lands a moment + * later. Screens that submit at capture time opt out, since the upload can read the file mid-swap. + */ + canUpgradeReceiptQuality?: boolean; }; export default CameraProps; diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx index f74d9389dc54..3202ba82945e 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx @@ -60,6 +60,9 @@ type CameraViewportProps = { /** Whether a photo has been captured (forces camera inactive) */ didCapturePhoto: boolean; + /** Whether a full-resolution still capture is in flight, which keeps the camera session alive */ + hasPendingStillCapture?: boolean; + /** Callback fired when the camera finishes initializing */ onInitialized: () => void; @@ -91,6 +94,7 @@ function CameraViewport({ blinkStyle, isAttachmentPickerActive, didCapturePhoto, + hasPendingStillCapture = false, onInitialized, canUseMultiScan, cameraPermissionStatus, @@ -117,7 +121,10 @@ function CameraViewport({ zoom={device.neutralZoom} photo cameraTabIndex={1} - forceInactive={isAttachmentPickerActive || didCapturePhoto} + // Closing the session cancels an in-flight `takePhoto` with "Camera is closed.", so a + // running capture keeps it open. This flag and losing focus would both close it. + forceInactive={isAttachmentPickerActive || (didCapturePhoto && !hasPendingStillCapture)} + shouldStayActiveWhenBlurred={hasPendingStillCapture} onInitialized={onInitialized} // Use TextureView on Android to fix partially blank images for takeSnapshot() androidPreviewViewType="texture-view" diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/NavigationAwareCamera/Camera.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/NavigationAwareCamera/Camera.tsx index 1c32134f7e9a..5f89c563823f 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/NavigationAwareCamera/Camera.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/NavigationAwareCamera/Camera.tsx @@ -10,9 +10,12 @@ import type {NavigationAwareCameraNativeProps} from './types'; const IS_VIDEO_REQUIRED_FOR_SNAPSHOT = Platform.OS === 'ios'; // Wraps a camera that will only be active when the tab is focused or as soon as it starts to become focused. -function Camera({cameraTabIndex, ref, forceInactive = false, ...props}: NavigationAwareCameraNativeProps) { +function Camera({cameraTabIndex, ref, forceInactive = false, shouldStayActiveWhenBlurred = false, ...props}: NavigationAwareCameraNativeProps) { const isFocused = useIsFocused(); - const isCameraActive = isFocused && !forceInactive; + + // The scan screen blurs as soon as a capture navigates away, and closing the session would cancel a + // capture that is still running. + const isCameraActive = (isFocused || shouldStayActiveWhenBlurred) && !forceInactive; return ( & { cameraTabIndex: number; ref?: ForwardedRef; forceInactive?: boolean; + shouldStayActiveWhenBlurred?: boolean; }; export type {NavigationAwareCameraProps, NavigationAwareCameraNativeProps}; diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx index 54e912f067df..8c201e590180 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx @@ -446,6 +446,9 @@ function ScanSkipConfirmation({report, action, iouType, reportID, transactionID, onAttachmentPickerStatusChange={setIsLoaderVisible} onMultiScanSubmit={submitMultiScan} shouldAcceptMultipleFiles + // This screen submits at capture time, so the upload can read the receipt file while a + // higher-resolution one is being swapped in. + canUpgradeReceiptQuality={false} /> { + Log.warn('[StillPhotoUpgrade] could not delete the abandoned still', {error: error instanceof Error ? error.message : String(error)}); + }); +} + +/** + * Deletes the still once the pending capture produces one. Callers set `isDiscarded` first, so a still + * that lands before this runs is deleted by the capture handler instead. + */ +function discardWhenItLands(pending: PendingStill) { + pending.promise + .then((photo) => { + if (!photo) { + return; + } + discardStill(photo.path); + }) + .catch(() => { + // `promise` resolves instead of rejecting, so there is nothing to clean up here. + }); +} + +type PendingStill = { + /** Resolves with the temporary still, or `undefined` when the capture failed or missed the deadline. */ + promise: Promise; + + /** Set once the still has no consumer, so anything that lands afterwards is deleted. */ + isDiscarded: boolean; + + /** Set once the still landed or the deadline passed, so the next shutter press may start another one. */ + isSettled: boolean; +}; + +/** + * Upgrades a receipt captured with `takeSnapshot`, a screen-sized screenshot of the preview, to the + * full-resolution `takePhoto` still. + * + * `captureStill` starts the still at the shutter and nothing awaits it, so the snapshot keeps driving + * navigation, and `upgradeReceiptWithStill` swaps the still onto that receipt in place under the same + * durable name. `hasPendingStillCapture` holds the camera session open, since closing it during a capture + * cancels the still with "Camera is closed." + */ +function useStillPhotoUpgrade() { + const [hasPendingStillCapture, setHasPendingStillCapture] = useState(false); + const pendingStillRef = useRef(undefined); + + const captureStill = (camera: Camera) => { + if (pendingStillRef.current && !pendingStillRef.current.isSettled) { + return; + } + + const pending: PendingStill = {isDiscarded: false, isSettled: false, promise: Promise.resolve(undefined)}; + + // The still goes to the temp directory, not the receipts folder, so ReceiptStorage stays the only + // writer there and an abandoned still never sits among the receipts looking like one. + const capture = camera.takePhoto({flash: 'off', enableShutterSound: false, path: RNFS.TemporaryDirectoryPath}).then( + (photo) => { + if (!pending.isDiscarded) { + return photo; + } + // This one has no consumer, so it does not stay on disk. + discardStill(photo.path); + return undefined; + }, + (error: unknown) => { + Log.warn('[StillPhotoUpgrade] full-resolution capture failed', {error: error instanceof Error ? error.message : String(error)}); + return undefined; + }, + ); + + // The deadline outlives the screen on purpose, since the swap finishes after the scan page is gone + // and a late arrival still needs deleting. + let deadlineTimeout: ReturnType; + const deadline = new Promise((resolve) => { + deadlineTimeout = setTimeout(() => { + pending.isDiscarded = true; + resolve(undefined); + }, STILL_CAPTURE_TIMEOUT_MS); + }); + + // The session is released when the race settles, not when the swap finishes. Once the still is on + // disk the camera is free, and the rotate and replace steps run without it. + pending.promise = Promise.race([capture, deadline]).then((photo) => { + clearTimeout(deadlineTimeout); + pending.isSettled = true; + setHasPendingStillCapture(false); + + if (!photo) { + Log.info('[StillPhotoUpgrade] no still to upgrade with, keeping the snapshot'); + return undefined; + } + + return photo; + }); + + pendingStillRef.current = pending; + setHasPendingStillCapture(true); + }; + + const upgradeReceiptWithStill = (durableName: string) => { + const pending = pendingStillRef.current; + if (!pending) { + return; + } + pendingStillRef.current = undefined; + + pending.promise + .then((photo) => { + if (!photo) { + return undefined; + } + + return rotateStillToUpright(photo.path).then((uprightPath) => + ReceiptStorage.replace(durableName, uprightPath ?? photo.path).then(() => { + Log.info('[StillPhotoUpgrade] receipt upgraded to the full-resolution still', false, { + width: photo.width, + height: photo.height, + wasRotated: !!uprightPath, + }); + + if (!uprightPath) { + return; + } + // `replace` moved the rotated copy, so the original is left behind, and its cleanup + // must not fail an upgrade that already went through. + discardStill(photo.path); + }), + ); + }) + .catch((error: unknown) => { + Log.warn('[StillPhotoUpgrade] keeping the snapshot, upgrade failed', {error: error instanceof Error ? error.message : String(error)}); + pending.isDiscarded = true; + discardWhenItLands(pending); + }); + }; + + const discardPendingStill = () => { + const pending = pendingStillRef.current; + if (!pending) { + return; + } + pendingStillRef.current = undefined; + pending.isDiscarded = true; + discardWhenItLands(pending); + }; + + return {hasPendingStillCapture, captureStill, upgradeReceiptWithStill, discardPendingStill}; +} + +export default useStillPhotoUpgrade; diff --git a/src/pages/iou/request/step/IOURequestStepScan/utils/rotateStillToUpright.ts b/src/pages/iou/request/step/IOURequestStepScan/utils/rotateStillToUpright.ts new file mode 100644 index 000000000000..06f2467d1d99 --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepScan/utils/rotateStillToUpright.ts @@ -0,0 +1,77 @@ +import {JPEG_QUALITY} from '@libs/fileDownload/FileUtils'; +import getPhotoSource from '@libs/fileDownload/getPhotoSource'; + +import {ImageManipulator, SaveFormat} from 'expo-image-manipulator'; +import ImageSize from 'react-native-image-size'; + +/** + * Turns a full-resolution camera still upright before it replaces a scanned receipt. + */ + +type StillSize = { + /** Pixel width of the still as it sits on disk */ + width: number; + + /** Pixel height of the still as it sits on disk */ + height: number; + + /** Clockwise degrees the file's metadata asks for, which only Android reports */ + rotation?: number; +}; + +/** + * Clockwise degrees to turn the still by, or `undefined` to keep the file as captured. + * + * Measure the frame the way the image loader hands it over, since the loader applies the file's rotation + * metadata while decoding. Verified on device: a 1920x1440 still whose metadata asks for 90 decodes as + * 1440x1920, so rotating it again put it back on its side. + * + * A returned `0` is not a no-op. Re-encoding bakes the rotation into the pixels and drops the tag, which + * the confirmation preview and the receipt on the server both ignore. + */ +function getUprightRotation({width, height, rotation = 0}: StillSize): number | undefined { + const isSideways = rotation === 90 || rotation === 270; + const decodedWidth = isSideways ? height : width; + const decodedHeight = isSideways ? width : height; + + // This upgrade only runs on the portrait `takeSnapshot` path, since landscape captures go through + // `takePhoto`. So a frame that decodes landscape has no rotation recorded and needs the sensor's turn. + const angle = decodedWidth > decodedHeight ? 90 : 0; + + if (!angle && !rotation) { + // Upright already, with nothing in metadata to bake in, so re-encoding would only lose quality. + return undefined; + } + + return angle; +} + +/** + * Bakes the rotation of a full-resolution camera still into its pixels. Rejects instead of falling back to + * the still as captured, since a receipt lying on its side is worse than the snapshot the caller has. + * + * @returns path of an upright temporary copy, or `undefined` when the still needs no turn + */ +function rotateStillToUpright(stillPath: string): Promise { + const sourceUri = getPhotoSource(stillPath); + + return ImageSize.getSize(sourceUri).then((imageSize) => { + const angle = getUprightRotation(imageSize); + if (angle === undefined) { + return undefined; + } + + const context = ImageManipulator.manipulate(sourceUri); + if (angle) { + context.rotate(angle); + } + + return context + .renderAsync() + .then((image) => image.saveAsync({compress: JPEG_QUALITY, format: SaveFormat.JPEG})) + .then((result) => result.uri); + }); +} + +export default rotateStillToUpright; +export {getUprightRotation}; diff --git a/tests/unit/hooks/useStillPhotoUpgrade.test.ts b/tests/unit/hooks/useStillPhotoUpgrade.test.ts new file mode 100644 index 000000000000..9d92e2ed169e --- /dev/null +++ b/tests/unit/hooks/useStillPhotoUpgrade.test.ts @@ -0,0 +1,204 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import useStillPhotoUpgrade from '@pages/iou/request/step/IOURequestStepScan/hooks/useStillPhotoUpgrade'; + +import type {Camera, PhotoFile} from 'react-native-vision-camera'; + +const mockReplace = jest.fn, [string, string]>(); +const mockDiscard = jest.fn, [string]>(); + +jest.mock('@libs/ReceiptStorage', () => ({ + __esModule: true, + default: { + replace: (durableName: string, path: string) => mockReplace(durableName, path), + discard: (path: string) => mockDiscard(path), + }, +})); + +const mockRotate = jest.fn, [string]>(); + +jest.mock('@pages/iou/request/step/IOURequestStepScan/utils/rotateStillToUpright', () => ({ + __esModule: true, + default: (path: string) => mockRotate(path), +})); + +jest.mock('react-native-fs', () => ({TemporaryDirectoryPath: '/tmp'})); + +const DURABLE_NAME = 'receipt_1234.jpg'; +const STILL_PATH = '/tmp/still.jpg'; +const UPRIGHT_PATH = '/tmp/ImageManipulator/upright.jpg'; +const STILL_CAPTURE_TIMEOUT_MS = 3000; + +function buildStill(path = STILL_PATH): PhotoFile { + return {path, width: 1920, height: 1440, isRawPhoto: false, orientation: 'portrait', isMirrored: false}; +} + +/** A camera whose capture the test resolves or rejects on demand. */ +function buildCamera() { + let settle: (photo: PhotoFile) => void = () => {}; + let fail: (error: Error) => void = () => {}; + const takePhoto = jest.fn( + () => + new Promise((resolve, reject) => { + settle = resolve; + fail = reject; + }), + ); + + return { + // The hook only ever calls `takePhoto`, so a stub of the one method is all the test needs. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + camera: {takePhoto} as unknown as Camera, + takePhoto, + landStill: async (photo = buildStill()) => { + await act(async () => { + settle(photo); + }); + }, + failCapture: async () => { + await act(async () => { + fail(new Error('Camera is closed.')); + }); + }, + }; +} + +describe('useStillPhotoUpgrade', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + mockReplace.mockResolvedValue(DURABLE_NAME); + mockDiscard.mockResolvedValue(undefined); + mockRotate.mockResolvedValue(UPRIGHT_PATH); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('holds the camera session open from the shutter until the still lands', async () => { + const {camera, landStill} = buildCamera(); + const {result} = renderHook(() => useStillPhotoUpgrade()); + + expect(result.current.hasPendingStillCapture).toBe(false); + + act(() => { + result.current.captureStill(camera); + }); + expect(result.current.hasPendingStillCapture).toBe(true); + + await landStill(); + expect(result.current.hasPendingStillCapture).toBe(false); + }); + + it('swaps the upright still onto the receipt and cleans up the file it rotated from', async () => { + const {camera, landStill} = buildCamera(); + const {result} = renderHook(() => useStillPhotoUpgrade()); + + act(() => { + result.current.captureStill(camera); + }); + act(() => { + result.current.upgradeReceiptWithStill(DURABLE_NAME); + }); + await landStill(); + + expect(mockRotate).toHaveBeenCalledWith(STILL_PATH); + expect(mockReplace).toHaveBeenCalledWith(DURABLE_NAME, UPRIGHT_PATH); + expect(mockDiscard).toHaveBeenCalledWith(STILL_PATH); + }); + + it('swaps the still as captured when it needs no rotation, leaving nothing to clean up', async () => { + mockRotate.mockResolvedValue(undefined); + const {camera, landStill} = buildCamera(); + const {result} = renderHook(() => useStillPhotoUpgrade()); + + act(() => { + result.current.captureStill(camera); + }); + act(() => { + result.current.upgradeReceiptWithStill(DURABLE_NAME); + }); + await landStill(); + + expect(mockReplace).toHaveBeenCalledWith(DURABLE_NAME, STILL_PATH); + expect(mockDiscard).not.toHaveBeenCalled(); + }); + + it('keeps the snapshot and releases the session when the still misses the deadline', async () => { + const {camera, landStill} = buildCamera(); + const {result} = renderHook(() => useStillPhotoUpgrade()); + + act(() => { + result.current.captureStill(camera); + }); + act(() => { + result.current.upgradeReceiptWithStill(DURABLE_NAME); + }); + + await act(async () => { + jest.advanceTimersByTime(STILL_CAPTURE_TIMEOUT_MS); + }); + + expect(result.current.hasPendingStillCapture).toBe(false); + expect(mockReplace).not.toHaveBeenCalled(); + + // A still that turns up after the deadline has no consumer, so it must not stay on disk. + await landStill(); + expect(mockDiscard).toHaveBeenCalledWith(STILL_PATH); + expect(mockReplace).not.toHaveBeenCalled(); + }); + + it('keeps the snapshot when the capture itself fails', async () => { + const {camera, failCapture} = buildCamera(); + const {result} = renderHook(() => useStillPhotoUpgrade()); + + act(() => { + result.current.captureStill(camera); + }); + act(() => { + result.current.upgradeReceiptWithStill(DURABLE_NAME); + }); + await failCapture(); + + expect(result.current.hasPendingStillCapture).toBe(false); + expect(mockReplace).not.toHaveBeenCalled(); + expect(mockDiscard).not.toHaveBeenCalled(); + }); + + it('deletes the still when the receipt it was meant for never arrived', async () => { + const {camera, landStill} = buildCamera(); + const {result} = renderHook(() => useStillPhotoUpgrade()); + + act(() => { + result.current.captureStill(camera); + }); + act(() => { + result.current.discardPendingStill(); + }); + await landStill(); + + expect(mockReplace).not.toHaveBeenCalled(); + expect(mockDiscard).toHaveBeenCalledWith(STILL_PATH); + }); + + it('ignores a second shutter press while a still is still in flight', async () => { + const {camera, takePhoto, landStill} = buildCamera(); + const {result} = renderHook(() => useStillPhotoUpgrade()); + + act(() => { + result.current.captureStill(camera); + }); + act(() => { + result.current.captureStill(camera); + }); + + expect(takePhoto).toHaveBeenCalledTimes(1); + + await landStill(); + act(() => { + result.current.captureStill(camera); + }); + expect(takePhoto).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/unit/libs/receiptStorageTest.ts b/tests/unit/libs/receiptStorageTest.ts index b02239c08497..b7173c7ebc32 100644 --- a/tests/unit/libs/receiptStorageTest.ts +++ b/tests/unit/libs/receiptStorageTest.ts @@ -3,11 +3,13 @@ import type ReceiptStorageType from '@libs/ReceiptStorage/types'; const mockExists = jest.fn, [string]>(); const mockMv = jest.fn, [string, string]>(); const mockMkdir = jest.fn, [string]>(); +const mockUnlink = jest.fn, [string]>(); jest.mock('react-native-fs', () => ({ exists: (path: string) => mockExists(path), moveFile: (from: string, to: string) => mockMv(from, to), mkdir: (path: string) => mockMkdir(path), + unlink: (path: string) => mockUnlink(path), })); jest.mock('@libs/NumberUtils', () => ({rand64: () => '1234'})); @@ -27,6 +29,7 @@ describe('ReceiptStorage', () => { mockExists.mockResolvedValue(true); mockMv.mockResolvedValue(undefined); mockMkdir.mockResolvedValue(undefined); + mockUnlink.mockResolvedValue(undefined); }); describe('adopt', () => { @@ -73,6 +76,102 @@ describe('ReceiptStorage', () => { }); }); + describe('replace', () => { + const RECEIPT = 'CAM-1.jpg'; + const STILL = '/var/mobile/tmp/still.jpg'; + + // Only the receipt is on disk, not the staged or backup paths a previous swap would have used. + const onlyTheReceiptExists = (path: string) => Promise.resolve(path === `${FOLDER}/${RECEIPT}`); + + it('swaps the bytes and hands back the same durable name, so every consumer of the receipt follows along', async () => { + mockExists.mockImplementation(onlyTheReceiptExists); + + const name = await ReceiptStorage.replace(RECEIPT, `file://${STILL}`); + + expect(name).toBe(RECEIPT); + expect(mockMv.mock.calls).toEqual([ + [STILL, `${FOLDER}/${RECEIPT}.staged`], + [`${FOLDER}/${RECEIPT}`, `${FOLDER}/${RECEIPT}.backup`], + [`${FOLDER}/${RECEIPT}.staged`, `${FOLDER}/${RECEIPT}`], + ]); + }); + + it('drops the file it moved aside once the swap went through, so a capture leaves one receipt behind', async () => { + mockExists.mockImplementation((path: string) => Promise.resolve(path === `${FOLDER}/${RECEIPT}` || path === `${FOLDER}/${RECEIPT}.backup`)); + + await ReceiptStorage.replace(RECEIPT, STILL); + + expect(mockUnlink).toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}.backup`); + }); + + it('puts the original receipt back when the swap fails, rather than leaving the receipt missing', async () => { + const existing = new Set([`${FOLDER}/${RECEIPT}`]); + mockExists.mockImplementation((path: string) => Promise.resolve(existing.has(path))); + mockMv.mockImplementation((from: string, to: string) => { + if (from === `${FOLDER}/${RECEIPT}.staged` && to === `${FOLDER}/${RECEIPT}`) { + return Promise.reject(new Error('no space left on device')); + } + existing.delete(from); + existing.add(to); + return Promise.resolve(); + }); + + await expect(ReceiptStorage.replace(RECEIPT, STILL)).rejects.toThrow('no space left on device'); + + expect(mockMv).toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}.backup`, `${FOLDER}/${RECEIPT}`); + expect(mockUnlink).toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}.staged`); + }); + + it('reports where the receipt was left when the swap fails and the restore fails too', async () => { + const existing = new Set([`${FOLDER}/${RECEIPT}`]); + mockExists.mockImplementation((path: string) => Promise.resolve(existing.has(path))); + mockMv.mockImplementation((from: string, to: string) => { + if (to === `${FOLDER}/${RECEIPT}`) { + return Promise.reject(new Error('no space left on device')); + } + existing.delete(from); + existing.add(to); + return Promise.resolve(); + }); + + await expect(ReceiptStorage.replace(RECEIPT, STILL)).rejects.toThrow(`it is left at ${FOLDER}/${RECEIPT}.backup`); + + // Freeing the staged copy gives a full disk room for the restore, so it happens even when the + // restore then fails. + expect(mockUnlink).toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}.staged`); + }); + + it('still reports success when only the housekeeping delete fails, because the receipt is already swapped', async () => { + mockExists.mockImplementation(onlyTheReceiptExists); + mockUnlink.mockRejectedValue(new Error('permission denied')); + + await expect(ReceiptStorage.replace(RECEIPT, STILL)).resolves.toBe(RECEIPT); + }); + + it('rejects without touching anything when the receipt is not in durable storage', async () => { + mockExists.mockResolvedValue(false); + + await expect(ReceiptStorage.replace(RECEIPT, STILL)).rejects.toThrow('not in durable storage'); + expect(mockMv).not.toHaveBeenCalled(); + }); + }); + + describe('discard', () => { + it('deletes a temporary file', async () => { + await ReceiptStorage.discard('file:///var/mobile/tmp/still.jpg'); + + expect(mockUnlink).toHaveBeenCalledWith('/var/mobile/tmp/still.jpg'); + }); + + it('resolves without deleting when the file is already gone', async () => { + mockExists.mockResolvedValue(false); + + await ReceiptStorage.discard('/var/mobile/tmp/still.jpg'); + + expect(mockUnlink).not.toHaveBeenCalled(); + }); + }); + describe('toLocalUri', () => { it('resolves against the folder as it is right now', () => { expect(ReceiptStorage.toLocalUri('receipt_1234.jpg')).toBe(`file://${FOLDER}/receipt_1234.jpg`); diff --git a/tests/unit/rotateStillToUprightTest.ts b/tests/unit/rotateStillToUprightTest.ts new file mode 100644 index 000000000000..4da1e340cdd4 --- /dev/null +++ b/tests/unit/rotateStillToUprightTest.ts @@ -0,0 +1,30 @@ +import {getUprightRotation} from '@pages/iou/request/step/IOURequestStepScan/utils/rotateStillToUpright'; + +describe('getUprightRotation', () => { + it('only re-encodes a still whose metadata already describes it upright, because the decoder applied that rotation', () => { + // Measured on a Galaxy S20 FE. 1920x1440 on disk with metadata asking for 90, so it decodes as + // 1440x1920 and turning it again would put it back on its side. + expect(getUprightRotation({width: 1920, height: 1440, rotation: 90})).toBe(0); + expect(getUprightRotation({width: 1920, height: 1440, rotation: 270})).toBe(0); + }); + + it('turns a landscape sensor buffer that carries no rotation of its own', () => { + expect(getUprightRotation({width: 1920, height: 1440, rotation: 0})).toBe(90); + expect(getUprightRotation({width: 1920, height: 1440, rotation: 180})).toBe(90); + }); + + it('leaves a still that is already upright with no metadata alone, rather than re-encoding it', () => { + // iOS, where react-native-image-size reports the displayed size and no rotation. + expect(getUprightRotation({width: 2160, height: 2880})).toBeUndefined(); + expect(getUprightRotation({width: 1440, height: 1920, rotation: 0})).toBeUndefined(); + }); + + it('re-encodes an upright still that still carries metadata, so consumers that ignore it see it upright too', () => { + expect(getUprightRotation({width: 1440, height: 1920, rotation: 180})).toBe(0); + expect(getUprightRotation({width: 1440, height: 1920, rotation: 90})).toBe(90); + }); + + it('leaves a square still with no metadata alone', () => { + expect(getUprightRotation({width: 1440, height: 1440, rotation: 0})).toBeUndefined(); + }); +}); From 858832d8f4358be63f908481596a7b5d82096854 Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 8 Sep 2026 17:43:55 +0200 Subject: [PATCH 02/23] address review comments --- .../components/ScanEditReceipt.tsx | 3 ++ .../hooks/useStillPhotoUpgrade.ts | 21 ++++++++----- tests/unit/hooks/useStillPhotoUpgrade.test.ts | 30 ++++++++++--------- 3 files changed, 32 insertions(+), 22 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/ScanEditReceipt.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/ScanEditReceipt.tsx index 66d551009d24..eef617d1b05e 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/ScanEditReceipt.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/ScanEditReceipt.tsx @@ -107,6 +107,9 @@ function ScanEditReceipt({report, transactionID, backTo, isEditing}: ScanEditRec onPicked={validateFiles} onAttachmentPickerStatusChange={setIsLoaderVisible} isReplacingReceipt + // While editing, the capture is uploaded through `replaceReceipt` right away, so the + // upload can read the receipt file while a higher-resolution one is being swapped in. + canUpgradeReceiptQuality={!isEditing} /> ); diff --git a/src/pages/iou/request/step/IOURequestStepScan/hooks/useStillPhotoUpgrade.ts b/src/pages/iou/request/step/IOURequestStepScan/hooks/useStillPhotoUpgrade.ts index b9c07a42814c..cb62c56ad9dd 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/hooks/useStillPhotoUpgrade.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/hooks/useStillPhotoUpgrade.ts @@ -51,9 +51,6 @@ type PendingStill = { /** Set once the still has no consumer, so anything that lands afterwards is deleted. */ isDiscarded: boolean; - - /** Set once the still landed or the deadline passed, so the next shutter press may start another one. */ - isSettled: boolean; }; /** @@ -69,12 +66,20 @@ function useStillPhotoUpgrade() { const [hasPendingStillCapture, setHasPendingStillCapture] = useState(false); const pendingStillRef = useRef(undefined); - const captureStill = (camera: Camera) => { - if (pendingStillRef.current && !pendingStillRef.current.isSettled) { + // Retaking a receipt can leave two captures in flight, so count them. With a single flag, the first + // capture to settle would close the session under the second one. + const captureCountRef = useRef(0); + + const releaseSession = () => { + captureCountRef.current -= 1; + if (captureCountRef.current > 0) { return; } + setHasPendingStillCapture(false); + }; - const pending: PendingStill = {isDiscarded: false, isSettled: false, promise: Promise.resolve(undefined)}; + const captureStill = (camera: Camera) => { + const pending: PendingStill = {isDiscarded: false, promise: Promise.resolve(undefined)}; // The still goes to the temp directory, not the receipts folder, so ReceiptStorage stays the only // writer there and an abandoned still never sits among the receipts looking like one. @@ -107,8 +112,7 @@ function useStillPhotoUpgrade() { // disk the camera is free, and the rotate and replace steps run without it. pending.promise = Promise.race([capture, deadline]).then((photo) => { clearTimeout(deadlineTimeout); - pending.isSettled = true; - setHasPendingStillCapture(false); + releaseSession(); if (!photo) { Log.info('[StillPhotoUpgrade] no still to upgrade with, keeping the snapshot'); @@ -119,6 +123,7 @@ function useStillPhotoUpgrade() { }); pendingStillRef.current = pending; + captureCountRef.current += 1; setHasPendingStillCapture(true); }; diff --git a/tests/unit/hooks/useStillPhotoUpgrade.test.ts b/tests/unit/hooks/useStillPhotoUpgrade.test.ts index 9d92e2ed169e..77ab6766f28e 100644 --- a/tests/unit/hooks/useStillPhotoUpgrade.test.ts +++ b/tests/unit/hooks/useStillPhotoUpgrade.test.ts @@ -33,15 +33,13 @@ function buildStill(path = STILL_PATH): PhotoFile { return {path, width: 1920, height: 1440, isRawPhoto: false, orientation: 'portrait', isMirrored: false}; } -/** A camera whose capture the test resolves or rejects on demand. */ +/** A camera whose captures the test resolves or rejects on demand, in the order they were started. */ function buildCamera() { - let settle: (photo: PhotoFile) => void = () => {}; - let fail: (error: Error) => void = () => {}; + const started: Array<{resolve: (photo: PhotoFile) => void; reject: (error: Error) => void}> = []; const takePhoto = jest.fn( () => new Promise((resolve, reject) => { - settle = resolve; - fail = reject; + started.push({resolve, reject}); }), ); @@ -50,14 +48,14 @@ function buildCamera() { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion camera: {takePhoto} as unknown as Camera, takePhoto, - landStill: async (photo = buildStill()) => { + landStill: async (photo = buildStill(), index = 0) => { await act(async () => { - settle(photo); + started.at(index)?.resolve(photo); }); }, - failCapture: async () => { + failCapture: async (index = 0) => { await act(async () => { - fail(new Error('Camera is closed.')); + started.at(index)?.reject(new Error('Camera is closed.')); }); }, }; @@ -182,7 +180,7 @@ describe('useStillPhotoUpgrade', () => { expect(mockDiscard).toHaveBeenCalledWith(STILL_PATH); }); - it('ignores a second shutter press while a still is still in flight', async () => { + it('keeps the session open for a retake when the first still settles after it started', async () => { const {camera, takePhoto, landStill} = buildCamera(); const {result} = renderHook(() => useStillPhotoUpgrade()); @@ -190,15 +188,19 @@ describe('useStillPhotoUpgrade', () => { result.current.captureStill(camera); }); act(() => { - result.current.captureStill(camera); + result.current.upgradeReceiptWithStill(DURABLE_NAME); }); - expect(takePhoto).toHaveBeenCalledTimes(1); - - await landStill(); + // The user came back to the camera and shot again before the first still landed. act(() => { result.current.captureStill(camera); }); expect(takePhoto).toHaveBeenCalledTimes(2); + + await landStill(buildStill(), 0); + expect(result.current.hasPendingStillCapture).toBe(true); + + await landStill(buildStill('/tmp/still2.jpg'), 1); + expect(result.current.hasPendingStillCapture).toBe(false); }); }); From 95ad1748f0cc4be9eb83d01392b963ff70559f77 Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 8 Sep 2026 19:26:37 +0200 Subject: [PATCH 03/23] restore a receipt left behind by an interrupted swap --- src/libs/ReceiptStorage/index.native.ts | 57 +++++++++++++++++-- src/libs/ReceiptStorage/index.ts | 1 + src/libs/ReceiptStorage/types.ts | 6 ++ .../prepareRequestPayload/index.native.ts | 7 +-- .../libs/prepareRequestPayloadNativeTest.ts | 5 +- tests/unit/libs/receiptStorageTest.ts | 56 ++++++++++++++++++ 6 files changed, 121 insertions(+), 11 deletions(-) diff --git a/src/libs/ReceiptStorage/index.native.ts b/src/libs/ReceiptStorage/index.native.ts index 34904bb5e54f..7acf5aa73cdc 100644 --- a/src/libs/ReceiptStorage/index.native.ts +++ b/src/libs/ReceiptStorage/index.native.ts @@ -1,3 +1,4 @@ +import checkFileExists from '@libs/fileDownload/checkFileExists'; import {cleanFileName, isLocalFile} from '@libs/fileDownload/FileUtils'; import fileURIToPath from '@libs/fileURIToPath'; import getReceiptsUploadFolderPath from '@libs/getReceiptsUploadFolderPath'; @@ -12,6 +13,9 @@ import type ReceiptStorage from './types'; // the app data container on most upgrades, so an absolute path stored before the upgrade names a // directory the device no longer has, even though iOS carried the file itself across. +const STAGED_SUFFIX = '.staged'; +const BACKUP_SUFFIX = '.backup'; + async function verify(dir: string, name: string): Promise { if (!name || !(await RNFS.exists(`${dir}/${name}`))) { throw new Error('[ReceiptStorage] file is not in durable storage'); @@ -69,20 +73,46 @@ async function discardLeftover(path: string) { } } +/** + * Puts a receipt back when a swap was interrupted between its two renames, which leaves the file under the + * backup name with nothing at the receipt's own path. Resolves to whether the receipt is readable now. + */ +async function restoreInterruptedSwap(target: string): Promise { + if (await RNFS.exists(target)) { + return true; + } + + const backupPath = `${target}${BACKUP_SUFFIX}`; + if (!(await RNFS.exists(backupPath))) { + return false; + } + + try { + await RNFS.moveFile(backupPath, target); + } catch (error) { + Log.warn('[ReceiptStorage] could not put back a receipt left by an interrupted swap', {error: error instanceof Error ? error.message : String(error)}); + return false; + } + + Log.info('[ReceiptStorage] put back a receipt left by an interrupted swap'); + return true; +} + const replace: ReceiptStorage['replace'] = async (durableName, uriOrPath) => { const dir = getReceiptsUploadFolderPath(); if (!dir) { throw new Error('[ReceiptStorage] no receipts folder on this platform'); } - await verify(dir, durableName); const target = `${dir}/${durableName}`; + await restoreInterruptedSwap(target); + await verify(dir, durableName); // Neither platform can move a file onto one that already exists. NSFileManager refuses and Android's // `renameTo` is unreliable, so stage the new bytes beside the receipt, then swap them in with two // renames inside the directory. The original keeps a second name until the swap succeeds. - const stagedPath = `${target}.staged`; - const backupPath = `${target}.backup`; + const stagedPath = `${target}${STAGED_SUFFIX}`; + const backupPath = `${target}${BACKUP_SUFFIX}`; // An attempt that died mid-swap would leave these occupied, and iOS refuses an occupied destination. await discard(stagedPath); await discard(backupPath); @@ -143,6 +173,25 @@ const resolve: ReceiptStorage['resolve'] = (source) => { return durableName ? toLocalUri(durableName) : source; }; -const receiptStorage: ReceiptStorage = {adopt, replace, discard, toLocalUri, resolve}; +const locate: ReceiptStorage['locate'] = async (source) => { + const uri = resolve(source); + if (!uri) { + return undefined; + } + + if (await checkFileExists(uri)) { + return uri; + } + + // A receipt that is missing may only be missing from its own name, so look for an interrupted swap + // before the caller writes it off. + if (!isLocalFile(uri) || !(await restoreInterruptedSwap(fileURIToPath(uri)))) { + return undefined; + } + + return uri; +}; + +const receiptStorage: ReceiptStorage = {adopt, replace, discard, locate, toLocalUri, resolve}; export default receiptStorage; diff --git a/src/libs/ReceiptStorage/index.ts b/src/libs/ReceiptStorage/index.ts index c42b7489a617..3daa30ad6599 100644 --- a/src/libs/ReceiptStorage/index.ts +++ b/src/libs/ReceiptStorage/index.ts @@ -6,6 +6,7 @@ const receiptStorage: ReceiptStorage = { // Web captures through a canvas at full resolution, so there is nothing to swap or clean up. replace: (durableName) => Promise.resolve(durableName), discard: () => Promise.resolve(), + locate: (source) => Promise.resolve(typeof source === 'string' ? source : undefined), toLocalUri: (durableName) => durableName, resolve: (source) => (typeof source === 'string' ? source : undefined), }; diff --git a/src/libs/ReceiptStorage/types.ts b/src/libs/ReceiptStorage/types.ts index 13cd77f75adf..33441548dc7f 100644 --- a/src/libs/ReceiptStorage/types.ts +++ b/src/libs/ReceiptStorage/types.ts @@ -15,6 +15,12 @@ type ReceiptStorage = { /** Deletes a temporary file the app is done with. Resolves even when the file is already gone. */ discard: (uriOrPath: string) => Promise; + /** + * Resolves a stored source to a URI that can be read right now, or `undefined` when the file is gone. + * Also puts a receipt back when a swap was interrupted and left it under a temporary name. + */ + locate: (source: ReceiptSource | null | undefined) => Promise; + /** Valid for this launch only, so never store the result. */ toLocalUri: (durableName: string) => string; diff --git a/src/libs/prepareRequestPayload/index.native.ts b/src/libs/prepareRequestPayload/index.native.ts index 080249e8d061..2b78a1621d23 100644 --- a/src/libs/prepareRequestPayload/index.native.ts +++ b/src/libs/prepareRequestPayload/index.native.ts @@ -1,4 +1,3 @@ -import checkFileExists from '@libs/fileDownload/checkFileExists'; import {readFileAsync} from '@libs/fileDownload/FileUtils'; import ReceiptStorage from '@libs/ReceiptStorage'; import {logReceiptDropped} from '@libs/telemetry/ReceiptObservability'; @@ -33,10 +32,8 @@ const prepareRequestPayload: PrepareRequestPayload = (command, data, initiatedOf return Promise.resolve(); } - const localUri = ReceiptStorage.resolve(source) ?? source; - - return checkFileExists(localUri).then((exists) => { - if (!exists) { + return ReceiptStorage.locate(source).then((localUri) => { + if (!localUri) { const transactionID = typeof data.transactionID === 'string' ? data.transactionID : undefined; logReceiptDropped({receiptTraceId, transactionID, command, source, fileName: name}); return; diff --git a/tests/unit/libs/prepareRequestPayloadNativeTest.ts b/tests/unit/libs/prepareRequestPayloadNativeTest.ts index 75d79aebebfb..bf02bb322d73 100644 --- a/tests/unit/libs/prepareRequestPayloadNativeTest.ts +++ b/tests/unit/libs/prepareRequestPayloadNativeTest.ts @@ -25,9 +25,10 @@ const RECEIPTS_FOLDER = '/Containers/Data/Application/CURRENT/Documents/Receipts jest.mock('@libs/ReceiptStorage', () => ({ __esModule: true, default: { - resolve: (source?: string) => { + locate: (source?: string) => { const name = source?.includes('/Receipts-Upload/') ? source.split('/').pop() : undefined; - return name ? `file://${RECEIPTS_FOLDER}/${name}` : source; + const uri = name ? `file://${RECEIPTS_FOLDER}/${name}` : source; + return mockCheckFileExists(uri).then((exists: boolean) => (exists ? uri : undefined)); }, }, })); diff --git a/tests/unit/libs/receiptStorageTest.ts b/tests/unit/libs/receiptStorageTest.ts index b7173c7ebc32..c070f006897d 100644 --- a/tests/unit/libs/receiptStorageTest.ts +++ b/tests/unit/libs/receiptStorageTest.ts @@ -14,6 +14,13 @@ jest.mock('react-native-fs', () => ({ jest.mock('@libs/NumberUtils', () => ({rand64: () => '1234'})); +const mockCheckFileExists = jest.fn, [string | undefined]>(); + +jest.mock('@libs/fileDownload/checkFileExists', () => ({ + __esModule: true, + default: (path: string | undefined) => mockCheckFileExists(path), +})); + const FOLDER = '/var/mobile/Containers/Data/Application/AAAA-1111/Documents/Receipts-Upload'; jest.mock('@libs/getReceiptsUploadFolderPath', () => ({ __esModule: true, @@ -30,6 +37,7 @@ describe('ReceiptStorage', () => { mockMv.mockResolvedValue(undefined); mockMkdir.mockResolvedValue(undefined); mockUnlink.mockResolvedValue(undefined); + mockCheckFileExists.mockResolvedValue(true); }); describe('adopt', () => { @@ -148,6 +156,22 @@ describe('ReceiptStorage', () => { await expect(ReceiptStorage.replace(RECEIPT, STILL)).resolves.toBe(RECEIPT); }); + it('restores a receipt stranded under the backup name by a swap the app died in the middle of', async () => { + const existing = new Set([`${FOLDER}/${RECEIPT}.backup`]); + mockExists.mockImplementation((path: string) => Promise.resolve(existing.has(path))); + mockMv.mockImplementation((from: string, to: string) => { + existing.delete(from); + existing.add(to); + return Promise.resolve(); + }); + + await expect(ReceiptStorage.replace(RECEIPT, STILL)).resolves.toBe(RECEIPT); + + // The stranded copy is the only one left, so it goes back before any cleanup can delete it. + expect(mockMv).toHaveBeenNthCalledWith(1, `${FOLDER}/${RECEIPT}.backup`, `${FOLDER}/${RECEIPT}`); + expect(mockMv.mock.invocationCallOrder.at(0) ?? 0).toBeLessThan(mockUnlink.mock.invocationCallOrder.at(0) ?? Number.MAX_SAFE_INTEGER); + }); + it('rejects without touching anything when the receipt is not in durable storage', async () => { mockExists.mockResolvedValue(false); @@ -156,6 +180,38 @@ describe('ReceiptStorage', () => { }); }); + describe('locate', () => { + const RECEIPT_URI = `file://${FOLDER}/CAM-1.jpg`; + const RECEIPT_PATH = `${FOLDER}/CAM-1.jpg`; + + it('hands back a receipt that is where it should be', async () => { + await expect(ReceiptStorage.locate(RECEIPT_URI)).resolves.toBe(RECEIPT_URI); + expect(mockMv).not.toHaveBeenCalled(); + }); + + it('puts back a receipt stranded under the backup name by an interrupted swap', async () => { + mockCheckFileExists.mockResolvedValue(false); + mockExists.mockImplementation((path: string) => Promise.resolve(path === `${RECEIPT_PATH}.backup`)); + + await expect(ReceiptStorage.locate(RECEIPT_URI)).resolves.toBe(RECEIPT_URI); + expect(mockMv).toHaveBeenCalledWith(`${RECEIPT_PATH}.backup`, RECEIPT_PATH); + }); + + it('reports a receipt that is gone and has no backup, so the caller can log it as dropped', async () => { + mockCheckFileExists.mockResolvedValue(false); + mockExists.mockResolvedValue(false); + + await expect(ReceiptStorage.locate(RECEIPT_URI)).resolves.toBeUndefined(); + }); + + it('reports a remote source that could not be read, rather than handing back a URL to upload', async () => { + mockCheckFileExists.mockResolvedValue(false); + + await expect(ReceiptStorage.locate('https://www.expensify.com/receipts/w_9.jpg')).resolves.toBeUndefined(); + expect(mockMv).not.toHaveBeenCalled(); + }); + }); + describe('discard', () => { it('deletes a temporary file', async () => { await ReceiptStorage.discard('file:///var/mobile/tmp/still.jpg'); From f5a8fcd368ff3aa7661bad7d6830aa38f087b9c0 Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 10 Sep 2026 09:51:20 +0200 Subject: [PATCH 04/23] fix backup restore racing --- src/libs/ReceiptStorage/index.native.ts | 47 ++++++++++++++++++------ tests/unit/libs/receiptStorageTest.ts | 49 +++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 11 deletions(-) diff --git a/src/libs/ReceiptStorage/index.native.ts b/src/libs/ReceiptStorage/index.native.ts index 7acf5aa73cdc..9a0b14f29206 100644 --- a/src/libs/ReceiptStorage/index.native.ts +++ b/src/libs/ReceiptStorage/index.native.ts @@ -16,6 +16,9 @@ import type ReceiptStorage from './types'; const STAGED_SUFFIX = '.staged'; const BACKUP_SUFFIX = '.backup'; +/** Swaps running right now, by target path. A reader that finds a receipt missing waits on these first. */ +const swapsInFlight = new Map>(); + async function verify(dir: string, name: string): Promise { if (!name || !(await RNFS.exists(`${dir}/${name}`))) { throw new Error('[ReceiptStorage] file is not in durable storage'); @@ -82,6 +85,14 @@ async function restoreInterruptedSwap(target: string): Promise { return true; } + // A swap running right now is between its own two renames, so the receipt is missing for a moment + // rather than stranded. Moving the backup back here would undo the file that swap is installing. + const runningSwap = swapsInFlight.get(target); + if (runningSwap) { + await runningSwap.catch(() => {}); + return RNFS.exists(target); + } + const backupPath = `${target}${BACKUP_SUFFIX}`; if (!(await RNFS.exists(backupPath))) { return false; @@ -91,23 +102,15 @@ async function restoreInterruptedSwap(target: string): Promise { await RNFS.moveFile(backupPath, target); } catch (error) { Log.warn('[ReceiptStorage] could not put back a receipt left by an interrupted swap', {error: error instanceof Error ? error.message : String(error)}); - return false; + // Something else may have put the receipt back in the meantime, which is a success, not a failure. + return RNFS.exists(target); } Log.info('[ReceiptStorage] put back a receipt left by an interrupted swap'); return true; } -const replace: ReceiptStorage['replace'] = async (durableName, uriOrPath) => { - const dir = getReceiptsUploadFolderPath(); - if (!dir) { - throw new Error('[ReceiptStorage] no receipts folder on this platform'); - } - - const target = `${dir}/${durableName}`; - await restoreInterruptedSwap(target); - await verify(dir, durableName); - +async function swapIntoPlace(dir: string, durableName: string, target: string, uriOrPath: string): Promise { // Neither platform can move a file onto one that already exists. NSFileManager refuses and Android's // `renameTo` is unreliable, so stage the new bytes beside the receipt, then swap them in with two // renames inside the directory. The original keeps a second name until the swap succeeds. @@ -144,6 +147,28 @@ const replace: ReceiptStorage['replace'] = async (durableName, uriOrPath) => { await discardLeftover(backupPath); return verify(dir, durableName); +} + +const replace: ReceiptStorage['replace'] = async (durableName, uriOrPath) => { + const dir = getReceiptsUploadFolderPath(); + if (!dir) { + throw new Error('[ReceiptStorage] no receipts folder on this platform'); + } + + const target = `${dir}/${durableName}`; + await restoreInterruptedSwap(target); + await verify(dir, durableName); + + // Registered while it runs so a reader that catches the receipt mid-swap waits for it instead of + // treating the gap between the two renames as an interrupted swap. + const swap = swapIntoPlace(dir, durableName, target, uriOrPath); + swapsInFlight.set(target, swap); + + try { + return await swap; + } finally { + swapsInFlight.delete(target); + } }; const toLocalUri: ReceiptStorage['toLocalUri'] = (durableName) => `file://${getReceiptsUploadFolderPath()}/${durableName}`; diff --git a/tests/unit/libs/receiptStorageTest.ts b/tests/unit/libs/receiptStorageTest.ts index c070f006897d..9c7369db085f 100644 --- a/tests/unit/libs/receiptStorageTest.ts +++ b/tests/unit/libs/receiptStorageTest.ts @@ -1,3 +1,4 @@ +import fileURIToPath from '@libs/fileURIToPath'; import type ReceiptStorageType from '@libs/ReceiptStorage/types'; const mockExists = jest.fn, [string]>(); @@ -197,6 +198,54 @@ describe('ReceiptStorage', () => { expect(mockMv).toHaveBeenCalledWith(`${RECEIPT_PATH}.backup`, RECEIPT_PATH); }); + it('waits for a swap that is running rather than putting the backup back over it', async () => { + const existing = new Set([RECEIPT_PATH]); + let finishSwap: () => void = () => {}; + const swapReachedItsGap = new Promise((resolve) => { + mockMv.mockImplementation((from: string, to: string) => { + existing.delete(from); + existing.add(to); + if (to === `${RECEIPT_PATH}.backup`) { + // The receipt is now missing from its own path, which is the gap a reader can land in. + resolve(); + return new Promise((settle) => { + finishSwap = () => { + existing.delete(`${RECEIPT_PATH}.staged`); + existing.add(RECEIPT_PATH); + settle(); + }; + }); + } + return Promise.resolve(); + }); + }); + mockExists.mockImplementation((path: string) => Promise.resolve(existing.has(path))); + mockCheckFileExists.mockImplementation((path?: string) => Promise.resolve(existing.has(fileURIToPath(path ?? '')))); + + const swap = ReceiptStorage.replace('CAM-1.jpg', '/var/mobile/tmp/still.jpg'); + await swapReachedItsGap; + + const located = ReceiptStorage.locate(RECEIPT_URI); + finishSwap(); + await swap; + + // The swapped-in file is what the reader gets, and the backup was never moved back over it. + await expect(located).resolves.toBe(RECEIPT_URI); + expect(mockMv).not.toHaveBeenCalledWith(`${RECEIPT_PATH}.backup`, RECEIPT_PATH); + }); + + it('treats a receipt that reappeared while the restore was failing as readable', async () => { + mockCheckFileExists.mockResolvedValue(false); + let hasMoved = false; + mockExists.mockImplementation((path: string) => Promise.resolve(path === `${RECEIPT_PATH}.backup` || (path === RECEIPT_PATH && hasMoved))); + mockMv.mockImplementation(() => { + hasMoved = true; + return Promise.reject(new Error('file exists')); + }); + + await expect(ReceiptStorage.locate(RECEIPT_URI)).resolves.toBe(RECEIPT_URI); + }); + it('reports a receipt that is gone and has no backup, so the caller can log it as dropped', async () => { mockCheckFileExists.mockResolvedValue(false); mockExists.mockResolvedValue(false); From bb309633fe92ef775ea73e511edc26e36653dccb Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 10 Sep 2026 13:14:50 +0200 Subject: [PATCH 05/23] Upgrade the receipt on every scan flow --- .../hooks/useReceiptThumbnailSource.ts | 15 ++- src/hooks/useLocalReceiptThumbnail.ts | 11 ++- src/hooks/useReceiptUpgradeCount.ts | 23 +++++ src/libs/ReceiptStorage/index.native.ts | 33 +++++-- src/libs/ReceiptStorage/index.ts | 2 +- src/libs/ReceiptStorage/receiptUpgrades.ts | 98 +++++++++++++++++++ src/libs/ReceiptStorage/types.ts | 11 ++- .../components/Camera/index.native.tsx | 4 +- .../components/Camera/types.ts | 6 -- .../components/ScanEditReceipt.tsx | 34 ++++--- .../components/ScanSkipConfirmation.tsx | 3 - .../hooks/useStillPhotoUpgrade.ts | 12 ++- tests/unit/hooks/useStillPhotoUpgrade.test.ts | 14 ++- tests/unit/libs/receiptStorageTest.ts | 55 +++++++++-- tests/unit/libs/receiptUpgradesTest.ts | 68 +++++++++++++ 15 files changed, 341 insertions(+), 48 deletions(-) create mode 100644 src/hooks/useReceiptUpgradeCount.ts create mode 100644 src/libs/ReceiptStorage/receiptUpgrades.ts create mode 100644 tests/unit/libs/receiptUpgradesTest.ts diff --git a/src/components/MoneyRequestConfirmationListFooter/hooks/useReceiptThumbnailSource.ts b/src/components/MoneyRequestConfirmationListFooter/hooks/useReceiptThumbnailSource.ts index 296cecabdd33..a47a7b799fd1 100644 --- a/src/components/MoneyRequestConfirmationListFooter/hooks/useReceiptThumbnailSource.ts +++ b/src/components/MoneyRequestConfirmationListFooter/hooks/useReceiptThumbnailSource.ts @@ -1,4 +1,5 @@ import useLocalReceiptThumbnail from '@hooks/useLocalReceiptThumbnail'; +import useReceiptUpgradeCount from '@hooks/useReceiptUpgradeCount'; import type {ThumbnailAndImageURI} from '@libs/ReceiptUtils'; import {getThumbnailAndImageURIs} from '@libs/ReceiptUtils'; @@ -42,10 +43,18 @@ function useReceiptThumbnailSource({transaction, receiptPath, receiptFilename}: // (react-hooks/refs). React handles this synchronously before painting, so there is no visible // double-render or layout thrash. const resolvedReceiptImageStr = resolvedReceiptImage != null ? String(resolvedReceiptImage) : undefined; - const [initialLocalSource, setInitialLocalSource] = useState<{source: string | undefined; resolvedImage: string | undefined}>({source: undefined, resolvedImage: undefined}); - if (isLocalFile && (initialLocalSource.source === undefined || initialLocalSource.resolvedImage !== resolvedReceiptImageStr)) { + + // Pinning the source keeps a late thumbnail from flashing, but a receipt replaced by a better capture + // has to get through, so the count of upgrades is part of what is pinned. + const upgradeCount = useReceiptUpgradeCount(resolvedReceiptImageStr); + const [initialLocalSource, setInitialLocalSource] = useState<{source: string | undefined; resolvedImage: string | undefined; upgradeCount: number}>({ + source: undefined, + resolvedImage: undefined, + upgradeCount: 0, + }); + if (isLocalFile && (initialLocalSource.source === undefined || initialLocalSource.resolvedImage !== resolvedReceiptImageStr || initialLocalSource.upgradeCount !== upgradeCount)) { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- empty string fallback is intentional: we want to skip the empty-string case in the OR chain, not treat it as a valid source - setInitialLocalSource({source: thumbnailUri || resolvedReceiptImageStr || '', resolvedImage: resolvedReceiptImageStr}); + setInitialLocalSource({source: thumbnailUri || resolvedReceiptImageStr || '', resolvedImage: resolvedReceiptImageStr, upgradeCount}); } // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- empty string fallback is intentional: we want to skip the empty-string case in the OR chain, not treat it as a valid source diff --git a/src/hooks/useLocalReceiptThumbnail.ts b/src/hooks/useLocalReceiptThumbnail.ts index 54b87caa45aa..414680f70817 100644 --- a/src/hooks/useLocalReceiptThumbnail.ts +++ b/src/hooks/useLocalReceiptThumbnail.ts @@ -2,6 +2,8 @@ import {generateThumbnail} from '@pages/iou/request/step/IOURequestStepScan/crop import {useEffect, useRef, useState, useTransition} from 'react'; +import useReceiptUpgradeCount from './useReceiptUpgradeCount'; + const thumbnailCache = new Map(); /** Track how many mounted hook instances reference each sourceUri */ const thumbnailRefCount = new Map(); @@ -42,10 +44,17 @@ function useLocalReceiptThumbnail(sourceUri: string | undefined, isLocalFile: bo const [, startTransition] = useTransition(); const retainedUriRef = useRef(undefined); + // A receipt swapped for a better capture keeps its path, so the image loaders would serve the decode + // they already have. Counting the upgrades gives the same file a source they treat as new. + const upgradeCount = useReceiptUpgradeCount(sourceUri); + // Resolve cached thumbnails synchronously during render (fast path) const cachedUri = sourceUri ? thumbnailCache.get(sourceUri) : undefined; const resultForCurrentSource = asyncResult?.source === sourceUri ? asyncResult : undefined; - const thumbnailUri = cachedUri ?? resultForCurrentSource?.uri; + const resolvedUri = cachedUri ?? resultForCurrentSource?.uri; + // The suffix is for the image loaders only. Nothing stores it, so a view handed this source can pass it + // back in without the receipt losing its identity. + const thumbnailUri = resolvedUri && upgradeCount > 0 ? `${resolvedUri.split('#').at(0) ?? resolvedUri}#upgraded${upgradeCount}` : resolvedUri; const shouldGenerate = !!sourceUri && isLocalFile && !cachedUri; const isGenerating = shouldGenerate && !resultForCurrentSource?.done; diff --git a/src/hooks/useReceiptUpgradeCount.ts b/src/hooks/useReceiptUpgradeCount.ts new file mode 100644 index 000000000000..64734e815547 --- /dev/null +++ b/src/hooks/useReceiptUpgradeCount.ts @@ -0,0 +1,23 @@ +import {getUpgradeCount, subscribe} from '@libs/ReceiptStorage/receiptUpgrades'; + +import {useSyncExternalStore} from 'react'; + +/** + * The receipt's durable name, from any URI that points at it. The fragment matters here: a view that has + * already been handed an upgraded source passes it back in, and keying on it would count a different + * receipt every time the file changes. + */ +function toDurableName(sourceUri: string | undefined): string { + return sourceUri?.split('/').pop()?.split('#').at(0)?.split('?').at(0) ?? ''; +} + +/** + * How many times the receipt at this URI has been replaced by a better capture during this launch. A view + * that shows the receipt can use it to tell one version of the file from the next, since the path itself + * never changes. + */ +function useReceiptUpgradeCount(sourceUri: string | undefined): number { + return useSyncExternalStore(subscribe, () => getUpgradeCount(toDurableName(sourceUri))); +} + +export default useReceiptUpgradeCount; diff --git a/src/libs/ReceiptStorage/index.native.ts b/src/libs/ReceiptStorage/index.native.ts index 9a0b14f29206..5ad7257984c4 100644 --- a/src/libs/ReceiptStorage/index.native.ts +++ b/src/libs/ReceiptStorage/index.native.ts @@ -9,16 +9,30 @@ import RNFS from 'react-native-fs'; import type ReceiptStorage from './types'; +import {waitFor as waitForPendingUpgrade} from './receiptUpgrades'; + // A durable name is the bare filename inside the receipts folder. Never store a full path: iOS moves // the app data container on most upgrades, so an absolute path stored before the upgrade names a // directory the device no longer has, even though iOS carried the file itself across. +/** How long a reader waits for an upgrade before taking the file as it stands, so a stall cannot hold an upload open. */ +const UPGRADE_WAIT_TIMEOUT_MS = 10000; + const STAGED_SUFFIX = '.staged'; const BACKUP_SUFFIX = '.backup'; -/** Swaps running right now, by target path. A reader that finds a receipt missing waits on these first. */ +/** Swaps running right now, by target path. A reader of one of those paths waits on these first. */ const swapsInFlight = new Map>(); +/** + * Waits for a swap that owns this path, if one is running. Its outcome is not the caller's business: + * `overwrite` reports its own failures and leaves the receipt in place either way, so the caller checks the + * file for itself once the swap is done. + */ +async function waitForRunningSwap(target: string) { + await swapsInFlight.get(target)?.catch(() => {}); +} + async function verify(dir: string, name: string): Promise { if (!name || !(await RNFS.exists(`${dir}/${name}`))) { throw new Error('[ReceiptStorage] file is not in durable storage'); @@ -87,9 +101,8 @@ async function restoreInterruptedSwap(target: string): Promise { // A swap running right now is between its own two renames, so the receipt is missing for a moment // rather than stranded. Moving the backup back here would undo the file that swap is installing. - const runningSwap = swapsInFlight.get(target); - if (runningSwap) { - await runningSwap.catch(() => {}); + if (swapsInFlight.has(target)) { + await waitForRunningSwap(target); return RNFS.exists(target); } @@ -149,7 +162,7 @@ async function swapIntoPlace(dir: string, durableName: string, target: string, u return verify(dir, durableName); } -const replace: ReceiptStorage['replace'] = async (durableName, uriOrPath) => { +const overwrite: ReceiptStorage['overwrite'] = async (durableName, uriOrPath) => { const dir = getReceiptsUploadFolderPath(); if (!dir) { throw new Error('[ReceiptStorage] no receipts folder on this platform'); @@ -204,6 +217,14 @@ const locate: ReceiptStorage['locate'] = async (source) => { return undefined; } + // A swap in flight is about to change these bytes, and on a busy thread it can take seconds, so read + // the receipt after it finishes rather than shipping the file it is replacing. + if (isLocalFile(uri)) { + const target = fileURIToPath(uri); + await waitForPendingUpgrade(target.split('/').pop() ?? '', UPGRADE_WAIT_TIMEOUT_MS); + await waitForRunningSwap(target); + } + if (await checkFileExists(uri)) { return uri; } @@ -217,6 +238,6 @@ const locate: ReceiptStorage['locate'] = async (source) => { return uri; }; -const receiptStorage: ReceiptStorage = {adopt, replace, discard, locate, toLocalUri, resolve}; +const receiptStorage: ReceiptStorage = {adopt, overwrite, discard, locate, toLocalUri, resolve}; export default receiptStorage; diff --git a/src/libs/ReceiptStorage/index.ts b/src/libs/ReceiptStorage/index.ts index 3daa30ad6599..11d3ad75a275 100644 --- a/src/libs/ReceiptStorage/index.ts +++ b/src/libs/ReceiptStorage/index.ts @@ -4,7 +4,7 @@ import type ReceiptStorage from './types'; const receiptStorage: ReceiptStorage = { adopt: (uriOrPath) => Promise.resolve(uriOrPath), // Web captures through a canvas at full resolution, so there is nothing to swap or clean up. - replace: (durableName) => Promise.resolve(durableName), + overwrite: (durableName) => Promise.resolve(durableName), discard: () => Promise.resolve(), locate: (source) => Promise.resolve(typeof source === 'string' ? source : undefined), toLocalUri: (durableName) => durableName, diff --git a/src/libs/ReceiptStorage/receiptUpgrades.ts b/src/libs/ReceiptStorage/receiptUpgrades.ts new file mode 100644 index 000000000000..b9d9c55bec78 --- /dev/null +++ b/src/libs/ReceiptStorage/receiptUpgrades.ts @@ -0,0 +1,98 @@ +/** + * Tracks receipts whose file is about to be replaced by a better capture, so the rest of the app can wait + * for the swap or react to it instead of reading a receipt that is about to change. + * + * Keyed by durable name, because that is the receipt's identity everywhere else. Entries live only as long + * as the upgrade: nothing here survives a relaunch, and a receipt with no entry is simply not changing. + */ + +type PendingUpgrade = { + /** Resolves once the upgrade has finished, bailed or failed. */ + promise: Promise; + + /** Settles `promise`. Called by `finish`. */ + settle: () => void; +}; + +const upgradesInFlight = new Map(); + +/** How many times each receipt has been upgraded this launch. Lets a view tell one version from the next. */ +const upgradeCounts = new Map(); + +const listeners = new Set<() => void>(); + +function notify() { + for (const listener of listeners) { + listener(); + } +} + +/** Announces that a receipt is being upgraded. */ +function start(durableName: string) { + if (upgradesInFlight.has(durableName)) { + return; + } + + let settle: () => void = () => {}; + const promise = new Promise((resolveUpgrade) => { + settle = resolveUpgrade; + }); + upgradesInFlight.set(durableName, {promise, settle}); + notify(); +} + +/** Announces that a receipt has stopped changing, whether the upgrade landed or not. */ +function finish(durableName: string) { + const upgrade = upgradesInFlight.get(durableName); + if (!upgrade) { + return; + } + + upgradesInFlight.delete(durableName); + upgradeCounts.set(durableName, (upgradeCounts.get(durableName) ?? 0) + 1); + upgrade.settle(); + notify(); +} + +/** + * Waits for a receipt to stop changing. Resolves immediately when nothing is in flight, and gives up after + * `capMs` so a stalled upgrade cannot hold a caller open. Never rejects: the caller reads the file either + * way, and the upgrade reports its own failures. + */ +function waitFor(durableName: string, capMs: number): Promise { + const upgrade = upgradesInFlight.get(durableName); + if (!upgrade) { + return Promise.resolve(); + } + + return new Promise((settle) => { + const cap = setTimeout(settle, capMs); + upgrade.promise.then(() => { + clearTimeout(cap); + settle(); + }); + }); +} + +/** + * How many upgrades this receipt has been through. A view that renders the file can put this in its image + * source, so the same path counts as a different image once the bytes behind it change. + */ +function getUpgradeCount(durableName: string): number { + return upgradeCounts.get(durableName) ?? 0; +} + +/** Whether this receipt is being upgraded right now. */ +function isUpgrading(durableName: string): boolean { + return upgradesInFlight.has(durableName); +} + +/** Subscribes to changes, for components that show a receipt while it is being upgraded. */ +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export {start, finish, waitFor, isUpgrading, getUpgradeCount, subscribe}; diff --git a/src/libs/ReceiptStorage/types.ts b/src/libs/ReceiptStorage/types.ts index 33441548dc7f..49b543222609 100644 --- a/src/libs/ReceiptStorage/types.ts +++ b/src/libs/ReceiptStorage/types.ts @@ -1,5 +1,14 @@ import type {ReceiptSource} from '@src/types/onyx/Transaction'; +/* + * A receipt is identified by its durable name for as long as it exists, and a better capture replaces the + * bytes behind that name rather than arriving as a second file. The alternative, pointing the receipt at a + * new file, was tried and abandoned: the upload reads the source off the in-memory receipt object and the + * replace-receipt screen never goes through the code that builds it, so there is no single place to update + * the pointer. Overwriting in place reaches every reader without touching any of them, at the cost of the + * coordination in `receiptUpgrades` and the staged swap below. + */ + /** Owns the receipts folder. The only code that writes a file there, names one, or resolves one. */ type ReceiptStorage = { /** Moves a file into the receipts folder and returns its durable name. Rejects when the file did not land. */ @@ -10,7 +19,7 @@ type ReceiptStorage = { * upload and replace-receipt all read the new file. Rejects if the receipt is not in durable storage or * the swap fails, leaving the original in place. */ - replace: (durableName: string, uriOrPath: string) => Promise; + overwrite: (durableName: string, uriOrPath: string) => Promise; /** Deletes a temporary file the app is done with. Resolves even when the file is already gone. */ discard: (uriOrPath: string) => Promise; diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx index 149df2116c62..dc3e03866cd8 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx @@ -45,7 +45,7 @@ const BLINK_DURATION_MS = 80; * Renders a react-native-vision-camera viewfinder with shutter, flash toggle, gallery picker, and focus gesture. * Calls `onCapture(file, source)` for each photo taken or file picked from the gallery. */ -function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayout, onAttachmentPickerStatusChange, onMultiScanSubmit, canUpgradeReceiptQuality = true}: CameraProps) { +function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayout, onAttachmentPickerStatusChange, onMultiScanSubmit}: CameraProps) { const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -175,7 +175,7 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou // `takeSnapshot` saves a screen-sized screenshot of the preview, so on that path a full-resolution // `takePhoto` runs alongside it and replaces the receipt file once it lands. Nothing below awaits it. - const shouldUpgradeToStill = canUpgradeReceiptQuality && !isMultiScanEnabled && !shouldTakePhoto({flash, hasFlash, isInLandscapeMode}); + const shouldUpgradeToStill = !isMultiScanEnabled && !shouldTakePhoto({flash, hasFlash, isInLandscapeMode}); // The snapshot goes first so its request reaches the native queue ahead of the still. On iOS it // reads the most recent video frame, which a photo capture can interrupt. diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/types.ts b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/types.ts index f76b8f3c787b..4bfae6924dfc 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/types.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/types.ts @@ -25,12 +25,6 @@ type CameraProps = { /** Called when the user taps the multi-scan submit button on the preview ribbon. Used by native and mobile web. */ onMultiScanSubmit?: () => void; - - /** - * Whether a receipt captured here may be swapped for the full-resolution still that lands a moment - * later. Screens that submit at capture time opt out, since the upload can read the file mid-swap. - */ - canUpgradeReceiptQuality?: boolean; }; export default CameraProps; diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/ScanEditReceipt.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/ScanEditReceipt.tsx index eef617d1b05e..44b417e9522a 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/ScanEditReceipt.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/ScanEditReceipt.tsx @@ -8,6 +8,7 @@ import usePolicy from '@hooks/usePolicy'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Navigation from '@libs/Navigation/Navigation'; import navigationRef from '@libs/Navigation/navigationRef'; +import {waitFor as waitForReceiptUpgrade} from '@libs/ReceiptStorage/receiptUpgrades'; import {cancelSpan} from '@libs/telemetry/activeSpans'; import getFileSource from '@pages/iou/request/step/IOURequestStepScan/utils/getFileSource'; @@ -40,6 +41,12 @@ type ScanEditReceiptProps = { * ScanEditReceipt — replace an existing receipt and navigate back. * Simplest variant: no multi-scan, no participants, no confirmation page. */ +/** + * How long this screen waits for the better photo before replacing the receipt with what it has. Short, + * because someone is watching a loader while it runs. + */ +const RECEIPT_UPGRADE_WAIT_MS = 1500; + function ScanEditReceipt({report, transactionID, backTo, isEditing}: ScanEditReceiptProps) { const {translate} = useLocalize(); const policy = usePolicy(report?.policyID); @@ -69,15 +76,21 @@ function ScanEditReceipt({report, transactionID, backTo, isEditing}: ScanEditRec cancelSpan(CONST.TELEMETRY.SPAN_RECEIPT_PREPARE); if (isEditing) { setMoneyRequestReceipt(transactionID, source, file.name ?? '', false, file.type); - replaceReceipt({ - transaction, - file: file as File, - source, - transactionPolicy: policy, - transactionPolicyCategories: policyCategories, - transactionPolicyTagList: policyTagList, - transactionViolations, - transactionReport, + + // `replaceReceipt` sends the file object itself rather than a receipt source, so this upload + // never reaches `ReceiptStorage.locate` and cannot wait there. Hold the request back until the + // full-resolution photo has landed, while the screen goes back straight away. + waitForReceiptUpgrade(file.name ?? '', RECEIPT_UPGRADE_WAIT_MS).then(() => { + replaceReceipt({ + transaction, + file: file as File, + source, + transactionPolicy: policy, + transactionPolicyCategories: policyCategories, + transactionPolicyTagList: policyTagList, + transactionViolations, + transactionReport, + }); }); } else { setMoneyRequestReceipt(transactionID, source, file.name ?? '', true, file.type); @@ -107,9 +120,6 @@ function ScanEditReceipt({report, transactionID, backTo, isEditing}: ScanEditRec onPicked={validateFiles} onAttachmentPickerStatusChange={setIsLoaderVisible} isReplacingReceipt - // While editing, the capture is uploaded through `replaceReceipt` right away, so the - // upload can read the receipt file while a higher-resolution one is being swapped in. - canUpgradeReceiptQuality={!isEditing} /> ); diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx index 8c201e590180..54e912f067df 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx @@ -446,9 +446,6 @@ function ScanSkipConfirmation({report, action, iouType, reportID, transactionID, onAttachmentPickerStatusChange={setIsLoaderVisible} onMultiScanSubmit={submitMultiScan} shouldAcceptMultipleFiles - // This screen submits at capture time, so the upload can read the receipt file while a - // higher-resolution one is being swapped in. - canUpgradeReceiptQuality={false} /> { if (!photo) { @@ -141,7 +146,7 @@ function useStillPhotoUpgrade() { } return rotateStillToUpright(photo.path).then((uprightPath) => - ReceiptStorage.replace(durableName, uprightPath ?? photo.path).then(() => { + ReceiptStorage.overwrite(durableName, uprightPath ?? photo.path).then(() => { Log.info('[StillPhotoUpgrade] receipt upgraded to the full-resolution still', false, { width: photo.width, height: photo.height, @@ -151,7 +156,7 @@ function useStillPhotoUpgrade() { if (!uprightPath) { return; } - // `replace` moved the rotated copy, so the original is left behind, and its cleanup + // `overwrite` moved the rotated copy, so the original is left behind, and its cleanup // must not fail an upgrade that already went through. discardStill(photo.path); }), @@ -161,7 +166,8 @@ function useStillPhotoUpgrade() { Log.warn('[StillPhotoUpgrade] keeping the snapshot, upgrade failed', {error: error instanceof Error ? error.message : String(error)}); pending.isDiscarded = true; discardWhenItLands(pending); - }); + }) + .finally(() => finishUpgrade(durableName)); }; const discardPendingStill = () => { diff --git a/tests/unit/hooks/useStillPhotoUpgrade.test.ts b/tests/unit/hooks/useStillPhotoUpgrade.test.ts index 77ab6766f28e..a6bffc87d63c 100644 --- a/tests/unit/hooks/useStillPhotoUpgrade.test.ts +++ b/tests/unit/hooks/useStillPhotoUpgrade.test.ts @@ -6,11 +6,18 @@ import type {Camera, PhotoFile} from 'react-native-vision-camera'; const mockReplace = jest.fn, [string, string]>(); const mockDiscard = jest.fn, [string]>(); +const mockStartUpgrade = jest.fn(); +const mockFinishUpgrade = jest.fn(); + +jest.mock('@libs/ReceiptStorage/receiptUpgrades', () => ({ + start: (name: string) => mockStartUpgrade(name), + finish: (name: string) => mockFinishUpgrade(name), +})); jest.mock('@libs/ReceiptStorage', () => ({ __esModule: true, default: { - replace: (durableName: string, path: string) => mockReplace(durableName, path), + overwrite: (durableName: string, path: string) => mockReplace(durableName, path), discard: (path: string) => mockDiscard(path), }, })); @@ -104,6 +111,8 @@ describe('useStillPhotoUpgrade', () => { expect(mockRotate).toHaveBeenCalledWith(STILL_PATH); expect(mockReplace).toHaveBeenCalledWith(DURABLE_NAME, UPRIGHT_PATH); expect(mockDiscard).toHaveBeenCalledWith(STILL_PATH); + // The upload waits on this hold, so it has to be let go once the receipt is upgraded. + expect(mockFinishUpgrade).toHaveBeenCalledWith(DURABLE_NAME); }); it('swaps the still as captured when it needs no rotation, leaving nothing to clean up', async () => { @@ -140,6 +149,7 @@ describe('useStillPhotoUpgrade', () => { expect(result.current.hasPendingStillCapture).toBe(false); expect(mockReplace).not.toHaveBeenCalled(); + expect(mockFinishUpgrade).toHaveBeenCalledWith(DURABLE_NAME); // A still that turns up after the deadline has no consumer, so it must not stay on disk. await landStill(); @@ -162,6 +172,8 @@ describe('useStillPhotoUpgrade', () => { expect(result.current.hasPendingStillCapture).toBe(false); expect(mockReplace).not.toHaveBeenCalled(); expect(mockDiscard).not.toHaveBeenCalled(); + // A capture that failed must not leave the upload waiting on it. + expect(mockFinishUpgrade).toHaveBeenCalledWith(DURABLE_NAME); }); it('deletes the still when the receipt it was meant for never arrived', async () => { diff --git a/tests/unit/libs/receiptStorageTest.ts b/tests/unit/libs/receiptStorageTest.ts index 9c7369db085f..cd4492e824f7 100644 --- a/tests/unit/libs/receiptStorageTest.ts +++ b/tests/unit/libs/receiptStorageTest.ts @@ -85,7 +85,7 @@ describe('ReceiptStorage', () => { }); }); - describe('replace', () => { + describe('overwrite', () => { const RECEIPT = 'CAM-1.jpg'; const STILL = '/var/mobile/tmp/still.jpg'; @@ -95,7 +95,7 @@ describe('ReceiptStorage', () => { it('swaps the bytes and hands back the same durable name, so every consumer of the receipt follows along', async () => { mockExists.mockImplementation(onlyTheReceiptExists); - const name = await ReceiptStorage.replace(RECEIPT, `file://${STILL}`); + const name = await ReceiptStorage.overwrite(RECEIPT, `file://${STILL}`); expect(name).toBe(RECEIPT); expect(mockMv.mock.calls).toEqual([ @@ -108,7 +108,7 @@ describe('ReceiptStorage', () => { it('drops the file it moved aside once the swap went through, so a capture leaves one receipt behind', async () => { mockExists.mockImplementation((path: string) => Promise.resolve(path === `${FOLDER}/${RECEIPT}` || path === `${FOLDER}/${RECEIPT}.backup`)); - await ReceiptStorage.replace(RECEIPT, STILL); + await ReceiptStorage.overwrite(RECEIPT, STILL); expect(mockUnlink).toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}.backup`); }); @@ -125,7 +125,7 @@ describe('ReceiptStorage', () => { return Promise.resolve(); }); - await expect(ReceiptStorage.replace(RECEIPT, STILL)).rejects.toThrow('no space left on device'); + await expect(ReceiptStorage.overwrite(RECEIPT, STILL)).rejects.toThrow('no space left on device'); expect(mockMv).toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}.backup`, `${FOLDER}/${RECEIPT}`); expect(mockUnlink).toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}.staged`); @@ -143,7 +143,7 @@ describe('ReceiptStorage', () => { return Promise.resolve(); }); - await expect(ReceiptStorage.replace(RECEIPT, STILL)).rejects.toThrow(`it is left at ${FOLDER}/${RECEIPT}.backup`); + await expect(ReceiptStorage.overwrite(RECEIPT, STILL)).rejects.toThrow(`it is left at ${FOLDER}/${RECEIPT}.backup`); // Freeing the staged copy gives a full disk room for the restore, so it happens even when the // restore then fails. @@ -154,7 +154,7 @@ describe('ReceiptStorage', () => { mockExists.mockImplementation(onlyTheReceiptExists); mockUnlink.mockRejectedValue(new Error('permission denied')); - await expect(ReceiptStorage.replace(RECEIPT, STILL)).resolves.toBe(RECEIPT); + await expect(ReceiptStorage.overwrite(RECEIPT, STILL)).resolves.toBe(RECEIPT); }); it('restores a receipt stranded under the backup name by a swap the app died in the middle of', async () => { @@ -166,7 +166,7 @@ describe('ReceiptStorage', () => { return Promise.resolve(); }); - await expect(ReceiptStorage.replace(RECEIPT, STILL)).resolves.toBe(RECEIPT); + await expect(ReceiptStorage.overwrite(RECEIPT, STILL)).resolves.toBe(RECEIPT); // The stranded copy is the only one left, so it goes back before any cleanup can delete it. expect(mockMv).toHaveBeenNthCalledWith(1, `${FOLDER}/${RECEIPT}.backup`, `${FOLDER}/${RECEIPT}`); @@ -176,7 +176,7 @@ describe('ReceiptStorage', () => { it('rejects without touching anything when the receipt is not in durable storage', async () => { mockExists.mockResolvedValue(false); - await expect(ReceiptStorage.replace(RECEIPT, STILL)).rejects.toThrow('not in durable storage'); + await expect(ReceiptStorage.overwrite(RECEIPT, STILL)).rejects.toThrow('not in durable storage'); expect(mockMv).not.toHaveBeenCalled(); }); }); @@ -198,6 +198,43 @@ describe('ReceiptStorage', () => { expect(mockMv).toHaveBeenCalledWith(`${RECEIPT_PATH}.backup`, RECEIPT_PATH); }); + it('waits for a swap that is running before reading a receipt that is still there', async () => { + const existing = new Set([RECEIPT_PATH]); + let finishSwap: () => void = () => {}; + const swapStarted = new Promise((resolve) => { + mockMv.mockImplementation((from: string, to: string) => { + existing.delete(from); + existing.add(to); + if (to === `${RECEIPT_PATH}.staged`) { + resolve(); + return new Promise((settle) => { + finishSwap = settle; + }); + } + return Promise.resolve(); + }); + }); + mockExists.mockImplementation((path: string) => Promise.resolve(existing.has(path))); + mockCheckFileExists.mockImplementation((path?: string) => Promise.resolve(existing.has(fileURIToPath(path ?? '')))); + + const swap = ReceiptStorage.overwrite('CAM-1.jpg', '/var/mobile/tmp/still.jpg'); + await swapStarted; + + let hasLocated = false; + const located = ReceiptStorage.locate(RECEIPT_URI).then((uri) => { + hasLocated = true; + return uri; + }); + await Promise.resolve(); + + // The receipt is on disk the whole time, but reading it now would ship the file being replaced. + expect(hasLocated).toBe(false); + + finishSwap(); + await swap; + await expect(located).resolves.toBe(RECEIPT_URI); + }); + it('waits for a swap that is running rather than putting the backup back over it', async () => { const existing = new Set([RECEIPT_PATH]); let finishSwap: () => void = () => {}; @@ -222,7 +259,7 @@ describe('ReceiptStorage', () => { mockExists.mockImplementation((path: string) => Promise.resolve(existing.has(path))); mockCheckFileExists.mockImplementation((path?: string) => Promise.resolve(existing.has(fileURIToPath(path ?? '')))); - const swap = ReceiptStorage.replace('CAM-1.jpg', '/var/mobile/tmp/still.jpg'); + const swap = ReceiptStorage.overwrite('CAM-1.jpg', '/var/mobile/tmp/still.jpg'); await swapReachedItsGap; const located = ReceiptStorage.locate(RECEIPT_URI); diff --git a/tests/unit/libs/receiptUpgradesTest.ts b/tests/unit/libs/receiptUpgradesTest.ts new file mode 100644 index 000000000000..f88761b5df78 --- /dev/null +++ b/tests/unit/libs/receiptUpgradesTest.ts @@ -0,0 +1,68 @@ +import {finish, isUpgrading, start, subscribe, waitFor} from '@libs/ReceiptStorage/receiptUpgrades'; + +const RECEIPT = 'receipt_1234.jpg'; +const CAP_MS = 1500; + +describe('receiptUpgrades', () => { + afterEach(() => { + finish(RECEIPT); + jest.useRealTimers(); + }); + + it('reports nothing in flight for a receipt that is not being upgraded', async () => { + expect(isUpgrading(RECEIPT)).toBe(false); + await expect(waitFor(RECEIPT, CAP_MS)).resolves.toBeUndefined(); + }); + + it('holds a caller until the upgrade finishes', async () => { + start(RECEIPT); + expect(isUpgrading(RECEIPT)).toBe(true); + + let hasWaited = false; + const waited = waitFor(RECEIPT, CAP_MS).then(() => { + hasWaited = true; + }); + await Promise.resolve(); + expect(hasWaited).toBe(false); + + finish(RECEIPT); + await waited; + expect(isUpgrading(RECEIPT)).toBe(false); + }); + + it('lets a caller through once it has waited long enough, so a stalled upgrade blocks nothing', async () => { + jest.useFakeTimers(); + start(RECEIPT); + + const waited = waitFor(RECEIPT, CAP_MS); + await jest.advanceTimersByTimeAsync(CAP_MS); + + await expect(waited).resolves.toBeUndefined(); + // The upgrade is still running, it just no longer has anyone waiting on it. + expect(isUpgrading(RECEIPT)).toBe(true); + }); + + it('tells subscribers when a receipt starts and stops changing', () => { + const listener = jest.fn(); + const unsubscribe = subscribe(listener); + + start(RECEIPT); + expect(listener).toHaveBeenCalledTimes(1); + + finish(RECEIPT); + expect(listener).toHaveBeenCalledTimes(2); + + unsubscribe(); + start(RECEIPT); + expect(listener).toHaveBeenCalledTimes(2); + }); + + it('ignores a second start for the same receipt, so one upgrade cannot orphan another', async () => { + start(RECEIPT); + start(RECEIPT); + + finish(RECEIPT); + + await expect(waitFor(RECEIPT, CAP_MS)).resolves.toBeUndefined(); + }); +}); From 0e02461ab2f5f0d6c59777baef6204f10366e010 Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 10 Sep 2026 13:37:12 +0200 Subject: [PATCH 06/23] Measure the receipt upgrade and change naming --- src/CONST/index.ts | 9 ++ src/libs/ReceiptStorage/receiptUpgrades.ts | 7 +- .../components/Camera/index.native.tsx | 18 +-- .../components/CameraViewport.tsx | 10 +- .../components/ScanEditReceipt.tsx | 7 +- ...tillPhotoUpgrade.ts => usePhotoUpgrade.ts} | 117 +++++++++++------- ...llToUpright.ts => rotatePhotoToUpright.ts} | 24 ++-- ...pgrade.test.ts => usePhotoUpgrade.test.ts} | 108 ++++++++-------- ...ghtTest.ts => rotatePhotoToUprightTest.ts} | 2 +- 9 files changed, 171 insertions(+), 131 deletions(-) rename src/pages/iou/request/step/IOURequestStepScan/hooks/{useStillPhotoUpgrade.ts => usePhotoUpgrade.ts} (50%) rename src/pages/iou/request/step/IOURequestStepScan/utils/{rotateStillToUpright.ts => rotatePhotoToUpright.ts} (76%) rename tests/unit/hooks/{useStillPhotoUpgrade.test.ts => usePhotoUpgrade.test.ts} (62%) rename tests/unit/{rotateStillToUprightTest.ts => rotatePhotoToUprightTest.ts} (97%) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 197bb84fa302..48722a175eb1 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2307,6 +2307,7 @@ const CONST = { SPAN_ENTRY_TO_SCAN_READY: 'ManualEntryToScanReady', SPAN_SHUTTER_TO_CONFIRMATION: 'ManualShutterToConfirmation', SPAN_RECEIPT_CAPTURE: 'ManualReceiptCapture', + SPAN_RECEIPT_PHOTO_UPGRADE: 'ManualReceiptPhotoUpgrade', SPAN_RECEIPT_PREPARE: 'ManualReceiptPrepare', SPAN_SCAN_PROCESS_AND_NAVIGATE: 'ManualScanProcessAndNavigate', SPAN_CONFIRMATION_MOUNT: 'ManualConfirmationMount', @@ -2416,6 +2417,7 @@ const CONST = { ATTRIBUTE_FLASH_USED: 'flash_used', ATTRIBUTE_PHOTO_WIDTH: 'photo_width', ATTRIBUTE_PHOTO_HEIGHT: 'photo_height', + ATTRIBUTE_UPGRADE_OUTCOME: 'upgrade_outcome', ATTRIBUTE_SOURCE: 'source', ATTRIBUTE_ODOMETER_IMAGE_TYPE: 'odometer_image_type', ATTRIBUTE_DURATION_SINCE_NATIVE_APP_STARTUP_MS: 'duration_since_native_app_startup_ms', @@ -2423,6 +2425,13 @@ const CONST = { PHOTO: 'photo', SNAPSHOT: 'snapshot', }, + /** How a receipt captured as a snapshot ended up, once the full-resolution photo had its chance. */ + UPGRADE_OUTCOME: { + UPGRADED: 'upgraded', + NO_PHOTO: 'no_photo', + FAILED: 'failed', + ABANDONED: 'abandoned', + }, SPAN_PLATFORM: { NATIVE: 'native', WEB: 'web', diff --git a/src/libs/ReceiptStorage/receiptUpgrades.ts b/src/libs/ReceiptStorage/receiptUpgrades.ts index b9d9c55bec78..efe452d987d0 100644 --- a/src/libs/ReceiptStorage/receiptUpgrades.ts +++ b/src/libs/ReceiptStorage/receiptUpgrades.ts @@ -6,6 +6,8 @@ * as the upgrade: nothing here survives a relaunch, and a receipt with no entry is simply not changing. */ +import Log from '@libs/Log'; + type PendingUpgrade = { /** Resolves once the upgrade has finished, bailed or failed. */ promise: Promise; @@ -66,7 +68,10 @@ function waitFor(durableName: string, capMs: number): Promise { } return new Promise((settle) => { - const cap = setTimeout(settle, capMs); + const cap = setTimeout(() => { + Log.warn('[ReceiptUpgrades] gave up waiting for a receipt upgrade', {durableName, capMs}); + settle(); + }, capMs); upgrade.promise.then(() => { clearTimeout(cap); settle(); diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx index dc3e03866cd8..f0f941ba1ce9 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx @@ -22,7 +22,7 @@ import ReceiptPreviews from '@pages/iou/request/step/IOURequestStepScan/componen import ScannerControlsBar from '@pages/iou/request/step/IOURequestStepScan/components/ScannerControlsBar'; import getCameraAspectRatio from '@pages/iou/request/step/IOURequestStepScan/getCameraAspectRatio'; import useCameraInitTelemetry from '@pages/iou/request/step/IOURequestStepScan/hooks/useCameraInitTelemetry'; -import useStillPhotoUpgrade from '@pages/iou/request/step/IOURequestStepScan/hooks/useStillPhotoUpgrade'; +import usePhotoUpgrade from '@pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade'; import startReceiptPrepareSpan from '@pages/iou/request/step/IOURequestStepScan/utils/startReceiptPrepareSpan'; import CONST from '@src/CONST'; @@ -113,7 +113,7 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou }; const {handleCameraInitialized} = useCameraInitTelemetry({cameraPermissionStatus, device}); - const {hasPendingStillCapture, captureStill, upgradeReceiptWithStill, discardPendingStill} = useStillPhotoUpgrade(); + const {hasPendingPhotoCapture, startPhotoCapture, upgradeReceiptWithPhoto, discardPendingPhoto} = usePhotoUpgrade(); const maybeCancelShutterSpan = () => { if (isMultiScanEnabled) { @@ -175,14 +175,14 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou // `takeSnapshot` saves a screen-sized screenshot of the preview, so on that path a full-resolution // `takePhoto` runs alongside it and replaces the receipt file once it lands. Nothing below awaits it. - const shouldUpgradeToStill = !isMultiScanEnabled && !shouldTakePhoto({flash, hasFlash, isInLandscapeMode}); + const shouldUpgradeToPhoto = !isMultiScanEnabled && !shouldTakePhoto({flash, hasFlash, isInLandscapeMode}); // The snapshot goes first so its request reaches the native queue ahead of the still. On iOS it // reads the most recent video frame, which a photo capture can interrupt. const receiptCapture = captureReceipt(camera.current, {flash, hasFlash, isPlatformMuted, path, isInLandscapeMode}); - if (shouldUpgradeToStill) { - captureStill(camera.current); + if (shouldUpgradeToPhoto) { + startPhotoCapture(camera.current); } receiptCapture @@ -212,8 +212,8 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou onCapture(cameraFile, source); - if (shouldUpgradeToStill) { - upgradeReceiptWithStill(durableName); + if (shouldUpgradeToPhoto) { + upgradeReceiptWithPhoto(durableName); } }) .catch((error: string) => { @@ -221,7 +221,7 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou maybeCancelShutterSpan(); showCameraAlert(); Log.warn('Error taking photo', error); - discardPendingStill(); + discardPendingPhoto(); }); }; @@ -265,7 +265,7 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou blinkStyle={blinkStyle} isAttachmentPickerActive={isAttachmentPickerActive} didCapturePhoto={didCapturePhoto} - hasPendingStillCapture={hasPendingStillCapture} + hasPendingPhotoCapture={hasPendingPhotoCapture} onInitialized={handleCameraInitialized} canUseMultiScan={canUseMultiScan} cameraPermissionStatus={cameraPermissionStatus} diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx index a70691946e8d..4dcfff538b8c 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx @@ -60,8 +60,8 @@ type CameraViewportProps = { /** Whether a photo has been captured (forces camera inactive) */ didCapturePhoto: boolean; - /** Whether a full-resolution still capture is in flight, which keeps the camera session alive */ - hasPendingStillCapture?: boolean; + /** Whether a full-resolution photo capture is in flight, which keeps the camera session alive */ + hasPendingPhotoCapture?: boolean; /** Callback fired when the camera finishes initializing */ onInitialized: () => void; @@ -92,7 +92,7 @@ function CameraViewport({ blinkStyle, isAttachmentPickerActive, didCapturePhoto, - hasPendingStillCapture = false, + hasPendingPhotoCapture = false, onInitialized, canUseMultiScan, cameraPermissionStatus, @@ -121,8 +121,8 @@ function CameraViewport({ cameraTabIndex={1} // Closing the session cancels an in-flight `takePhoto` with "Camera is closed.", so a // running capture keeps it open. This flag and losing focus would both close it. - forceInactive={isAttachmentPickerActive || (didCapturePhoto && !hasPendingStillCapture)} - shouldStayActiveWhenBlurred={hasPendingStillCapture} + forceInactive={isAttachmentPickerActive || (didCapturePhoto && !hasPendingPhotoCapture)} + shouldStayActiveWhenBlurred={hasPendingPhotoCapture} onInitialized={onInitialized} // Use TextureView on Android to fix partially blank images for takeSnapshot() androidPreviewViewType="texture-view" diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/ScanEditReceipt.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/ScanEditReceipt.tsx index 44b417e9522a..962c7ab420d0 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/ScanEditReceipt.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/ScanEditReceipt.tsx @@ -42,10 +42,11 @@ type ScanEditReceiptProps = { * Simplest variant: no multi-scan, no participants, no confirmation page. */ /** - * How long this screen waits for the better photo before replacing the receipt with what it has. Short, - * because someone is watching a loader while it runs. + * How long the request waits for the better photo before going out with what is on disk. It matches the + * window `ReceiptStorage.locate` gives every other flow, and costs nothing on screen because the page has + * already gone back by then. */ -const RECEIPT_UPGRADE_WAIT_MS = 1500; +const RECEIPT_UPGRADE_WAIT_MS = 10000; function ScanEditReceipt({report, transactionID, backTo, isEditing}: ScanEditReceiptProps) { const {translate} = useLocalize(); diff --git a/src/pages/iou/request/step/IOURequestStepScan/hooks/useStillPhotoUpgrade.ts b/src/pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade.ts similarity index 50% rename from src/pages/iou/request/step/IOURequestStepScan/hooks/useStillPhotoUpgrade.ts rename to src/pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade.ts index 40943f2b1276..53387b3aab77 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/hooks/useStillPhotoUpgrade.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade.ts @@ -1,8 +1,11 @@ import Log from '@libs/Log'; import ReceiptStorage from '@libs/ReceiptStorage'; import {finish as finishUpgrade, start as startUpgrade} from '@libs/ReceiptStorage/receiptUpgrades'; +import {endSpanWithAttributes, startSpan} from '@libs/telemetry/activeSpans'; -import rotateStillToUpright from '@pages/iou/request/step/IOURequestStepScan/utils/rotateStillToUpright'; +import rotatePhotoToUpright from '@pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright'; + +import CONST from '@src/CONST'; import type {Camera, PhotoFile} from 'react-native-vision-camera'; @@ -10,62 +13,62 @@ import {useRef, useState} from 'react'; import RNFS from 'react-native-fs'; /** - * Runs the full-resolution still capture that upgrades a receipt taken with `takeSnapshot`. + * Runs the full-resolution photo capture that upgrades a receipt taken with `takeSnapshot`. */ /** - * How long the camera session is held open waiting for the still. After that the receipt keeps the + * How long the camera session is held open waiting for the photo. After that the receipt keeps the * snapshot, which is how this path behaved before the upgrade. */ -const STILL_CAPTURE_TIMEOUT_MS = 3000; +const PHOTO_CAPTURE_TIMEOUT_MS = 3000; /** - * Deletes a still that has no consumer. A failure only leaves a file in the OS temp directory, so it is + * Deletes a photo that has no consumer. A failure only leaves a file in the OS temp directory, so it is * logged and not surfaced. */ -function discardStill(path: string) { +function discardPhoto(path: string) { ReceiptStorage.discard(path).catch((error: unknown) => { - Log.warn('[StillPhotoUpgrade] could not delete the abandoned still', {error: error instanceof Error ? error.message : String(error)}); + Log.warn('[PhotoUpgrade] could not delete the abandoned photo', {error: error instanceof Error ? error.message : String(error)}); }); } /** - * Deletes the still once the pending capture produces one. Callers set `isDiscarded` first, so a still + * Deletes the photo once the pending capture produces one. Callers set `isDiscarded` first, so a photo * that lands before this runs is deleted by the capture handler instead. */ -function discardWhenItLands(pending: PendingStill) { +function discardWhenItLands(pending: PendingPhoto) { pending.promise .then((photo) => { if (!photo) { return; } - discardStill(photo.path); + discardPhoto(photo.path); }) .catch(() => { // `promise` resolves instead of rejecting, so there is nothing to clean up here. }); } -type PendingStill = { - /** Resolves with the temporary still, or `undefined` when the capture failed or missed the deadline. */ +type PendingPhoto = { + /** Resolves with the temporary photo, or `undefined` when the capture failed or missed the deadline. */ promise: Promise; - /** Set once the still has no consumer, so anything that lands afterwards is deleted. */ + /** Set once the photo has no consumer, so anything that lands afterwards is deleted. */ isDiscarded: boolean; }; /** * Upgrades a receipt captured with `takeSnapshot`, a screen-sized screenshot of the preview, to the - * full-resolution `takePhoto` still. + * full-resolution `takePhoto` photo. * - * `captureStill` starts the still at the shutter and nothing awaits it, so the snapshot keeps driving - * navigation, and `upgradeReceiptWithStill` swaps the still onto that receipt in place under the same - * durable name. `hasPendingStillCapture` holds the camera session open, since closing it during a capture - * cancels the still with "Camera is closed." + * `startPhotoCapture` starts the photo at the shutter and nothing awaits it, so the snapshot keeps driving + * navigation, and `upgradeReceiptWithPhoto` swaps the photo onto that receipt in place under the same + * durable name. `hasPendingPhotoCapture` holds the camera session open, since closing it during a capture + * cancels the photo with "Camera is closed." */ -function useStillPhotoUpgrade() { - const [hasPendingStillCapture, setHasPendingStillCapture] = useState(false); - const pendingStillRef = useRef(undefined); +function usePhotoUpgrade() { + const [hasPendingPhotoCapture, setHasPendingPhotoCapture] = useState(false); + const pendingPhotoRef = useRef(undefined); // Retaking a receipt can leave two captures in flight, so count them. With a single flag, the first // capture to settle would close the session under the second one. @@ -76,64 +79,72 @@ function useStillPhotoUpgrade() { if (captureCountRef.current > 0) { return; } - setHasPendingStillCapture(false); + setHasPendingPhotoCapture(false); }; - const captureStill = (camera: Camera) => { - const pending: PendingStill = {isDiscarded: false, promise: Promise.resolve(undefined)}; + const startPhotoCapture = (camera: Camera) => { + const pending: PendingPhoto = {isDiscarded: false, promise: Promise.resolve(undefined)}; - // The still goes to the temp directory, not the receipts folder, so ReceiptStorage stays the only - // writer there and an abandoned still never sits among the receipts looking like one. + // The photo goes to the temp directory, not the receipts folder, so ReceiptStorage stays the only + // writer there and an abandoned photo never sits among the receipts looking like one. const capture = camera.takePhoto({flash: 'off', enableShutterSound: false, path: RNFS.TemporaryDirectoryPath}).then( (photo) => { if (!pending.isDiscarded) { return photo; } // This one has no consumer, so it does not stay on disk. - discardStill(photo.path); + discardPhoto(photo.path); return undefined; }, (error: unknown) => { - Log.warn('[StillPhotoUpgrade] full-resolution capture failed', {error: error instanceof Error ? error.message : String(error)}); + Log.warn('[PhotoUpgrade] full-resolution capture failed', {error: error instanceof Error ? error.message : String(error)}); return undefined; }, ); // The deadline outlives the screen on purpose, since the swap finishes after the scan page is gone - // and a late arrival still needs deleting. + // and a late arrival needs deleting too. let deadlineTimeout: ReturnType; const deadline = new Promise((resolve) => { deadlineTimeout = setTimeout(() => { pending.isDiscarded = true; resolve(undefined); - }, STILL_CAPTURE_TIMEOUT_MS); + }, PHOTO_CAPTURE_TIMEOUT_MS); }); - // The session is released when the race settles, not when the swap finishes. Once the still is on + // The session is released when the race settles, not when the swap finishes. Once the photo is on // disk the camera is free, and the rotate and replace steps run without it. pending.promise = Promise.race([capture, deadline]).then((photo) => { clearTimeout(deadlineTimeout); releaseSession(); if (!photo) { - Log.info('[StillPhotoUpgrade] no still to upgrade with, keeping the snapshot'); + Log.info('[PhotoUpgrade] no photo to upgrade with, keeping the snapshot'); return undefined; } return photo; }); - pendingStillRef.current = pending; + pendingPhotoRef.current = pending; captureCountRef.current += 1; - setHasPendingStillCapture(true); + setHasPendingPhotoCapture(true); + + // Nothing about this upgrade is visible to the user, so the span is the only way to tell a receipt + // that was upgraded from one that quietly kept its snapshot. + startSpan(CONST.TELEMETRY.SPAN_RECEIPT_PHOTO_UPGRADE, { + name: CONST.TELEMETRY.SPAN_RECEIPT_PHOTO_UPGRADE, + op: CONST.TELEMETRY.SPAN_RECEIPT_PHOTO_UPGRADE, + attributes: {[CONST.TELEMETRY.ATTRIBUTE_PLATFORM]: CONST.TELEMETRY.SPAN_PLATFORM.NATIVE}, + }); }; - const upgradeReceiptWithStill = (durableName: string) => { - const pending = pendingStillRef.current; + const upgradeReceiptWithPhoto = (durableName: string) => { + const pending = pendingPhotoRef.current; if (!pending) { return; } - pendingStillRef.current = undefined; + pendingPhotoRef.current = undefined; // Anything that reads or shows this receipt can now wait for the better file, or refresh once it // arrives, instead of taking the snapshot it is about to replace. @@ -142,45 +153,59 @@ function useStillPhotoUpgrade() { pending.promise .then((photo) => { if (!photo) { + endSpanWithAttributes(CONST.TELEMETRY.SPAN_RECEIPT_PHOTO_UPGRADE, { + [CONST.TELEMETRY.ATTRIBUTE_UPGRADE_OUTCOME]: CONST.TELEMETRY.UPGRADE_OUTCOME.NO_PHOTO, + }); return undefined; } - return rotateStillToUpright(photo.path).then((uprightPath) => + return rotatePhotoToUpright(photo.path).then((uprightPath) => ReceiptStorage.overwrite(durableName, uprightPath ?? photo.path).then(() => { - Log.info('[StillPhotoUpgrade] receipt upgraded to the full-resolution still', false, { + Log.info('[PhotoUpgrade] receipt upgraded to the full-resolution photo', false, { width: photo.width, height: photo.height, wasRotated: !!uprightPath, }); + endSpanWithAttributes(CONST.TELEMETRY.SPAN_RECEIPT_PHOTO_UPGRADE, { + [CONST.TELEMETRY.ATTRIBUTE_UPGRADE_OUTCOME]: CONST.TELEMETRY.UPGRADE_OUTCOME.UPGRADED, + [CONST.TELEMETRY.ATTRIBUTE_PHOTO_WIDTH]: photo.width, + [CONST.TELEMETRY.ATTRIBUTE_PHOTO_HEIGHT]: photo.height, + }); if (!uprightPath) { return; } // `overwrite` moved the rotated copy, so the original is left behind, and its cleanup // must not fail an upgrade that already went through. - discardStill(photo.path); + discardPhoto(photo.path); }), ); }) .catch((error: unknown) => { - Log.warn('[StillPhotoUpgrade] keeping the snapshot, upgrade failed', {error: error instanceof Error ? error.message : String(error)}); + Log.warn('[PhotoUpgrade] keeping the snapshot, upgrade failed', {error: error instanceof Error ? error.message : String(error)}); + endSpanWithAttributes(CONST.TELEMETRY.SPAN_RECEIPT_PHOTO_UPGRADE, { + [CONST.TELEMETRY.ATTRIBUTE_UPGRADE_OUTCOME]: CONST.TELEMETRY.UPGRADE_OUTCOME.FAILED, + }); pending.isDiscarded = true; discardWhenItLands(pending); }) .finally(() => finishUpgrade(durableName)); }; - const discardPendingStill = () => { - const pending = pendingStillRef.current; + const discardPendingPhoto = () => { + const pending = pendingPhotoRef.current; if (!pending) { return; } - pendingStillRef.current = undefined; + pendingPhotoRef.current = undefined; + endSpanWithAttributes(CONST.TELEMETRY.SPAN_RECEIPT_PHOTO_UPGRADE, { + [CONST.TELEMETRY.ATTRIBUTE_UPGRADE_OUTCOME]: CONST.TELEMETRY.UPGRADE_OUTCOME.ABANDONED, + }); pending.isDiscarded = true; discardWhenItLands(pending); }; - return {hasPendingStillCapture, captureStill, upgradeReceiptWithStill, discardPendingStill}; + return {hasPendingPhotoCapture, startPhotoCapture, upgradeReceiptWithPhoto, discardPendingPhoto}; } -export default useStillPhotoUpgrade; +export default usePhotoUpgrade; diff --git a/src/pages/iou/request/step/IOURequestStepScan/utils/rotateStillToUpright.ts b/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts similarity index 76% rename from src/pages/iou/request/step/IOURequestStepScan/utils/rotateStillToUpright.ts rename to src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts index 06f2467d1d99..e62e1cdf3d88 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/utils/rotateStillToUpright.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts @@ -5,14 +5,14 @@ import {ImageManipulator, SaveFormat} from 'expo-image-manipulator'; import ImageSize from 'react-native-image-size'; /** - * Turns a full-resolution camera still upright before it replaces a scanned receipt. + * Turns a full-resolution camera photo upright before it replaces a scanned receipt. */ -type StillSize = { - /** Pixel width of the still as it sits on disk */ +type PhotoSize = { + /** Pixel width of the photo as it sits on disk */ width: number; - /** Pixel height of the still as it sits on disk */ + /** Pixel height of the photo as it sits on disk */ height: number; /** Clockwise degrees the file's metadata asks for, which only Android reports */ @@ -20,16 +20,16 @@ type StillSize = { }; /** - * Clockwise degrees to turn the still by, or `undefined` to keep the file as captured. + * Clockwise degrees to turn the photo by, or `undefined` to keep the file as captured. * * Measure the frame the way the image loader hands it over, since the loader applies the file's rotation - * metadata while decoding. Verified on device: a 1920x1440 still whose metadata asks for 90 decodes as + * metadata while decoding. Verified on device: a 1920x1440 photo whose metadata asks for 90 decodes as * 1440x1920, so rotating it again put it back on its side. * * A returned `0` is not a no-op. Re-encoding bakes the rotation into the pixels and drops the tag, which * the confirmation preview and the receipt on the server both ignore. */ -function getUprightRotation({width, height, rotation = 0}: StillSize): number | undefined { +function getUprightRotation({width, height, rotation = 0}: PhotoSize): number | undefined { const isSideways = rotation === 90 || rotation === 270; const decodedWidth = isSideways ? height : width; const decodedHeight = isSideways ? width : height; @@ -47,12 +47,12 @@ function getUprightRotation({width, height, rotation = 0}: StillSize): number | } /** - * Bakes the rotation of a full-resolution camera still into its pixels. Rejects instead of falling back to - * the still as captured, since a receipt lying on its side is worse than the snapshot the caller has. + * Bakes the rotation of a full-resolution camera photo into its pixels. Rejects instead of falling back to + * the photo as captured, since a receipt lying on its side is worse than the snapshot the caller has. * - * @returns path of an upright temporary copy, or `undefined` when the still needs no turn + * @returns path of an upright temporary copy, or `undefined` when the photo needs no turn */ -function rotateStillToUpright(stillPath: string): Promise { +function rotatePhotoToUpright(stillPath: string): Promise { const sourceUri = getPhotoSource(stillPath); return ImageSize.getSize(sourceUri).then((imageSize) => { @@ -73,5 +73,5 @@ function rotateStillToUpright(stillPath: string): Promise { }); } -export default rotateStillToUpright; +export default rotatePhotoToUpright; export {getUprightRotation}; diff --git a/tests/unit/hooks/useStillPhotoUpgrade.test.ts b/tests/unit/hooks/usePhotoUpgrade.test.ts similarity index 62% rename from tests/unit/hooks/useStillPhotoUpgrade.test.ts rename to tests/unit/hooks/usePhotoUpgrade.test.ts index a6bffc87d63c..d06e6ac9b9d8 100644 --- a/tests/unit/hooks/useStillPhotoUpgrade.test.ts +++ b/tests/unit/hooks/usePhotoUpgrade.test.ts @@ -1,6 +1,6 @@ import {act, renderHook} from '@testing-library/react-native'; -import useStillPhotoUpgrade from '@pages/iou/request/step/IOURequestStepScan/hooks/useStillPhotoUpgrade'; +import usePhotoUpgrade from '@pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade'; import type {Camera, PhotoFile} from 'react-native-vision-camera'; @@ -24,7 +24,7 @@ jest.mock('@libs/ReceiptStorage', () => ({ const mockRotate = jest.fn, [string]>(); -jest.mock('@pages/iou/request/step/IOURequestStepScan/utils/rotateStillToUpright', () => ({ +jest.mock('@pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright', () => ({ __esModule: true, default: (path: string) => mockRotate(path), })); @@ -32,11 +32,11 @@ jest.mock('@pages/iou/request/step/IOURequestStepScan/utils/rotateStillToUpright jest.mock('react-native-fs', () => ({TemporaryDirectoryPath: '/tmp'})); const DURABLE_NAME = 'receipt_1234.jpg'; -const STILL_PATH = '/tmp/still.jpg'; +const PHOTO_PATH = '/tmp/still.jpg'; const UPRIGHT_PATH = '/tmp/ImageManipulator/upright.jpg'; -const STILL_CAPTURE_TIMEOUT_MS = 3000; +const PHOTO_CAPTURE_TIMEOUT_MS = 3000; -function buildStill(path = STILL_PATH): PhotoFile { +function buildPhoto(path = PHOTO_PATH): PhotoFile { return {path, width: 1920, height: 1440, isRawPhoto: false, orientation: 'portrait', isMirrored: false}; } @@ -55,7 +55,7 @@ function buildCamera() { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion camera: {takePhoto} as unknown as Camera, takePhoto, - landStill: async (photo = buildStill(), index = 0) => { + landPhoto: async (photo = buildPhoto(), index = 0) => { await act(async () => { started.at(index)?.resolve(photo); }); @@ -68,7 +68,7 @@ function buildCamera() { }; } -describe('useStillPhotoUpgrade', () => { +describe('usePhotoUpgrade', () => { beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); @@ -82,94 +82,94 @@ describe('useStillPhotoUpgrade', () => { }); it('holds the camera session open from the shutter until the still lands', async () => { - const {camera, landStill} = buildCamera(); - const {result} = renderHook(() => useStillPhotoUpgrade()); + const {camera, landPhoto} = buildCamera(); + const {result} = renderHook(() => usePhotoUpgrade()); - expect(result.current.hasPendingStillCapture).toBe(false); + expect(result.current.hasPendingPhotoCapture).toBe(false); act(() => { - result.current.captureStill(camera); + result.current.startPhotoCapture(camera); }); - expect(result.current.hasPendingStillCapture).toBe(true); + expect(result.current.hasPendingPhotoCapture).toBe(true); - await landStill(); - expect(result.current.hasPendingStillCapture).toBe(false); + await landPhoto(); + expect(result.current.hasPendingPhotoCapture).toBe(false); }); it('swaps the upright still onto the receipt and cleans up the file it rotated from', async () => { - const {camera, landStill} = buildCamera(); - const {result} = renderHook(() => useStillPhotoUpgrade()); + const {camera, landPhoto} = buildCamera(); + const {result} = renderHook(() => usePhotoUpgrade()); act(() => { - result.current.captureStill(camera); + result.current.startPhotoCapture(camera); }); act(() => { - result.current.upgradeReceiptWithStill(DURABLE_NAME); + result.current.upgradeReceiptWithPhoto(DURABLE_NAME); }); - await landStill(); + await landPhoto(); - expect(mockRotate).toHaveBeenCalledWith(STILL_PATH); + expect(mockRotate).toHaveBeenCalledWith(PHOTO_PATH); expect(mockReplace).toHaveBeenCalledWith(DURABLE_NAME, UPRIGHT_PATH); - expect(mockDiscard).toHaveBeenCalledWith(STILL_PATH); + expect(mockDiscard).toHaveBeenCalledWith(PHOTO_PATH); // The upload waits on this hold, so it has to be let go once the receipt is upgraded. expect(mockFinishUpgrade).toHaveBeenCalledWith(DURABLE_NAME); }); it('swaps the still as captured when it needs no rotation, leaving nothing to clean up', async () => { mockRotate.mockResolvedValue(undefined); - const {camera, landStill} = buildCamera(); - const {result} = renderHook(() => useStillPhotoUpgrade()); + const {camera, landPhoto} = buildCamera(); + const {result} = renderHook(() => usePhotoUpgrade()); act(() => { - result.current.captureStill(camera); + result.current.startPhotoCapture(camera); }); act(() => { - result.current.upgradeReceiptWithStill(DURABLE_NAME); + result.current.upgradeReceiptWithPhoto(DURABLE_NAME); }); - await landStill(); + await landPhoto(); - expect(mockReplace).toHaveBeenCalledWith(DURABLE_NAME, STILL_PATH); + expect(mockReplace).toHaveBeenCalledWith(DURABLE_NAME, PHOTO_PATH); expect(mockDiscard).not.toHaveBeenCalled(); }); it('keeps the snapshot and releases the session when the still misses the deadline', async () => { - const {camera, landStill} = buildCamera(); - const {result} = renderHook(() => useStillPhotoUpgrade()); + const {camera, landPhoto} = buildCamera(); + const {result} = renderHook(() => usePhotoUpgrade()); act(() => { - result.current.captureStill(camera); + result.current.startPhotoCapture(camera); }); act(() => { - result.current.upgradeReceiptWithStill(DURABLE_NAME); + result.current.upgradeReceiptWithPhoto(DURABLE_NAME); }); await act(async () => { - jest.advanceTimersByTime(STILL_CAPTURE_TIMEOUT_MS); + jest.advanceTimersByTime(PHOTO_CAPTURE_TIMEOUT_MS); }); - expect(result.current.hasPendingStillCapture).toBe(false); + expect(result.current.hasPendingPhotoCapture).toBe(false); expect(mockReplace).not.toHaveBeenCalled(); expect(mockFinishUpgrade).toHaveBeenCalledWith(DURABLE_NAME); // A still that turns up after the deadline has no consumer, so it must not stay on disk. - await landStill(); - expect(mockDiscard).toHaveBeenCalledWith(STILL_PATH); + await landPhoto(); + expect(mockDiscard).toHaveBeenCalledWith(PHOTO_PATH); expect(mockReplace).not.toHaveBeenCalled(); }); it('keeps the snapshot when the capture itself fails', async () => { const {camera, failCapture} = buildCamera(); - const {result} = renderHook(() => useStillPhotoUpgrade()); + const {result} = renderHook(() => usePhotoUpgrade()); act(() => { - result.current.captureStill(camera); + result.current.startPhotoCapture(camera); }); act(() => { - result.current.upgradeReceiptWithStill(DURABLE_NAME); + result.current.upgradeReceiptWithPhoto(DURABLE_NAME); }); await failCapture(); - expect(result.current.hasPendingStillCapture).toBe(false); + expect(result.current.hasPendingPhotoCapture).toBe(false); expect(mockReplace).not.toHaveBeenCalled(); expect(mockDiscard).not.toHaveBeenCalled(); // A capture that failed must not leave the upload waiting on it. @@ -177,42 +177,42 @@ describe('useStillPhotoUpgrade', () => { }); it('deletes the still when the receipt it was meant for never arrived', async () => { - const {camera, landStill} = buildCamera(); - const {result} = renderHook(() => useStillPhotoUpgrade()); + const {camera, landPhoto} = buildCamera(); + const {result} = renderHook(() => usePhotoUpgrade()); act(() => { - result.current.captureStill(camera); + result.current.startPhotoCapture(camera); }); act(() => { - result.current.discardPendingStill(); + result.current.discardPendingPhoto(); }); - await landStill(); + await landPhoto(); expect(mockReplace).not.toHaveBeenCalled(); - expect(mockDiscard).toHaveBeenCalledWith(STILL_PATH); + expect(mockDiscard).toHaveBeenCalledWith(PHOTO_PATH); }); it('keeps the session open for a retake when the first still settles after it started', async () => { - const {camera, takePhoto, landStill} = buildCamera(); - const {result} = renderHook(() => useStillPhotoUpgrade()); + const {camera, takePhoto, landPhoto} = buildCamera(); + const {result} = renderHook(() => usePhotoUpgrade()); act(() => { - result.current.captureStill(camera); + result.current.startPhotoCapture(camera); }); act(() => { - result.current.upgradeReceiptWithStill(DURABLE_NAME); + result.current.upgradeReceiptWithPhoto(DURABLE_NAME); }); // The user came back to the camera and shot again before the first still landed. act(() => { - result.current.captureStill(camera); + result.current.startPhotoCapture(camera); }); expect(takePhoto).toHaveBeenCalledTimes(2); - await landStill(buildStill(), 0); - expect(result.current.hasPendingStillCapture).toBe(true); + await landPhoto(buildPhoto(), 0); + expect(result.current.hasPendingPhotoCapture).toBe(true); - await landStill(buildStill('/tmp/still2.jpg'), 1); - expect(result.current.hasPendingStillCapture).toBe(false); + await landPhoto(buildPhoto('/tmp/still2.jpg'), 1); + expect(result.current.hasPendingPhotoCapture).toBe(false); }); }); diff --git a/tests/unit/rotateStillToUprightTest.ts b/tests/unit/rotatePhotoToUprightTest.ts similarity index 97% rename from tests/unit/rotateStillToUprightTest.ts rename to tests/unit/rotatePhotoToUprightTest.ts index 4da1e340cdd4..b01bc41dcbca 100644 --- a/tests/unit/rotateStillToUprightTest.ts +++ b/tests/unit/rotatePhotoToUprightTest.ts @@ -1,4 +1,4 @@ -import {getUprightRotation} from '@pages/iou/request/step/IOURequestStepScan/utils/rotateStillToUpright'; +import {getUprightRotation} from '@pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright'; describe('getUprightRotation', () => { it('only re-encodes a still whose metadata already describes it upright, because the decoder applied that rotation', () => { From 8a5679ec4eea06ba0a469e8c2a242cb7f5f97222 Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 10 Sep 2026 13:43:05 +0200 Subject: [PATCH 07/23] Register the upgrade before handing over the receipt --- .../IOURequestStepScan/components/Camera/index.native.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx index f0f941ba1ce9..68be27233758 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx @@ -210,11 +210,11 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou type: 'image/jpeg', }; - onCapture(cameraFile, source); - if (shouldUpgradeToPhoto) { upgradeReceiptWithPhoto(durableName); } + + onCapture(cameraFile, source); }) .catch((error: string) => { isCapturingPhoto.current = false; From 3c1037351ee0bd26425e0301a2ddf0c0c410ded0 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 11 Sep 2026 13:52:23 +0200 Subject: [PATCH 08/23] Keep the receipt upgrade off the request queue --- src/CONST/index.ts | 9 - src/libs/ReceiptStorage/index.native.ts | 139 ++++++++-- src/libs/ReceiptStorage/index.ts | 1 + src/libs/ReceiptStorage/receiptUpgrades.ts | 86 +++--- src/libs/ReceiptStorage/types.ts | 33 ++- .../prepareRequestPayload/index.native.ts | 16 +- .../components/Camera/index.native.tsx | 15 +- .../components/Camera/types.ts | 9 + .../components/ScanEditReceipt.tsx | 35 +-- .../components/ScanSkipConfirmation.tsx | 3 + .../hooks/usePhotoUpgrade.ts | 245 +++++++++++------ .../utils/rotatePhotoToUpright.ts | 24 +- .../components/NavigationAwareCameraTest.tsx | 61 +++++ .../hooks/useLocalReceiptThumbnail.test.ts | 83 ++++++ tests/unit/hooks/usePhotoUpgrade.test.ts | 248 ++++++++++++++++-- .../unit/hooks/useReceiptUpgradeCount.test.ts | 68 +++++ .../libs/prepareRequestPayloadNativeTest.ts | 63 +++++ tests/unit/libs/receiptStorageTest.ts | 222 +++++++++++++++- tests/unit/libs/receiptUpgradesTest.ts | 95 +++++-- tests/unit/rotatePhotoToUprightTest.ts | 26 +- 20 files changed, 1220 insertions(+), 261 deletions(-) create mode 100644 tests/unit/components/NavigationAwareCameraTest.tsx create mode 100644 tests/unit/hooks/useLocalReceiptThumbnail.test.ts create mode 100644 tests/unit/hooks/useReceiptUpgradeCount.test.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 48722a175eb1..197bb84fa302 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2307,7 +2307,6 @@ const CONST = { SPAN_ENTRY_TO_SCAN_READY: 'ManualEntryToScanReady', SPAN_SHUTTER_TO_CONFIRMATION: 'ManualShutterToConfirmation', SPAN_RECEIPT_CAPTURE: 'ManualReceiptCapture', - SPAN_RECEIPT_PHOTO_UPGRADE: 'ManualReceiptPhotoUpgrade', SPAN_RECEIPT_PREPARE: 'ManualReceiptPrepare', SPAN_SCAN_PROCESS_AND_NAVIGATE: 'ManualScanProcessAndNavigate', SPAN_CONFIRMATION_MOUNT: 'ManualConfirmationMount', @@ -2417,7 +2416,6 @@ const CONST = { ATTRIBUTE_FLASH_USED: 'flash_used', ATTRIBUTE_PHOTO_WIDTH: 'photo_width', ATTRIBUTE_PHOTO_HEIGHT: 'photo_height', - ATTRIBUTE_UPGRADE_OUTCOME: 'upgrade_outcome', ATTRIBUTE_SOURCE: 'source', ATTRIBUTE_ODOMETER_IMAGE_TYPE: 'odometer_image_type', ATTRIBUTE_DURATION_SINCE_NATIVE_APP_STARTUP_MS: 'duration_since_native_app_startup_ms', @@ -2425,13 +2423,6 @@ const CONST = { PHOTO: 'photo', SNAPSHOT: 'snapshot', }, - /** How a receipt captured as a snapshot ended up, once the full-resolution photo had its chance. */ - UPGRADE_OUTCOME: { - UPGRADED: 'upgraded', - NO_PHOTO: 'no_photo', - FAILED: 'failed', - ABANDONED: 'abandoned', - }, SPAN_PLATFORM: { NATIVE: 'native', WEB: 'web', diff --git a/src/libs/ReceiptStorage/index.native.ts b/src/libs/ReceiptStorage/index.native.ts index 5ad7257984c4..a4853a477375 100644 --- a/src/libs/ReceiptStorage/index.native.ts +++ b/src/libs/ReceiptStorage/index.native.ts @@ -9,28 +9,34 @@ import RNFS from 'react-native-fs'; import type ReceiptStorage from './types'; -import {waitFor as waitForPendingUpgrade} from './receiptUpgrades'; +import {claimForRead} from './receiptUpgrades'; // A durable name is the bare filename inside the receipts folder. Never store a full path: iOS moves // the app data container on most upgrades, so an absolute path stored before the upgrade names a // directory the device no longer has, even though iOS carried the file itself across. -/** How long a reader waits for an upgrade before taking the file as it stands, so a stall cannot hold an upload open. */ -const UPGRADE_WAIT_TIMEOUT_MS = 10000; - const STAGED_SUFFIX = '.staged'; const BACKUP_SUFFIX = '.backup'; -/** Swaps running right now, by target path. A reader of one of those paths waits on these first. */ +/** + * Swaps running right now, by target path, from the moment `overwrite` is called. Owning an entry stops a + * second swap over the same receipt and keeps the leftover sweep off one mid-swap. + */ const swapsInFlight = new Map>(); /** - * Waits for a swap that owns this path, if one is running. Its outcome is not the caller's business: + * Swaps past their last claim check, by target path. From that point the receipt's own name is about to + * move and no guard can call it off, so this is the only part of a swap a reader ever waits for. + */ +const committedSwaps = new Map>(); + +/** + * Waits for a committed swap over this path, if one is running. Its outcome is not the caller's business: * `overwrite` reports its own failures and leaves the receipt in place either way, so the caller checks the - * file for itself once the swap is done. + * file for itself once the renames are done. */ -async function waitForRunningSwap(target: string) { - await swapsInFlight.get(target)?.catch(() => {}); +async function waitForCommittedSwap(target: string) { + await committedSwaps.get(target)?.catch(() => {}); } async function verify(dir: string, name: string): Promise { @@ -94,15 +100,16 @@ async function discardLeftover(path: string) { * Puts a receipt back when a swap was interrupted between its two renames, which leaves the file under the * backup name with nothing at the receipt's own path. Resolves to whether the receipt is readable now. */ -async function restoreInterruptedSwap(target: string): Promise { +async function restoreInterruptedSwap(target: string, {shouldWaitForRunningSwap = true} = {}): Promise { if (await RNFS.exists(target)) { return true; } // A swap running right now is between its own two renames, so the receipt is missing for a moment // rather than stranded. Moving the backup back here would undo the file that swap is installing. - if (swapsInFlight.has(target)) { - await waitForRunningSwap(target); + if (shouldWaitForRunningSwap && swapsInFlight.has(target)) { + // Housekeeping, not a read, so it waits for the staging half too. + await swapsInFlight.get(target)?.catch(() => {}); return RNFS.exists(target); } @@ -123,7 +130,7 @@ async function restoreInterruptedSwap(target: string): Promise { return true; } -async function swapIntoPlace(dir: string, durableName: string, target: string, uriOrPath: string): Promise { +async function swapIntoPlace(dir: string, durableName: string, target: string, uriOrPath: string, shouldAbort?: () => boolean): Promise { // Neither platform can move a file onto one that already exists. NSFileManager refuses and Android's // `renameTo` is unreliable, so stage the new bytes beside the receipt, then swap them in with two // renames inside the directory. The original keeps a second name until the swap succeeds. @@ -134,6 +141,22 @@ async function swapIntoPlace(dir: string, durableName: string, target: string, u await discard(backupPath); await RNFS.moveFile(fileURIToPath(uriOrPath), stagedPath); + // Last moment at which nothing under the receipt's own name has moved, and everything above yields, so + // an upload can have claimed it since the caller's check. Staging only added a file beside it. + if (shouldAbort?.()) { + await discardLeftover(stagedPath); + throw new Error('[ReceiptStorage] an upload claimed the receipt, so it was left as it stands'); + } + + // Same synchronous block as the check above, so a claim either stops the swap or finds this and waits. + let releaseCommitted: () => void = () => {}; + committedSwaps.set( + target, + new Promise((resolve) => { + releaseCommitted = resolve; + }), + ); + try { await RNFS.moveFile(target, backupPath); await RNFS.moveFile(stagedPath, target); @@ -154,6 +177,11 @@ async function swapIntoPlace(dir: string, durableName: string, target: string, u } throw error; + } finally { + // Either the new bytes are under the receipt's name or the original has been put back. Deleting the + // spare copy below never touches that name, so a reader has nothing left to wait for. + committedSwaps.delete(target); + releaseCommitted(); } // The receipt is in place, so the copy set aside can go. @@ -162,19 +190,58 @@ async function swapIntoPlace(dir: string, durableName: string, target: string, u return verify(dir, durableName); } -const overwrite: ReceiptStorage['overwrite'] = async (durableName, uriOrPath) => { +/** + * Clears what a swap leaves behind by dying part-way: a `.staged` copy nothing consumed, or a `.backup` + * holding the only copy of a receipt. Left alone, a full-size `.backup` stays forever and looks like a + * receipt to anything listing the directory. + */ +async function sweepInterruptedSwaps(dir: string) { + const names = (await RNFS.readDir(dir)).map((entry) => entry.name); + + // A `.backup` is the original receipt under a second name, so restore it wherever the receipt's own + // path is empty. Must never wait on a running swap: `overwrite` awaits the sweep after registering + // itself, so a wait here waits on the caller waiting on us. + await Promise.all( + names.filter((name) => name.endsWith(BACKUP_SUFFIX)).map((name) => restoreInterruptedSwap(`${dir}/${name.slice(0, -BACKUP_SUFFIX.length)}`, {shouldWaitForRunningSwap: false})), + ); + + // Anything still under a temporary name has no owner, whether or not the restore above took from it. + await Promise.all(names.filter((name) => name.endsWith(STAGED_SUFFIX) || name.endsWith(BACKUP_SUFFIX)).map((name) => discardLeftover(`${dir}/${name}`))); +} + +let leftoversSwept: Promise | undefined; + +/** Runs the sweep once per launch. A sweep that fails is logged and not retried: it is only housekeeping. */ +function whenLeftoversSwept(dir: string): Promise { + leftoversSwept ??= sweepInterruptedSwaps(dir).catch((error: unknown) => { + Log.warn('[ReceiptStorage] could not sweep leftover receipt copies', {error: error instanceof Error ? error.message : String(error)}); + }); + return leftoversSwept; +} + +const overwrite: ReceiptStorage['overwrite'] = async (durableName, uriOrPath, shouldAbort) => { const dir = getReceiptsUploadFolderPath(); if (!dir) { throw new Error('[ReceiptStorage] no receipts folder on this platform'); } const target = `${dir}/${durableName}`; - await restoreInterruptedSwap(target); - await verify(dir, durableName); - // Registered while it runs so a reader that catches the receipt mid-swap waits for it instead of - // treating the gap between the two renames as an interrupted swap. - const swap = swapIntoPlace(dir, durableName, target, uriOrPath); + // Two swaps would clear each other's staged and backup names, leaving the loser renaming from a path + // holding the only copy. + if (swapsInFlight.has(target)) { + throw new Error('[ReceiptStorage] a swap is already running for this receipt'); + } + + // Registered before the first await: the checks below read the filesystem, and a reader arriving + // during them must not see the path as free. + const swap = (async () => { + await whenLeftoversSwept(dir); + // This swap already owns `target`, so consulting the map here would be waiting on itself. + await restoreInterruptedSwap(target, {shouldWaitForRunningSwap: false}); + await verify(dir, durableName); + return swapIntoPlace(dir, durableName, target, uriOrPath, shouldAbort); + })(); swapsInFlight.set(target, swap); try { @@ -211,18 +278,42 @@ const resolve: ReceiptStorage['resolve'] = (source) => { return durableName ? toLocalUri(durableName) : source; }; +const settle: ReceiptStorage['settle'] = async (durableName) => { + if (!durableName) { + return; + } + + // An upgrade still capturing or rotating gives up here, and the receipt is sent as it stands. + claimForRead(durableName); + + const dir = getReceiptsUploadFolderPath(); + if (!dir) { + return; + } + + // A swap past its own claim check is committed to its renames, so the only safe thing is to let it + // finish. + await waitForCommittedSwap(`${dir}/${durableName}`); +}; + const locate: ReceiptStorage['locate'] = async (source) => { const uri = resolve(source); if (!uri) { return undefined; } - // A swap in flight is about to change these bytes, and on a busy thread it can take seconds, so read - // the receipt after it finishes rather than shipping the file it is replacing. + // Claimed before reading, so the file cannot move underneath the read. if (isLocalFile(uri)) { + const dir = getReceiptsUploadFolderPath(); const target = fileURIToPath(uri); - await waitForPendingUpgrade(target.split('/').pop() ?? '', UPGRADE_WAIT_TIMEOUT_MS); - await waitForRunningSwap(target); + + // Not awaited: this runs inside the request the sequential queue is processing, and the sweep is + // housekeeping for the folder as a whole. The receipt being read has its own recovery below. + if (dir) { + whenLeftoversSwept(dir).catch(() => {}); + } + + await settle(target.split('/').pop() ?? ''); } if (await checkFileExists(uri)) { @@ -238,6 +329,6 @@ const locate: ReceiptStorage['locate'] = async (source) => { return uri; }; -const receiptStorage: ReceiptStorage = {adopt, overwrite, discard, locate, toLocalUri, resolve}; +const receiptStorage: ReceiptStorage = {adopt, overwrite, discard, locate, settle, toLocalUri, resolve}; export default receiptStorage; diff --git a/src/libs/ReceiptStorage/index.ts b/src/libs/ReceiptStorage/index.ts index 11d3ad75a275..e67845fd4630 100644 --- a/src/libs/ReceiptStorage/index.ts +++ b/src/libs/ReceiptStorage/index.ts @@ -7,6 +7,7 @@ const receiptStorage: ReceiptStorage = { overwrite: (durableName) => Promise.resolve(durableName), discard: () => Promise.resolve(), locate: (source) => Promise.resolve(typeof source === 'string' ? source : undefined), + settle: () => Promise.resolve(), toLocalUri: (durableName) => durableName, resolve: (source) => (typeof source === 'string' ? source : undefined), }; diff --git a/src/libs/ReceiptStorage/receiptUpgrades.ts b/src/libs/ReceiptStorage/receiptUpgrades.ts index efe452d987d0..e27c0c916957 100644 --- a/src/libs/ReceiptStorage/receiptUpgrades.ts +++ b/src/libs/ReceiptStorage/receiptUpgrades.ts @@ -1,24 +1,26 @@ /** - * Tracks receipts whose file is about to be replaced by a better capture, so the rest of the app can wait - * for the swap or react to it instead of reading a receipt that is about to change. + * Tracks receipts whose file is about to be replaced by a better capture. * - * Keyed by durable name, because that is the receipt's identity everywhere else. Entries live only as long - * as the upgrade: nothing here survives a relaunch, and a receipt with no entry is simply not changing. + * Nothing here waits for an upgrade. Every reader is a request the sequential queue is processing, so a + * wait would put camera and rotation time in front of every later write, for an optional improvement. A + * reader claims the receipt instead, and the upgrade drops its photo. + * + * Keyed by durable name. Nothing survives a relaunch, and a receipt with no entry is not changing. */ import Log from '@libs/Log'; -type PendingUpgrade = { - /** Resolves once the upgrade has finished, bailed or failed. */ - promise: Promise; +/** Most receipts one launch keeps a version count for. Trimmed oldest-first beyond this. */ +const MAX_TRACKED_UPGRADE_COUNTS = 50; - /** Settles `promise`. Called by `finish`. */ - settle: () => void; +type PendingUpgrade = { + /** Set once a reader has claimed the receipt, so the file must not change under it. */ + isClaimed: boolean; }; const upgradesInFlight = new Map(); -/** How many times each receipt has been upgraded this launch. Lets a view tell one version from the next. */ +/** How many times each receipt has been upgraded this launch, so a view can tell one version from the next. */ const upgradeCounts = new Map(); const listeners = new Set<() => void>(); @@ -35,11 +37,7 @@ function start(durableName: string) { return; } - let settle: () => void = () => {}; - const promise = new Promise((resolveUpgrade) => { - settle = resolveUpgrade; - }); - upgradesInFlight.set(durableName, {promise, settle}); + upgradesInFlight.set(durableName, {isClaimed: false}); notify(); } @@ -51,48 +49,58 @@ function finish(durableName: string) { } upgradesInFlight.delete(durableName); - upgradeCounts.set(durableName, (upgradeCounts.get(durableName) ?? 0) + 1); - upgrade.settle(); notify(); } /** - * Waits for a receipt to stop changing. Resolves immediately when nothing is in flight, and gives up after - * `capMs` so a stalled upgrade cannot hold a caller open. Never rejects: the caller reads the file either - * way, and the upgrade reports its own failures. + * Records that a receipt's bytes actually changed. A view keys its image source on this count, so it moves + * only when the file did. */ -function waitFor(durableName: string, capMs: number): Promise { - const upgrade = upgradesInFlight.get(durableName); - if (!upgrade) { - return Promise.resolve(); +function recordUpgrade(durableName: string) { + upgradeCounts.set(durableName, (upgradeCounts.get(durableName) ?? 0) + 1); + + // Map iteration is insertion-ordered, and re-setting a key keeps its original position, so the front + // holds the receipts least likely to still be on screen. + while (upgradeCounts.size > MAX_TRACKED_UPGRADE_COUNTS) { + const oldest = upgradeCounts.keys().next().value; + if (oldest === undefined) { + break; + } + upgradeCounts.delete(oldest); } - return new Promise((settle) => { - const cap = setTimeout(() => { - Log.warn('[ReceiptUpgrades] gave up waiting for a receipt upgrade', {durableName, capMs}); - settle(); - }, capMs); - upgrade.promise.then(() => { - clearTimeout(cap); - settle(); - }); - }); + notify(); } /** - * How many upgrades this receipt has been through. A view that renders the file can put this in its image - * source, so the same path counts as a different image once the bytes behind it change. + * Claims the receipt's current bytes, so the upgrade leaves the file alone. Returns straight away. + * + * The upgrade honours this at its last guard before the first rename. A claim landing after that guard + * cannot stop the swap, which is the one case a reader still has to wait out. */ +function claimForRead(durableName: string) { + const upgrade = upgradesInFlight.get(durableName); + if (!upgrade || upgrade.isClaimed) { + return; + } + + upgrade.isClaimed = true; + Log.info('[ReceiptUpgrades] a reader claimed the receipt, so the upgrade will keep the snapshot', false, {durableName}); +} + function getUpgradeCount(durableName: string): number { return upgradeCounts.get(durableName) ?? 0; } -/** Whether this receipt is being upgraded right now. */ +/** Whether a reader has claimed this receipt, meaning its bytes are already on their way to the server. */ +function isClaimedForRead(durableName: string): boolean { + return upgradesInFlight.get(durableName)?.isClaimed ?? false; +} + function isUpgrading(durableName: string): boolean { return upgradesInFlight.has(durableName); } -/** Subscribes to changes, for components that show a receipt while it is being upgraded. */ function subscribe(listener: () => void): () => void { listeners.add(listener); return () => { @@ -100,4 +108,4 @@ function subscribe(listener: () => void): () => void { }; } -export {start, finish, waitFor, isUpgrading, getUpgradeCount, subscribe}; +export {start, finish, recordUpgrade, claimForRead, isUpgrading, isClaimedForRead, getUpgradeCount, subscribe}; diff --git a/src/libs/ReceiptStorage/types.ts b/src/libs/ReceiptStorage/types.ts index 49b543222609..8638341dc68e 100644 --- a/src/libs/ReceiptStorage/types.ts +++ b/src/libs/ReceiptStorage/types.ts @@ -2,11 +2,12 @@ import type {ReceiptSource} from '@src/types/onyx/Transaction'; /* * A receipt is identified by its durable name for as long as it exists, and a better capture replaces the - * bytes behind that name rather than arriving as a second file. The alternative, pointing the receipt at a - * new file, was tried and abandoned: the upload reads the source off the in-memory receipt object and the - * replace-receipt screen never goes through the code that builds it, so there is no single place to update - * the pointer. Overwriting in place reaches every reader without touching any of them, at the cost of the - * coordination in `receiptUpgrades` and the staged swap below. + * bytes behind that name rather than arriving as a second file. + * + * Pointing the receipt at a new file instead has no single place to update the pointer: the upload reads + * the source off the in-memory receipt object, and the replace-receipt screen never goes through the code + * that builds it. Overwriting in place reaches every reader without touching any of them, at the cost of + * the coordination in `receiptUpgrades` and the staged swap below. */ /** Owns the receipts folder. The only code that writes a file there, names one, or resolves one. */ @@ -16,10 +17,16 @@ type ReceiptStorage = { /** * Swaps the bytes behind an adopted receipt, keeping its durable name so the confirmation preview, the - * upload and replace-receipt all read the new file. Rejects if the receipt is not in durable storage or - * the swap fails, leaving the original in place. + * upload and replace-receipt all read the new file. Rejects if the receipt is not in durable storage, + * another swap over the same receipt is already running, or the swap fails — in every case leaving the + * original in place. + * + * `shouldAbort` is consulted once more at the last moment before the receipt's own path changes. The + * work before that point yields several times, so a reader can claim the receipt after the caller's own + * check; asking again here means the swap backs out instead of renaming underneath a read in progress. + * Past that point the swap is committed, and a reader arriving late waits for the renames instead. */ - overwrite: (durableName: string, uriOrPath: string) => Promise; + overwrite: (durableName: string, uriOrPath: string, shouldAbort?: () => boolean) => Promise; /** Deletes a temporary file the app is done with. Resolves even when the file is already gone. */ discard: (uriOrPath: string) => Promise; @@ -30,6 +37,16 @@ type ReceiptStorage = { */ locate: (source: ReceiptSource | null | undefined) => Promise; + /** + * Claims the receipt's current bytes for a caller about to send them, and resolves once its name is + * stable. An optional upgrade still preparing a better file gives up rather than being waited for, so + * the only wait here is a swap already committed to its renames — two renames inside one directory. + * + * `locate` claims on behalf of the callers it resolves a path for; a caller that sends the file object + * itself has to claim here directly. + */ + settle: (durableName: string) => Promise; + /** Valid for this launch only, so never store the result. */ toLocalUri: (durableName: string) => string; diff --git a/src/libs/prepareRequestPayload/index.native.ts b/src/libs/prepareRequestPayload/index.native.ts index 2b78a1621d23..cc1e584bed36 100644 --- a/src/libs/prepareRequestPayload/index.native.ts +++ b/src/libs/prepareRequestPayload/index.native.ts @@ -23,6 +23,12 @@ const prepareRequestPayload: PrepareRequestPayload = (command, data, initiatedOf return Promise.resolve(); } + // The assertion is FormData's signature being narrower than what request data can hold. + const appendValueAsIs = () => { + validateFormDataParameter(command, key, value); + formData.append(key, value as string | Blob); + }; + if (key === 'receipt') { const {source, name, type, receiptTraceId} = value as Omit & Pick; @@ -47,6 +53,13 @@ const prepareRequestPayload: PrepareRequestPayload = (command, data, initiatedOf formData.append(key, receiptFormData as File); }); } + + // ReplaceReceipt sends the file object rather than a receipt source, so it never reaches + // `locate` and claims here instead. Claiming here rather than before the action keeps the + // optimistic write and queue entry immediate, so a receipt replaced offline is never lost. + if (name) { + return ReceiptStorage.settle(name).then(appendValueAsIs); + } } if (key === 'file' && initiatedOffline) { @@ -69,8 +82,7 @@ const prepareRequestPayload: PrepareRequestPayload = (command, data, initiatedOf }); } - validateFormDataParameter(command, key, value); - formData.append(key, value as string | Blob); + appendValueAsIs(); return Promise.resolve(); }); diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx index 68be27233758..bbf4d3f10812 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx @@ -45,7 +45,7 @@ const BLINK_DURATION_MS = 80; * Renders a react-native-vision-camera viewfinder with shutter, flash toggle, gallery picker, and focus gesture. * Calls `onCapture(file, source)` for each photo taken or file picked from the gallery. */ -function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayout, onAttachmentPickerStatusChange, onMultiScanSubmit}: CameraProps) { +function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayout, onAttachmentPickerStatusChange, onMultiScanSubmit, canUpgradeReceiptQuality = true}: CameraProps) { const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -175,16 +175,15 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou // `takeSnapshot` saves a screen-sized screenshot of the preview, so on that path a full-resolution // `takePhoto` runs alongside it and replaces the receipt file once it lands. Nothing below awaits it. - const shouldUpgradeToPhoto = !isMultiScanEnabled && !shouldTakePhoto({flash, hasFlash, isInLandscapeMode}); + const shouldUpgradeToPhoto = canUpgradeReceiptQuality && !isMultiScanEnabled && !shouldTakePhoto({flash, hasFlash, isInLandscapeMode}); // The snapshot goes first so its request reaches the native queue ahead of the still. On iOS it // reads the most recent video frame, which a photo capture can interrupt. const receiptCapture = captureReceipt(camera.current, {flash, hasFlash, isPlatformMuted, path, isInLandscapeMode}); - if (shouldUpgradeToPhoto) { - startPhotoCapture(camera.current); - } - + // Handlers attached before the photo capture starts: a synchronous throw from `startPhotoCapture` + // would otherwise leave this promise unhandled, losing the receipt with no alert. The native + // snapshot request is already out, so this does not change the order they reach the camera. receiptCapture .then((photo: PhotoFile) => { endSpanWithAttributes(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE, { @@ -223,6 +222,10 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou Log.warn('Error taking photo', error); discardPendingPhoto(); }); + + if (shouldUpgradeToPhoto) { + startPhotoCapture(camera.current); + } }; // Wait for camera permission status to render diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/types.ts b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/types.ts index 4bfae6924dfc..d3c312647f39 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/types.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/types.ts @@ -25,6 +25,15 @@ type CameraProps = { /** Called when the user taps the multi-scan submit button on the preview ribbon. Used by native and mobile web. */ onMultiScanSubmit?: () => void; + + /** + * Whether a portrait, flash-off capture may be upgraded to the full-resolution photo after the shutter. + * + * Only a screen that stays mounted while the capture finishes can do this, since closing the camera + * session cancels the photo. Screens that pop or dismiss on capture opt out, because attempting it + * costs a capture and a temporary file and records a quality miss that never had a chance. + */ + canUpgradeReceiptQuality?: boolean; }; export default CameraProps; diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/ScanEditReceipt.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/ScanEditReceipt.tsx index 962c7ab420d0..f0a5f01676d3 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/ScanEditReceipt.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/ScanEditReceipt.tsx @@ -8,7 +8,6 @@ import usePolicy from '@hooks/usePolicy'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Navigation from '@libs/Navigation/Navigation'; import navigationRef from '@libs/Navigation/navigationRef'; -import {waitFor as waitForReceiptUpgrade} from '@libs/ReceiptStorage/receiptUpgrades'; import {cancelSpan} from '@libs/telemetry/activeSpans'; import getFileSource from '@pages/iou/request/step/IOURequestStepScan/utils/getFileSource'; @@ -41,13 +40,6 @@ type ScanEditReceiptProps = { * ScanEditReceipt — replace an existing receipt and navigate back. * Simplest variant: no multi-scan, no participants, no confirmation page. */ -/** - * How long the request waits for the better photo before going out with what is on disk. It matches the - * window `ReceiptStorage.locate` gives every other flow, and costs nothing on screen because the page has - * already gone back by then. - */ -const RECEIPT_UPGRADE_WAIT_MS = 10000; - function ScanEditReceipt({report, transactionID, backTo, isEditing}: ScanEditReceiptProps) { const {translate} = useLocalize(); const policy = usePolicy(report?.policyID); @@ -77,21 +69,15 @@ function ScanEditReceipt({report, transactionID, backTo, isEditing}: ScanEditRec cancelSpan(CONST.TELEMETRY.SPAN_RECEIPT_PREPARE); if (isEditing) { setMoneyRequestReceipt(transactionID, source, file.name ?? '', false, file.type); - - // `replaceReceipt` sends the file object itself rather than a receipt source, so this upload - // never reaches `ReceiptStorage.locate` and cannot wait there. Hold the request back until the - // full-resolution photo has landed, while the screen goes back straight away. - waitForReceiptUpgrade(file.name ?? '', RECEIPT_UPGRADE_WAIT_MS).then(() => { - replaceReceipt({ - transaction, - file: file as File, - source, - transactionPolicy: policy, - transactionPolicyCategories: policyCategories, - transactionPolicyTagList: policyTagList, - transactionViolations, - transactionReport, - }); + replaceReceipt({ + transaction, + file: file as File, + source, + transactionPolicy: policy, + transactionPolicyCategories: policyCategories, + transactionPolicyTagList: policyTagList, + transactionViolations, + transactionReport, }); } else { setMoneyRequestReceipt(transactionID, source, file.name ?? '', true, file.type); @@ -121,6 +107,9 @@ function ScanEditReceipt({report, transactionID, backTo, isEditing}: ScanEditRec onPicked={validateFiles} onAttachmentPickerStatusChange={setIsLoaderVisible} isReplacingReceipt + // This screen goes back on capture, which unmounts the camera and cancels a photo still in + // flight, so the upgrade could not land here even when it is wanted. + canUpgradeReceiptQuality={false} /> ); diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx index 0803ea63b552..38b5e9661036 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx @@ -448,6 +448,9 @@ function ScanSkipConfirmation({report, action, iouType, reportID, transactionID, onAttachmentPickerStatusChange={setIsLoaderVisible} onMultiScanSubmit={submitMultiScan} shouldAcceptMultipleFiles + // Submitting dismisses this screen, which unmounts the camera and cancels a photo still in + // flight, so the upgrade could not land here even when it is wanted. + canUpgradeReceiptQuality={false} /> { Log.warn('[PhotoUpgrade] could not delete the abandoned photo', {error: error instanceof Error ? error.message : String(error)}); }); } -/** - * Deletes the photo once the pending capture produces one. Callers set `isDiscarded` first, so a photo - * that lands before this runs is deleted by the capture handler instead. - */ +/** Callers call `discard` first, so a photo landing before this runs is deleted by the capture handler. */ function discardWhenItLands(pending: PendingPhoto) { pending.promise .then((photo) => { @@ -49,31 +39,83 @@ function discardWhenItLands(pending: PendingPhoto) { }); } +/** + * Rejects when `promise` outlives `capMs`, so a step that never settles cannot hold the upgrade open. + * `discardLateResult` deletes a result that arrives afterwards, which nothing else is left to clean up. + */ +function withDeadline(promise: Promise, capMs: number, step: string, discardLateResult?: (value: T) => void): Promise { + let cap: ReturnType; + let hasTimedOut = false; + const deadline = new Promise((_resolve, reject) => { + cap = setTimeout(() => { + hasTimedOut = true; + reject(new Error(`[PhotoUpgrade] ${step} did not finish within ${capMs}ms`)); + }, capMs); + }); + + if (discardLateResult) { + promise + .then((value) => { + if (!hasTimedOut) { + return; + } + discardLateResult(value); + }) + .catch(() => { + // A rejection is the race's to report, and there is no result to clean up. + }); + } + + return Promise.race([promise, deadline]).finally(() => clearTimeout(cap)); +} + type PendingPhoto = { - /** Resolves with the temporary photo, or `undefined` when the capture failed or missed the deadline. */ + /** Resolves `undefined` when the capture failed or missed the deadline. */ promise: Promise; - /** Set once the photo has no consumer, so anything that lands afterwards is deleted. */ - isDiscarded: boolean; + /** Gives up on the photo, so whatever lands afterwards is deleted instead of kept. */ + discard: () => void; }; +function abandonPending(pending: PendingPhoto) { + pending.discard(); + discardWhenItLands(pending); +} + /** * Upgrades a receipt captured with `takeSnapshot`, a screen-sized screenshot of the preview, to the * full-resolution `takePhoto` photo. * - * `startPhotoCapture` starts the photo at the shutter and nothing awaits it, so the snapshot keeps driving - * navigation, and `upgradeReceiptWithPhoto` swaps the photo onto that receipt in place under the same - * durable name. `hasPendingPhotoCapture` holds the camera session open, since closing it during a capture - * cancels the photo with "Camera is closed." + * Nothing awaits `startPhotoCapture`, so the snapshot keeps driving navigation, and the photo is swapped + * in under the same durable name once it lands. `hasPendingPhotoCapture` holds the camera session open: + * closing it mid-capture cancels the photo with "Camera is closed." */ function usePhotoUpgrade() { const [hasPendingPhotoCapture, setHasPendingPhotoCapture] = useState(false); const pendingPhotoRef = useRef(undefined); - // Retaking a receipt can leave two captures in flight, so count them. With a single flag, the first - // capture to settle would close the session under the second one. + // Holds the camera session open across the race. Counted rather than a flag because it is released + // when the race settles, which can be while the capture behind it is still running. const captureCountRef = useRef(0); + // Captures the camera has not finished yet. Not the same as `captureCountRef`, which follows the race + // below and reaches zero once that gives up — the capture itself runs on past that, and cannot be + // called off. + const photosInFlightRef = useRef(0); + + // A capture nobody claimed outlives the screen, so its file has to be cleaned up from here. + useEffect( + () => () => { + const pending = pendingPhotoRef.current; + if (!pending) { + return; + } + pendingPhotoRef.current = undefined; + abandonPending(pending); + }, + [], + ); + const releaseSession = () => { captureCountRef.current -= 1; if (captureCountRef.current > 0) { @@ -83,16 +125,41 @@ function usePhotoUpgrade() { }; const startPhotoCapture = (camera: Camera) => { - const pending: PendingPhoto = {isDiscarded: false, promise: Promise.resolve(undefined)}; + // This capture was never handed a receipt, and the shutter below replaces it. Cleared before the + // guard, so a capture that skips the upgrade cannot inherit this one's photo. + const orphaned = pendingPhotoRef.current; + pendingPhotoRef.current = undefined; + if (orphaned) { + abandonPending(orphaned); + } + + // Abandoning a capture does not stop it: `discard` only marks the result for deletion, and + // vision-camera cannot cancel a `takePhoto`. A second one started over a capture still running + // makes them contend, and the next `takeSnapshot` pays for it — 6.6s measured against an 80ms + // baseline. A receipt keeping its snapshot is the cheaper loss. + if (photosInFlightRef.current > 0) { + Log.info('[PhotoUpgrade] a full-resolution capture is still running, so this receipt keeps its snapshot'); + return; + } - // The photo goes to the temp directory, not the receipts folder, so ReceiptStorage stays the only - // writer there and an abandoned photo never sits among the receipts looking like one. - const capture = camera.takePhoto({flash: 'off', enableShutterSound: false, path: RNFS.TemporaryDirectoryPath}).then( + let isDiscarded = false; + + // Temp directory, not the receipts folder, so ReceiptStorage stays its only writer and a discarded + // photo never sits among the receipts. + const nativeCapture = camera.takePhoto({flash: 'off', enableShutterSound: false, path: RNFS.TemporaryDirectoryPath}); + + // Released when the capture settles, which is what the guard above reads. + photosInFlightRef.current += 1; + const releasePhotoSlot = () => { + photosInFlightRef.current -= 1; + }; + nativeCapture.then(releasePhotoSlot, releasePhotoSlot); + + const capture = nativeCapture.then( (photo) => { - if (!pending.isDiscarded) { + if (!isDiscarded) { return photo; } - // This one has no consumer, so it does not stay on disk. discardPhoto(photo.path); return undefined; }, @@ -102,19 +169,18 @@ function usePhotoUpgrade() { }, ); - // The deadline outlives the screen on purpose, since the swap finishes after the scan page is gone - // and a late arrival needs deleting too. + // Outlives the screen on purpose: the swap finishes after the scan page is gone, and a late + // arrival still needs deleting. let deadlineTimeout: ReturnType; const deadline = new Promise((resolve) => { deadlineTimeout = setTimeout(() => { - pending.isDiscarded = true; + isDiscarded = true; resolve(undefined); }, PHOTO_CAPTURE_TIMEOUT_MS); }); - // The session is released when the race settles, not when the swap finishes. Once the photo is on - // disk the camera is free, and the rotate and replace steps run without it. - pending.promise = Promise.race([capture, deadline]).then((photo) => { + // Released when the race settles, not when the swap finishes: the rotate and swap need no camera. + const promise = Promise.race([capture, deadline]).then((photo) => { clearTimeout(deadlineTimeout); releaseSession(); @@ -126,17 +192,14 @@ function usePhotoUpgrade() { return photo; }); - pendingPhotoRef.current = pending; + pendingPhotoRef.current = { + promise, + discard: () => { + isDiscarded = true; + }, + }; captureCountRef.current += 1; setHasPendingPhotoCapture(true); - - // Nothing about this upgrade is visible to the user, so the span is the only way to tell a receipt - // that was upgraded from one that quietly kept its snapshot. - startSpan(CONST.TELEMETRY.SPAN_RECEIPT_PHOTO_UPGRADE, { - name: CONST.TELEMETRY.SPAN_RECEIPT_PHOTO_UPGRADE, - op: CONST.TELEMETRY.SPAN_RECEIPT_PHOTO_UPGRADE, - attributes: {[CONST.TELEMETRY.ATTRIBUTE_PLATFORM]: CONST.TELEMETRY.SPAN_PLATFORM.NATIVE}, - }); }; const upgradeReceiptWithPhoto = (durableName: string) => { @@ -146,47 +209,67 @@ function usePhotoUpgrade() { } pendingPhotoRef.current = undefined; - // Anything that reads or shows this receipt can now wait for the better file, or refresh once it - // arrives, instead of taking the snapshot it is about to replace. + // From here a reader can claim these bytes, and a view can refresh once they change. startUpgrade(durableName); pending.promise .then((photo) => { if (!photo) { - endSpanWithAttributes(CONST.TELEMETRY.SPAN_RECEIPT_PHOTO_UPGRADE, { - [CONST.TELEMETRY.ATTRIBUTE_UPGRADE_OUTCOME]: CONST.TELEMETRY.UPGRADE_OUTCOME.NO_PHOTO, - }); return undefined; } - return rotatePhotoToUpright(photo.path).then((uprightPath) => - ReceiptStorage.overwrite(durableName, uprightPath ?? photo.path).then(() => { - Log.info('[PhotoUpgrade] receipt upgraded to the full-resolution photo', false, { - width: photo.width, - height: photo.height, - wasRotated: !!uprightPath, - }); - endSpanWithAttributes(CONST.TELEMETRY.SPAN_RECEIPT_PHOTO_UPGRADE, { - [CONST.TELEMETRY.ATTRIBUTE_UPGRADE_OUTCOME]: CONST.TELEMETRY.UPGRADE_OUTCOME.UPGRADED, - [CONST.TELEMETRY.ATTRIBUTE_PHOTO_WIDTH]: photo.width, - [CONST.TELEMETRY.ATTRIBUTE_PHOTO_HEIGHT]: photo.height, - }); - - if (!uprightPath) { - return; - } - // `overwrite` moved the rotated copy, so the original is left behind, and its cleanup - // must not fail an upgrade that already went through. + return withDeadline(rotatePhotoToUpright(photo.path, photo.orientation), ROTATE_TIMEOUT_MS, 'rotate', (latePath) => { + // The swap gave up on this rotate, so the copy it went on to write is left orphaned. + if (!latePath) { + return; + } + discardPhoto(latePath); + }).then((uprightPath) => { + // An upload is sending the snapshot. Renaming now would hand it the old bytes, the + // new ones, or a missing file. + if (wasReceiptClaimed(durableName)) { discardPhoto(photo.path); - }), - ); + if (uprightPath) { + discardPhoto(uprightPath); + } + return undefined; + } + + // Asked again immediately before the rename, since everything between the two checks + // yields. + return ReceiptStorage.overwrite(durableName, uprightPath ?? photo.path, () => wasReceiptClaimed(durableName)) + .then(() => { + Log.info('[PhotoUpgrade] receipt upgraded to the full-resolution photo', false, { + width: photo.width, + height: photo.height, + wasRotated: !!uprightPath, + }); + // Only now have the bytes changed, which is what reloads the unchanged path. + recordUpgrade(durableName); + + if (!uprightPath) { + return; + } + // `overwrite` moved the rotated copy, leaving the original behind. Its + // cleanup must not fail an upgrade that already went through. + discardPhoto(photo.path); + }) + .catch((error: unknown) => { + // `overwrite` can reject before it moves the rotated copy. + if (uprightPath) { + discardPhoto(uprightPath); + } + throw error; + }); + }); }) .catch((error: unknown) => { - Log.warn('[PhotoUpgrade] keeping the snapshot, upgrade failed', {error: error instanceof Error ? error.message : String(error)}); - endSpanWithAttributes(CONST.TELEMETRY.SPAN_RECEIPT_PHOTO_UPGRADE, { - [CONST.TELEMETRY.ATTRIBUTE_UPGRADE_OUTCOME]: CONST.TELEMETRY.UPGRADE_OUTCOME.FAILED, + // A swap that backed out for an upload is not a failure, and gets its own outcome. + const wasClaimed = wasReceiptClaimed(durableName); + Log.warn(`[PhotoUpgrade] keeping the snapshot, upgrade ${wasClaimed ? 'claimed for upload' : 'failed'}`, { + error: error instanceof Error ? error.message : String(error), }); - pending.isDiscarded = true; + pending.discard(); discardWhenItLands(pending); }) .finally(() => finishUpgrade(durableName)); @@ -198,11 +281,7 @@ function usePhotoUpgrade() { return; } pendingPhotoRef.current = undefined; - endSpanWithAttributes(CONST.TELEMETRY.SPAN_RECEIPT_PHOTO_UPGRADE, { - [CONST.TELEMETRY.ATTRIBUTE_UPGRADE_OUTCOME]: CONST.TELEMETRY.UPGRADE_OUTCOME.ABANDONED, - }); - pending.isDiscarded = true; - discardWhenItLands(pending); + abandonPending(pending); }; return {hasPendingPhotoCapture, startPhotoCapture, upgradeReceiptWithPhoto, discardPendingPhoto}; diff --git a/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts b/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts index e62e1cdf3d88..bb227f9714d2 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts @@ -1,6 +1,8 @@ import {JPEG_QUALITY} from '@libs/fileDownload/FileUtils'; import getPhotoSource from '@libs/fileDownload/getPhotoSource'; +import type {Orientation} from 'react-native-vision-camera'; + import {ImageManipulator, SaveFormat} from 'expo-image-manipulator'; import ImageSize from 'react-native-image-size'; @@ -23,20 +25,25 @@ type PhotoSize = { * Clockwise degrees to turn the photo by, or `undefined` to keep the file as captured. * * Measure the frame the way the image loader hands it over, since the loader applies the file's rotation - * metadata while decoding. Verified on device: a 1920x1440 photo whose metadata asks for 90 decodes as - * 1440x1920, so rotating it again put it back on its side. + * metadata while decoding: a 1920x1440 photo whose metadata asks for 90 decodes as 1440x1920, and turning + * that again puts it back on its side. * * A returned `0` is not a no-op. Re-encoding bakes the rotation into the pixels and drops the tag, which * the confirmation preview and the receipt on the server both ignore. */ -function getUprightRotation({width, height, rotation = 0}: PhotoSize): number | undefined { +function getUprightRotation({width, height, rotation = 0}: PhotoSize, orientation?: Orientation): number | undefined { const isSideways = rotation === 90 || rotation === 270; const decodedWidth = isSideways ? height : width; const decodedHeight = isSideways ? width : height; - // This upgrade only runs on the portrait `takeSnapshot` path, since landscape captures go through - // `takePhoto`. So a frame that decodes landscape has no rotation recorded and needs the sensor's turn. - const angle = decodedWidth > decodedHeight ? 90 : 0; + // This upgrade only runs on the portrait path, so a frame that decodes landscape carries no rotation + // and needs the sensor's turn. Only a quarter-turn makes it portrait, so the hold picks the direction. + // + // `PhotoFile.orientation` is the display orientation relative to the sensor, as a counter-clockwise + // angle (see VisionCamera's `RotationHelper`): a portrait capture off a landscape sensor reports + // `landscape-left`, so 270 counter-clockwise, so 90 clockwise. The opposite hold turns the other way. + const quarterTurn = orientation === 'landscape-right' ? 270 : 90; + const angle = decodedWidth > decodedHeight ? quarterTurn : 0; if (!angle && !rotation) { // Upright already, with nothing in metadata to bake in, so re-encoding would only lose quality. @@ -50,13 +57,14 @@ function getUprightRotation({width, height, rotation = 0}: PhotoSize): number | * Bakes the rotation of a full-resolution camera photo into its pixels. Rejects instead of falling back to * the photo as captured, since a receipt lying on its side is worse than the snapshot the caller has. * + * @param orientation decides the direction of a quarter-turn * @returns path of an upright temporary copy, or `undefined` when the photo needs no turn */ -function rotatePhotoToUpright(stillPath: string): Promise { +function rotatePhotoToUpright(stillPath: string, orientation?: Orientation): Promise { const sourceUri = getPhotoSource(stillPath); return ImageSize.getSize(sourceUri).then((imageSize) => { - const angle = getUprightRotation(imageSize); + const angle = getUprightRotation(imageSize, orientation); if (angle === undefined) { return undefined; } diff --git a/tests/unit/components/NavigationAwareCameraTest.tsx b/tests/unit/components/NavigationAwareCameraTest.tsx new file mode 100644 index 000000000000..671afe33beef --- /dev/null +++ b/tests/unit/components/NavigationAwareCameraTest.tsx @@ -0,0 +1,61 @@ +import {render} from '@testing-library/react-native'; + +import NavigationAwareCamera from '@pages/iou/request/step/IOURequestStepScan/components/NavigationAwareCamera/Camera'; + +import type {CameraDevice} from 'react-native-vision-camera'; + +import React from 'react'; + +const mockUseIsFocused = jest.fn(); +jest.mock('@react-navigation/native', () => ({ + useIsFocused: () => mockUseIsFocused(), +})); + +const mockVisionCamera = jest.fn(); +jest.mock('react-native-vision-camera', () => ({ + Camera: (props: {isActive: boolean}) => { + mockVisionCamera(props); + return null; + }, +})); + +// The wrapper passes the device straight through to a camera that is stubbed out here, so it only has to +// satisfy the prop type. +// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion +const DEVICE = {id: 'back'} as unknown as CameraDevice; + +/** The session state the camera ends up in for one combination of focus and the two overrides. */ +function renderIsActive({isFocused, forceInactive, shouldStayActiveWhenBlurred}: {isFocused: boolean; forceInactive?: boolean; shouldStayActiveWhenBlurred?: boolean}): boolean { + mockUseIsFocused.mockReturnValue(isFocused); + mockVisionCamera.mockClear(); + + render( + , + ); + + return !!mockVisionCamera.mock.calls.at(0)?.at(0)?.isActive; +} + +describe('NavigationAwareCamera', () => { + it('runs the session while the scan tab is focused and nothing has forced it shut', () => { + expect(renderIsActive({isFocused: true})).toBe(true); + expect(renderIsActive({isFocused: false})).toBe(false); + }); + + it('keeps the session running once a capture navigates away and the screen blurs', () => { + // Closing it here cancels an in-flight `takePhoto` with "Camera is closed.", which is the whole + // reason a full-resolution capture can outlive the shutter. + expect(renderIsActive({isFocused: false, shouldStayActiveWhenBlurred: true})).toBe(true); + }); + + it('lets forceInactive win over a capture that is still running, since it is the harder stop', () => { + expect(renderIsActive({isFocused: true, forceInactive: true})).toBe(false); + expect(renderIsActive({isFocused: false, forceInactive: true, shouldStayActiveWhenBlurred: true})).toBe(false); + expect(renderIsActive({isFocused: true, forceInactive: true, shouldStayActiveWhenBlurred: true})).toBe(false); + }); +}); diff --git a/tests/unit/hooks/useLocalReceiptThumbnail.test.ts b/tests/unit/hooks/useLocalReceiptThumbnail.test.ts new file mode 100644 index 000000000000..7ea6425187b8 --- /dev/null +++ b/tests/unit/hooks/useLocalReceiptThumbnail.test.ts @@ -0,0 +1,83 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import useLocalReceiptThumbnail, {precacheReceiptImage} from '@hooks/useLocalReceiptThumbnail'; + +import {finish, recordUpgrade, start} from '@libs/ReceiptStorage/receiptUpgrades'; + +const mockGenerateThumbnail = jest.fn, [string]>(); +jest.mock('@pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio', () => ({ + generateThumbnail: (sourceUri: string) => mockGenerateThumbnail(sourceUri), +})); + +const FOLDER = 'file:///var/mobile/Containers/Data/Application/AAAA/Documents/Receipts-Upload'; + +/** Each test uses its own receipt, since the thumbnail cache outlives a single hook. */ +function buildReceipt(name: string) { + return {name, uri: `${FOLDER}/${name}`}; +} + +function upgradeReceipt(name: string) { + act(() => { + start(name); + recordUpgrade(name); + finish(name); + }); +} + +describe('useLocalReceiptThumbnail', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGenerateThumbnail.mockResolvedValue(null); + }); + + it('hands back the seeded receipt untouched while nothing has replaced it', () => { + const {uri} = buildReceipt('receipt_untouched.jpg'); + precacheReceiptImage(uri); + + const {result} = renderHook(() => useLocalReceiptThumbnail(uri, true)); + + expect(result.current.thumbnailUri).toBe(uri); + expect(result.current.isGenerating).toBe(false); + }); + + it('gives the same file a source the image loaders treat as new once a better capture replaces it', () => { + const {name, uri} = buildReceipt('receipt_replaced.jpg'); + precacheReceiptImage(uri); + + const {result} = renderHook(() => useLocalReceiptThumbnail(uri, true)); + expect(result.current.thumbnailUri).toBe(uri); + + upgradeReceipt(name); + + // The path never changes, so without this the loader would serve the decode it already had. + expect(result.current.thumbnailUri).toBe(`${uri}#upgraded1`); + }); + + it('moves the marker on rather than stacking it when the same receipt is replaced twice', () => { + const {name, uri} = buildReceipt('receipt_replaced_twice.jpg'); + precacheReceiptImage(uri); + + const {result} = renderHook(() => useLocalReceiptThumbnail(uri, true)); + + upgradeReceipt(name); + expect(result.current.thumbnailUri).toBe(`${uri}#upgraded1`); + + upgradeReceipt(name); + // One marker, not two, so the source stays a path the loader can still open. + expect(result.current.thumbnailUri).toBe(`${uri}#upgraded2`); + }); + + it('leaves a receipt that only finished an upgrade alone, since its bytes never moved', () => { + const {name, uri} = buildReceipt('receipt_still_the_snapshot.jpg'); + precacheReceiptImage(uri); + + const {result} = renderHook(() => useLocalReceiptThumbnail(uri, true)); + + act(() => { + start(name); + finish(name); + }); + + expect(result.current.thumbnailUri).toBe(uri); + }); +}); diff --git a/tests/unit/hooks/usePhotoUpgrade.test.ts b/tests/unit/hooks/usePhotoUpgrade.test.ts index d06e6ac9b9d8..184aa83142a1 100644 --- a/tests/unit/hooks/usePhotoUpgrade.test.ts +++ b/tests/unit/hooks/usePhotoUpgrade.test.ts @@ -2,31 +2,35 @@ import {act, renderHook} from '@testing-library/react-native'; import usePhotoUpgrade from '@pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade'; -import type {Camera, PhotoFile} from 'react-native-vision-camera'; +import type {Camera, Orientation, PhotoFile} from 'react-native-vision-camera'; -const mockReplace = jest.fn, [string, string]>(); +const mockReplace = jest.fn, [string, string, (() => boolean) | undefined]>(); const mockDiscard = jest.fn, [string]>(); const mockStartUpgrade = jest.fn(); const mockFinishUpgrade = jest.fn(); +const mockRecordUpgrade = jest.fn(); +const mockWasClaimed = jest.fn(); jest.mock('@libs/ReceiptStorage/receiptUpgrades', () => ({ start: (name: string) => mockStartUpgrade(name), finish: (name: string) => mockFinishUpgrade(name), + recordUpgrade: (name: string) => mockRecordUpgrade(name), + isClaimedForRead: (name: string) => mockWasClaimed(name), })); jest.mock('@libs/ReceiptStorage', () => ({ __esModule: true, default: { - overwrite: (durableName: string, path: string) => mockReplace(durableName, path), + overwrite: (durableName: string, path: string, shouldAbort?: () => boolean) => mockReplace(durableName, path, shouldAbort), discard: (path: string) => mockDiscard(path), }, })); -const mockRotate = jest.fn, [string]>(); +const mockRotate = jest.fn, [string, Orientation | undefined]>(); jest.mock('@pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright', () => ({ __esModule: true, - default: (path: string) => mockRotate(path), + default: (path: string, orientation?: Orientation) => mockRotate(path, orientation), })); jest.mock('react-native-fs', () => ({TemporaryDirectoryPath: '/tmp'})); @@ -35,9 +39,11 @@ const DURABLE_NAME = 'receipt_1234.jpg'; const PHOTO_PATH = '/tmp/still.jpg'; const UPRIGHT_PATH = '/tmp/ImageManipulator/upright.jpg'; const PHOTO_CAPTURE_TIMEOUT_MS = 3000; +const ROTATE_TIMEOUT_MS = 5000; function buildPhoto(path = PHOTO_PATH): PhotoFile { - return {path, width: 1920, height: 1440, isRawPhoto: false, orientation: 'portrait', isMirrored: false}; + // A portrait capture off a landscape sensor reports `landscape-left`, per VisionCamera's own docs. + return {path, width: 1920, height: 1440, isRawPhoto: false, orientation: 'landscape-left', isMirrored: false}; } /** A camera whose captures the test resolves or rejects on demand, in the order they were started. */ @@ -75,13 +81,14 @@ describe('usePhotoUpgrade', () => { mockReplace.mockResolvedValue(DURABLE_NAME); mockDiscard.mockResolvedValue(undefined); mockRotate.mockResolvedValue(UPRIGHT_PATH); + mockWasClaimed.mockReturnValue(false); }); afterEach(() => { jest.useRealTimers(); }); - it('holds the camera session open from the shutter until the still lands', async () => { + it('holds the camera session open from the shutter until the photo lands', async () => { const {camera, landPhoto} = buildCamera(); const {result} = renderHook(() => usePhotoUpgrade()); @@ -96,7 +103,7 @@ describe('usePhotoUpgrade', () => { expect(result.current.hasPendingPhotoCapture).toBe(false); }); - it('swaps the upright still onto the receipt and cleans up the file it rotated from', async () => { + it('swaps the upright photo onto the receipt and cleans up the file it rotated from', async () => { const {camera, landPhoto} = buildCamera(); const {result} = renderHook(() => usePhotoUpgrade()); @@ -108,14 +115,17 @@ describe('usePhotoUpgrade', () => { }); await landPhoto(); - expect(mockRotate).toHaveBeenCalledWith(PHOTO_PATH); - expect(mockReplace).toHaveBeenCalledWith(DURABLE_NAME, UPRIGHT_PATH); + // The hold the photo was taken in decides which way it is turned, so it has to be passed along. + expect(mockRotate).toHaveBeenCalledWith(PHOTO_PATH, 'landscape-left'); + expect(mockReplace).toHaveBeenCalledWith(DURABLE_NAME, UPRIGHT_PATH, expect.any(Function)); expect(mockDiscard).toHaveBeenCalledWith(PHOTO_PATH); + // Only a receipt whose bytes really changed gets a new version, which is what makes a view reload it. + expect(mockRecordUpgrade).toHaveBeenCalledWith(DURABLE_NAME); // The upload waits on this hold, so it has to be let go once the receipt is upgraded. expect(mockFinishUpgrade).toHaveBeenCalledWith(DURABLE_NAME); }); - it('swaps the still as captured when it needs no rotation, leaving nothing to clean up', async () => { + it('swaps the photo as captured when it needs no rotation, leaving nothing to clean up', async () => { mockRotate.mockResolvedValue(undefined); const {camera, landPhoto} = buildCamera(); const {result} = renderHook(() => usePhotoUpgrade()); @@ -128,11 +138,11 @@ describe('usePhotoUpgrade', () => { }); await landPhoto(); - expect(mockReplace).toHaveBeenCalledWith(DURABLE_NAME, PHOTO_PATH); + expect(mockReplace).toHaveBeenCalledWith(DURABLE_NAME, PHOTO_PATH, expect.any(Function)); expect(mockDiscard).not.toHaveBeenCalled(); }); - it('keeps the snapshot and releases the session when the still misses the deadline', async () => { + it('keeps the snapshot and releases the session when the photo misses the deadline', async () => { const {camera, landPhoto} = buildCamera(); const {result} = renderHook(() => usePhotoUpgrade()); @@ -150,13 +160,151 @@ describe('usePhotoUpgrade', () => { expect(result.current.hasPendingPhotoCapture).toBe(false); expect(mockReplace).not.toHaveBeenCalled(); expect(mockFinishUpgrade).toHaveBeenCalledWith(DURABLE_NAME); + // The file never moved, so nothing that shows it should be told to load it again. + expect(mockRecordUpgrade).not.toHaveBeenCalled(); - // A still that turns up after the deadline has no consumer, so it must not stay on disk. + // A photo that turns up after the deadline has no consumer, so it must not stay on disk. await landPhoto(); expect(mockDiscard).toHaveBeenCalledWith(PHOTO_PATH); expect(mockReplace).not.toHaveBeenCalled(); }); + it('leaves the receipt alone once an upload has claimed it', async () => { + mockWasClaimed.mockReturnValue(true); + const {camera, landPhoto} = buildCamera(); + const {result} = renderHook(() => usePhotoUpgrade()); + + act(() => { + result.current.startPhotoCapture(camera); + }); + act(() => { + result.current.upgradeReceiptWithPhoto(DURABLE_NAME); + }); + await landPhoto(); + + // Renaming the file now would change it under the upload that claimed it. + expect(mockReplace).not.toHaveBeenCalled(); + expect(mockRecordUpgrade).not.toHaveBeenCalled(); + expect(mockDiscard).toHaveBeenCalledWith(PHOTO_PATH); + expect(mockDiscard).toHaveBeenCalledWith(UPRIGHT_PATH); + }); + + it('keeps the snapshot when the rotate never settles, rather than holding the upgrade open', async () => { + mockRotate.mockReturnValue(new Promise(() => {})); + const {camera, landPhoto} = buildCamera(); + const {result} = renderHook(() => usePhotoUpgrade()); + + act(() => { + result.current.startPhotoCapture(camera); + }); + act(() => { + result.current.upgradeReceiptWithPhoto(DURABLE_NAME); + }); + await landPhoto(); + + expect(mockFinishUpgrade).not.toHaveBeenCalled(); + + await act(async () => { + jest.runOnlyPendingTimers(); + }); + + // Left registered, `isUpgrading` would stay true for the rest of the launch. + expect(mockFinishUpgrade).toHaveBeenCalledWith(DURABLE_NAME); + expect(mockReplace).not.toHaveBeenCalled(); + }); + + it('deletes a rotated copy that lands after the rotate deadline, which nothing else would claim', async () => { + let landRotate: (path: string) => void = () => {}; + mockRotate.mockReturnValue( + new Promise((resolve) => { + landRotate = resolve; + }), + ); + const {camera, landPhoto} = buildCamera(); + const {result} = renderHook(() => usePhotoUpgrade()); + + act(() => { + result.current.startPhotoCapture(camera); + }); + act(() => { + result.current.upgradeReceiptWithPhoto(DURABLE_NAME); + }); + await landPhoto(); + + // The rotate misses its cap, so the swap gives up and keeps the snapshot. + await act(async () => { + jest.advanceTimersByTime(ROTATE_TIMEOUT_MS); + }); + expect(mockReplace).not.toHaveBeenCalled(); + + // The manipulator still finishes and writes its JPEG, with nothing left pointing at it. + await act(async () => { + landRotate(UPRIGHT_PATH); + }); + + expect(mockDiscard).toHaveBeenCalledWith(UPRIGHT_PATH); + }); + + it('hands the swap a guard that still reports a claim made after the swap began', async () => { + const {camera, landPhoto} = buildCamera(); + const {result} = renderHook(() => usePhotoUpgrade()); + + act(() => { + result.current.startPhotoCapture(camera); + }); + act(() => { + result.current.upgradeReceiptWithPhoto(DURABLE_NAME); + }); + await landPhoto(); + + const firstSwap = mockReplace.mock.calls.at(0); + expect(firstSwap).toBeDefined(); + const shouldAbort = firstSwap?.[2]; + expect(shouldAbort?.()).toBe(false); + + // The swap yields several times before it renames anything, so a reader can give up in between. + mockWasClaimed.mockReturnValue(true); + expect(shouldAbort?.()).toBe(true); + }); + + it('records a swap that backed out for an upload as claimed rather than failed', async () => { + mockReplace.mockRejectedValue(new Error('a reader stopped waiting')); + mockWasClaimed.mockReturnValue(false); + const {camera, landPhoto} = buildCamera(); + const {result} = renderHook(() => usePhotoUpgrade()); + + act(() => { + result.current.startPhotoCapture(camera); + }); + act(() => { + result.current.upgradeReceiptWithPhoto(DURABLE_NAME); + }); + + // Decided while the swap was running, which is the case the in-swap guard exists for. + mockWasClaimed.mockReturnValue(true); + await landPhoto(); + + expect(mockRecordUpgrade).not.toHaveBeenCalled(); + expect(mockFinishUpgrade).toHaveBeenCalledWith(DURABLE_NAME); + }); + + it('deletes the rotated copy when the swap fails, so failed upgrades do not pile up', async () => { + mockReplace.mockRejectedValue(new Error('no space left on device')); + const {camera, landPhoto} = buildCamera(); + const {result} = renderHook(() => usePhotoUpgrade()); + + act(() => { + result.current.startPhotoCapture(camera); + }); + act(() => { + result.current.upgradeReceiptWithPhoto(DURABLE_NAME); + }); + await landPhoto(); + + expect(mockDiscard).toHaveBeenCalledWith(UPRIGHT_PATH); + expect(mockRecordUpgrade).not.toHaveBeenCalled(); + }); + it('keeps the snapshot when the capture itself fails', async () => { const {camera, failCapture} = buildCamera(); const {result} = renderHook(() => usePhotoUpgrade()); @@ -176,7 +324,7 @@ describe('usePhotoUpgrade', () => { expect(mockFinishUpgrade).toHaveBeenCalledWith(DURABLE_NAME); }); - it('deletes the still when the receipt it was meant for never arrived', async () => { + it('deletes the photo when the receipt it was meant for never arrived', async () => { const {camera, landPhoto} = buildCamera(); const {result} = renderHook(() => usePhotoUpgrade()); @@ -192,7 +340,43 @@ describe('usePhotoUpgrade', () => { expect(mockDiscard).toHaveBeenCalledWith(PHOTO_PATH); }); - it('keeps the session open for a retake when the first still settles after it started', async () => { + it('deletes the photo of a capture the next shutter replaced before it was claimed', async () => { + const {camera, takePhoto, landPhoto} = buildCamera(); + const {result} = renderHook(() => usePhotoUpgrade()); + + // Two shutters, and the first was never handed a receipt — so nothing else will clean up after it. + act(() => { + result.current.startPhotoCapture(camera); + }); + act(() => { + result.current.startPhotoCapture(camera); + }); + + // The second shutter replaces the first capture without starting one of its own, since the camera + // is still busy with it. + expect(takePhoto).toHaveBeenCalledTimes(1); + + // The abandoned photo still lands, and is still deleted, even though nothing replaced it. + await landPhoto(buildPhoto('/tmp/orphaned.jpg'), 0); + expect(mockDiscard).toHaveBeenCalledWith('/tmp/orphaned.jpg'); + expect(mockRotate).not.toHaveBeenCalled(); + }); + + it('deletes an unclaimed photo when the scan screen goes away', async () => { + const {camera, landPhoto} = buildCamera(); + const {result, unmount} = renderHook(() => usePhotoUpgrade()); + + act(() => { + result.current.startPhotoCapture(camera); + }); + unmount(); + + await landPhoto(); + expect(mockDiscard).toHaveBeenCalledWith(PHOTO_PATH); + expect(mockReplace).not.toHaveBeenCalled(); + }); + + it('does not start a second photo over one the camera is still taking, since the next snapshot pays for it', async () => { const {camera, takePhoto, landPhoto} = buildCamera(); const {result} = renderHook(() => usePhotoUpgrade()); @@ -203,16 +387,38 @@ describe('usePhotoUpgrade', () => { result.current.upgradeReceiptWithPhoto(DURABLE_NAME); }); - // The user came back to the camera and shot again before the first still landed. + // The user came back to the camera and shot again before the first photo landed. Starting a + // second capture here is what made a later `takeSnapshot` take seconds on device. act(() => { result.current.startPhotoCapture(camera); }); - expect(takePhoto).toHaveBeenCalledTimes(2); + expect(takePhoto).toHaveBeenCalledTimes(1); + + // The receipt this shutter produced keeps its snapshot rather than waiting on a contended camera. + act(() => { + result.current.upgradeReceiptWithPhoto('receipt_5678.jpg'); + }); + expect(mockStartUpgrade).not.toHaveBeenCalledWith('receipt_5678.jpg'); await landPhoto(buildPhoto(), 0); - expect(result.current.hasPendingPhotoCapture).toBe(true); + }); - await landPhoto(buildPhoto('/tmp/still2.jpg'), 1); - expect(result.current.hasPendingPhotoCapture).toBe(false); + it('takes a photo again once the camera has finished the previous one', async () => { + const {camera, takePhoto, landPhoto} = buildCamera(); + const {result} = renderHook(() => usePhotoUpgrade()); + + act(() => { + result.current.startPhotoCapture(camera); + }); + act(() => { + result.current.upgradeReceiptWithPhoto(DURABLE_NAME); + }); + await landPhoto(buildPhoto(), 0); + + // The camera is free, so the guard lets the next shutter upgrade as normal. + act(() => { + result.current.startPhotoCapture(camera); + }); + expect(takePhoto).toHaveBeenCalledTimes(2); }); }); diff --git a/tests/unit/hooks/useReceiptUpgradeCount.test.ts b/tests/unit/hooks/useReceiptUpgradeCount.test.ts new file mode 100644 index 000000000000..1302368255d1 --- /dev/null +++ b/tests/unit/hooks/useReceiptUpgradeCount.test.ts @@ -0,0 +1,68 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import useReceiptUpgradeCount from '@hooks/useReceiptUpgradeCount'; + +import {finish, recordUpgrade, start} from '@libs/ReceiptStorage/receiptUpgrades'; + +const FOLDER = 'file:///var/mobile/Containers/Data/Application/AAAA/Documents/Receipts-Upload'; +const DURABLE_NAME = 'receipt_1234.jpg'; +const RECEIPT_URI = `${FOLDER}/${DURABLE_NAME}`; + +describe('useReceiptUpgradeCount', () => { + it('reports nothing for a receipt that has never been upgraded', () => { + const {result} = renderHook(() => useReceiptUpgradeCount(`${FOLDER}/receipt_untouched.jpg`)); + + expect(result.current).toBe(0); + }); + + it('reports nothing for a view that has no source yet', () => { + const {result} = renderHook(() => useReceiptUpgradeCount(undefined)); + + expect(result.current).toBe(0); + }); + + it('goes up when the receipt behind the path is replaced, since the path itself never changes', () => { + const {result} = renderHook(() => useReceiptUpgradeCount(RECEIPT_URI)); + + expect(result.current).toBe(0); + + act(() => { + start(DURABLE_NAME); + recordUpgrade(DURABLE_NAME); + finish(DURABLE_NAME); + }); + + expect(result.current).toBe(1); + }); + + it('stays put for an upgrade that finished without replacing the file', () => { + const name = 'receipt_kept_its_snapshot.jpg'; + const {result} = renderHook(() => useReceiptUpgradeCount(`${FOLDER}/${name}`)); + + act(() => { + start(name); + finish(name); + }); + + expect(result.current).toBe(0); + }); + + it('reads the same count back from a source it has already handed out', () => { + const name = 'receipt_handed_back.jpg'; + act(() => { + start(name); + recordUpgrade(name); + finish(name); + }); + + // A view passes the source it was given straight back in. Keying on the fragment or the query would + // make that look like a different receipt every time the file changed. + const {result: plain} = renderHook(() => useReceiptUpgradeCount(`${FOLDER}/${name}`)); + const {result: withFragment} = renderHook(() => useReceiptUpgradeCount(`${FOLDER}/${name}#upgraded1`)); + const {result: withQuery} = renderHook(() => useReceiptUpgradeCount(`${FOLDER}/${name}?v=1`)); + + expect(plain.current).toBe(1); + expect(withFragment.current).toBe(1); + expect(withQuery.current).toBe(1); + }); +}); diff --git a/tests/unit/libs/prepareRequestPayloadNativeTest.ts b/tests/unit/libs/prepareRequestPayloadNativeTest.ts index bf02bb322d73..7fe31caebf51 100644 --- a/tests/unit/libs/prepareRequestPayloadNativeTest.ts +++ b/tests/unit/libs/prepareRequestPayloadNativeTest.ts @@ -22,6 +22,8 @@ jest.mock('@libs/telemetry/ReceiptObservability', () => ({ })); const RECEIPTS_FOLDER = '/Containers/Data/Application/CURRENT/Documents/Receipts-Upload'; +const mockSettle = jest.fn, [string]>(() => Promise.resolve()); + jest.mock('@libs/ReceiptStorage', () => ({ __esModule: true, default: { @@ -30,6 +32,7 @@ jest.mock('@libs/ReceiptStorage', () => ({ const uri = name ? `file://${RECEIPTS_FOLDER}/${name}` : source; return mockCheckFileExists(uri).then((exists: boolean) => (exists ? uri : undefined)); }, + settle: (durableName: string) => mockSettle(durableName), }, })); @@ -144,4 +147,64 @@ describe('prepareRequestPayload (native)', () => { expect(formData.get('amount')).toBe('100'); expect(formData.has('undefinedField')).toBe(false); }); + + describe('a receipt sent as a file object, the way ReplaceReceipt sends it', () => { + /** `clearAllMocks` keeps a `mockReturnValue`, so a deferred one would leak into the next test. */ + beforeEach(() => { + mockSettle.mockReturnValue(Promise.resolve()); + }); + + /** The payload walks one key per microtask, so settle the whole queue rather than counting hops. */ + const flushMicrotasks = () => + new Promise((resolve) => { + setImmediate(resolve); + }); + + it('claims the file before reading it, and waits only while a claimed swap finishes renaming', async () => { + let releaseCommittedSwap: () => void = () => {}; + mockSettle.mockReturnValue( + new Promise((resolve) => { + releaseCommittedSwap = resolve; + }), + ); + + let hasPrepared = false; + const prepared = prepareRequestPayload( + 'ReplaceReceipt', + { + transactionID: '1', + receipt: {uri: `file://${RECEIPTS_FOLDER}/CAM-1.jpg`, name: 'CAM-1.jpg', type: 'image/jpeg'}, + }, + false, + ).then((formData) => { + hasPrepared = true; + return formData; + }); + await flushMicrotasks(); + + expect(mockSettle).toHaveBeenCalledWith('CAM-1.jpg'); + // `settle` only holds the payload while a swap has already committed to its two renames, since + // reading the receipt mid-rename would send the old bytes, the new ones, or nothing at all. + expect(hasPrepared).toBe(false); + + releaseCommittedSwap(); + await prepared; + expect(hasPrepared).toBe(true); + expect(mockValidateFormDataParameter).toHaveBeenCalledWith('ReplaceReceipt', 'receipt', expect.objectContaining({name: 'CAM-1.jpg'})); + }); + + it('never reaches locate, since a file object carries no receipt source to resolve', async () => { + await prepareRequestPayload( + 'ReplaceReceipt', + { + transactionID: '1', + receipt: {uri: `file://${RECEIPTS_FOLDER}/CAM-1.jpg`, name: 'CAM-1.jpg', type: 'image/jpeg'}, + }, + false, + ); + + expect(mockCheckFileExists).not.toHaveBeenCalled(); + expect(mockLogReceiptDropped).not.toHaveBeenCalled(); + }); + }); }); diff --git a/tests/unit/libs/receiptStorageTest.ts b/tests/unit/libs/receiptStorageTest.ts index cd4492e824f7..cfef47040502 100644 --- a/tests/unit/libs/receiptStorageTest.ts +++ b/tests/unit/libs/receiptStorageTest.ts @@ -5,12 +5,14 @@ const mockExists = jest.fn, [string]>(); const mockMv = jest.fn, [string, string]>(); const mockMkdir = jest.fn, [string]>(); const mockUnlink = jest.fn, [string]>(); +const mockReadDir = jest.fn>, [string]>(); jest.mock('react-native-fs', () => ({ exists: (path: string) => mockExists(path), moveFile: (from: string, to: string) => mockMv(from, to), mkdir: (path: string) => mockMkdir(path), unlink: (path: string) => mockUnlink(path), + readDir: (path: string) => mockReadDir(path), })); jest.mock('@libs/NumberUtils', () => ({rand64: () => '1234'})); @@ -30,6 +32,11 @@ jest.mock('@libs/getReceiptsUploadFolderPath', () => ({ // Import the native implementation by path. Jest resolves the bare specifier to the web implementation. const {default: ReceiptStorage}: {default: ReceiptStorageType} = jest.requireActual('@libs/ReceiptStorage/index.native.ts'); +const { + start: startUpgrade, + finish: finishUpgrade, + isClaimedForRead, +}: {start: (name: string) => void; finish: (name: string) => void; isClaimedForRead: (name: string) => boolean} = jest.requireActual('@libs/ReceiptStorage/receiptUpgrades.ts'); describe('ReceiptStorage', () => { beforeEach(() => { @@ -39,6 +46,7 @@ describe('ReceiptStorage', () => { mockMkdir.mockResolvedValue(undefined); mockUnlink.mockResolvedValue(undefined); mockCheckFileExists.mockResolvedValue(true); + mockReadDir.mockResolvedValue([]); }); describe('adopt', () => { @@ -179,12 +187,154 @@ describe('ReceiptStorage', () => { await expect(ReceiptStorage.overwrite(RECEIPT, STILL)).rejects.toThrow('not in durable storage'); expect(mockMv).not.toHaveBeenCalled(); }); + + it('backs out at the last moment when an upload claimed the receipt while the swap was getting ready', async () => { + const existing = new Set([`${FOLDER}/${RECEIPT}`, STILL]); + mockExists.mockImplementation((path: string) => Promise.resolve(existing.has(path))); + + // False when `overwrite` is called, true by the time the renames are about to start: the checks + // in between all read the filesystem, so this is the window the in-swap guard covers. + let hasReaderGivenUp = false; + mockMv.mockImplementation((from: string, to: string) => { + existing.delete(from); + existing.add(to); + if (from === STILL) { + hasReaderGivenUp = true; + } + return Promise.resolve(); + }); + mockUnlink.mockImplementation((path: string) => { + existing.delete(path); + return Promise.resolve(); + }); + + await expect(ReceiptStorage.overwrite(RECEIPT, STILL, () => hasReaderGivenUp)).rejects.toThrow('an upload claimed the receipt'); + + // Staging only put a file beside the receipt, so backing out means dropping that copy and + // leaving the receipt's own name untouched. + expect(mockUnlink).toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}.staged`); + expect(mockMv).not.toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}`, `${FOLDER}/${RECEIPT}.backup`); + // The receipt itself is exactly where it was. + expect(existing.has(`${FOLDER}/${RECEIPT}`)).toBe(true); + }); + + it('swaps as normal when no upload has claimed the receipt', async () => { + mockExists.mockImplementation(onlyTheReceiptExists); + + await expect(ReceiptStorage.overwrite(RECEIPT, STILL, () => false)).resolves.toBe(RECEIPT); + expect(mockMv).toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}.staged`, `${FOLDER}/${RECEIPT}`); + }); + + it('refuses a second swap over a receipt already being swapped, rather than clearing its staged copy', async () => { + const existing = new Set([`${FOLDER}/${RECEIPT}`]); + let finishFirstSwap: () => void = () => {}; + const firstSwapStarted = new Promise((resolve) => { + mockMv.mockImplementation((from: string, to: string) => { + existing.delete(from); + existing.add(to); + if (to === `${FOLDER}/${RECEIPT}.staged`) { + resolve(); + return new Promise((settle) => { + finishFirstSwap = settle; + }); + } + return Promise.resolve(); + }); + }); + mockExists.mockImplementation((path: string) => Promise.resolve(existing.has(path))); + + const first = ReceiptStorage.overwrite(RECEIPT, STILL); + await firstSwapStarted; + + await expect(ReceiptStorage.overwrite(RECEIPT, '/var/mobile/tmp/other.jpg')).rejects.toThrow('a swap is already running'); + + finishFirstSwap(); + await expect(first).resolves.toBe(RECEIPT); + }); + + it('registers the swap before it reads the filesystem, so a reader cannot slip in front of it', async () => { + const existing = new Set([`${FOLDER}/${RECEIPT}`]); + mockExists.mockImplementation((path: string) => Promise.resolve(existing.has(path))); + mockMv.mockImplementation((from: string, to: string) => { + existing.delete(from); + existing.add(to); + return Promise.resolve(); + }); + + // No awaits yet: the checks inside `overwrite` all read the filesystem, and a reader arriving + // during them would otherwise see nothing in flight and read the file about to be renamed. + const swap = ReceiptStorage.overwrite(RECEIPT, STILL); + await expect(ReceiptStorage.overwrite(RECEIPT, STILL)).rejects.toThrow('a swap is already running'); + + await swap; + }); + }); + + describe('sweeping leftovers from an interrupted swap', () => { + const RECEIPT = 'CAM-1.jpg'; + + /** The sweep runs once per launch, so a test that wants to watch it needs a module that has not swept yet. */ + function loadFreshStorage(): ReceiptStorageType { + jest.resetModules(); + const {default: storage}: {default: ReceiptStorageType} = jest.requireActual('@libs/ReceiptStorage/index.native.ts'); + return storage; + } + + it('puts back a stranded backup and deletes the copies nothing owns before it renames anything', async () => { + const storage = loadFreshStorage(); + const existing = new Set([`${FOLDER}/${RECEIPT}.backup`, `${FOLDER}/other.jpg`, `${FOLDER}/other.jpg.staged`]); + mockReadDir.mockResolvedValue([{name: `${RECEIPT}.backup`}, {name: 'other.jpg'}, {name: 'other.jpg.staged'}]); + mockExists.mockImplementation((path: string) => Promise.resolve(existing.has(path))); + mockMv.mockImplementation((from: string, to: string) => { + existing.delete(from); + existing.add(to); + return Promise.resolve(); + }); + + // The app died mid-swap last launch, so this receipt exists only under the backup name. The swap + // has to find it there, which means the sweep must finish before `verify` runs. + await expect(storage.overwrite(RECEIPT, '/var/mobile/tmp/still.jpg')).resolves.toBe(RECEIPT); + + expect(mockMv).toHaveBeenNthCalledWith(1, `${FOLDER}/${RECEIPT}.backup`, `${FOLDER}/${RECEIPT}`); + // A staged copy beside a receipt that is already in place has no owner. + expect(mockUnlink).toHaveBeenCalledWith(`${FOLDER}/other.jpg.staged`); + expect(mockUnlink).not.toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}`); + + // A second write does not pay for the directory listing again. + mockReadDir.mockClear(); + await storage.overwrite(RECEIPT, '/var/mobile/tmp/another.jpg'); + expect(mockReadDir).not.toHaveBeenCalled(); + }); + + it('keeps the directory listing off the request path, so a read never waits on housekeeping', async () => { + const storage = loadFreshStorage(); + let hasListed = false; + mockReadDir.mockImplementation(() => { + hasListed = true; + return Promise.resolve([]); + }); + mockCheckFileExists.mockResolvedValue(true); + + // `locate` runs inside the request the sequential queue is processing, so it starts the sweep + // without waiting for it — the receipt it is reading has its own recovery. + await expect(storage.locate(`file://${FOLDER}/${RECEIPT}`)).resolves.toBe(`file://${FOLDER}/${RECEIPT}`); + expect(mockMv).not.toHaveBeenCalled(); + + // Started all the same, so a launch that only ever reads still gets its folder tidied. + expect(hasListed).toBe(true); + }); }); describe('locate', () => { const RECEIPT_URI = `file://${FOLDER}/CAM-1.jpg`; const RECEIPT_PATH = `${FOLDER}/CAM-1.jpg`; + /** Drains the microtask queue, so an assertion sees where a promise chain actually stopped. */ + const flushPendingWork = () => + new Promise((resolve) => { + setImmediate(resolve); + }); + it('hands back a receipt that is where it should be', async () => { await expect(ReceiptStorage.locate(RECEIPT_URI)).resolves.toBe(RECEIPT_URI); expect(mockMv).not.toHaveBeenCalled(); @@ -198,19 +348,55 @@ describe('ReceiptStorage', () => { expect(mockMv).toHaveBeenCalledWith(`${RECEIPT_PATH}.backup`, RECEIPT_PATH); }); - it('waits for a swap that is running before reading a receipt that is still there', async () => { + it('reads straight through while a swap is still staging, since that half can be called off', async () => { const existing = new Set([RECEIPT_PATH]); - let finishSwap: () => void = () => {}; - const swapStarted = new Promise((resolve) => { + let finishStaging: () => void = () => {}; + const stagingStarted = new Promise((resolve) => { mockMv.mockImplementation((from: string, to: string) => { + if (to === `${RECEIPT_PATH}.staged`) { + resolve(); + return new Promise((settle) => { + finishStaging = settle; + }); + } existing.delete(from); existing.add(to); - if (to === `${RECEIPT_PATH}.staged`) { + return Promise.resolve(); + }); + }); + mockExists.mockImplementation((path: string) => Promise.resolve(existing.has(path))); + mockCheckFileExists.mockImplementation((path?: string) => Promise.resolve(existing.has(fileURIToPath(path ?? '')))); + + // The upgrade has to be registered for a claim to land on it, as it is in the real flow. + startUpgrade('CAM-1.jpg'); + const swap = ReceiptStorage.overwrite('CAM-1.jpg', '/var/mobile/tmp/still.jpg', () => isClaimedForRead('CAM-1.jpg')); + await stagingStarted; + + // Staging is a copy into the receipts folder, which on a cross-filesystem move is the whole + // image. Waiting it out would put that copy in front of every other queued request. + await expect(ReceiptStorage.locate(RECEIPT_URI)).resolves.toBe(RECEIPT_URI); + expect(isClaimedForRead('CAM-1.jpg')).toBe(true); + + finishStaging(); + // The claim reaches the guard below the staging move, so the swap leaves the receipt alone. + await expect(swap).rejects.toThrow('an upload claimed the receipt'); + + finishUpgrade('CAM-1.jpg'); + }); + + it('waits for a swap that has started renaming, which is the one gap a claim cannot call off', async () => { + const existing = new Set([RECEIPT_PATH]); + let finishRename: () => void = () => {}; + const renameStarted = new Promise((resolve) => { + mockMv.mockImplementation((from: string, to: string) => { + if (to === `${RECEIPT_PATH}.backup`) { resolve(); return new Promise((settle) => { - finishSwap = settle; + finishRename = settle; }); } + existing.delete(from); + existing.add(to); return Promise.resolve(); }); }); @@ -218,23 +404,41 @@ describe('ReceiptStorage', () => { mockCheckFileExists.mockImplementation((path?: string) => Promise.resolve(existing.has(fileURIToPath(path ?? '')))); const swap = ReceiptStorage.overwrite('CAM-1.jpg', '/var/mobile/tmp/still.jpg'); - await swapStarted; + await renameStarted; let hasLocated = false; const located = ReceiptStorage.locate(RECEIPT_URI).then((uri) => { hasLocated = true; return uri; }); - await Promise.resolve(); + await flushPendingWork(); - // The receipt is on disk the whole time, but reading it now would ship the file being replaced. + // The receipt's own name is moving, so reading it now returns the old bytes, the new ones, or + // nothing at all. This is the only part of a swap a reader ever sits through. expect(hasLocated).toBe(false); - finishSwap(); + finishRename(); await swap; await expect(located).resolves.toBe(RECEIPT_URI); }); + it('does not wait on an upgrade that has no swap running, so a submit never queues behind a capture', async () => { + const existing = new Set([RECEIPT_PATH]); + mockExists.mockImplementation((path: string) => Promise.resolve(existing.has(path))); + mockCheckFileExists.mockImplementation((path?: string) => Promise.resolve(existing.has(fileURIToPath(path ?? '')))); + + // The upgrade is registered but still capturing or rotating: nothing has reached the renames. + startUpgrade('CAM-1.jpg'); + + const located = await ReceiptStorage.locate(RECEIPT_URI); + + // Read straight through, and the upgrade is told to leave the file alone rather than waited on. + expect(located).toBe(RECEIPT_URI); + expect(isClaimedForRead('CAM-1.jpg')).toBe(true); + + finishUpgrade('CAM-1.jpg'); + }); + it('waits for a swap that is running rather than putting the backup back over it', async () => { const existing = new Set([RECEIPT_PATH]); let finishSwap: () => void = () => {}; diff --git a/tests/unit/libs/receiptUpgradesTest.ts b/tests/unit/libs/receiptUpgradesTest.ts index f88761b5df78..094efdcbf7e4 100644 --- a/tests/unit/libs/receiptUpgradesTest.ts +++ b/tests/unit/libs/receiptUpgradesTest.ts @@ -1,7 +1,6 @@ -import {finish, isUpgrading, start, subscribe, waitFor} from '@libs/ReceiptStorage/receiptUpgrades'; +import {claimForRead, finish, getUpgradeCount, isClaimedForRead, isUpgrading, recordUpgrade, start, subscribe} from '@libs/ReceiptStorage/receiptUpgrades'; const RECEIPT = 'receipt_1234.jpg'; -const CAP_MS = 1500; describe('receiptUpgrades', () => { afterEach(() => { @@ -9,37 +8,44 @@ describe('receiptUpgrades', () => { jest.useRealTimers(); }); - it('reports nothing in flight for a receipt that is not being upgraded', async () => { + it('reports nothing in flight for a receipt that is not being upgraded', () => { expect(isUpgrading(RECEIPT)).toBe(false); - await expect(waitFor(RECEIPT, CAP_MS)).resolves.toBeUndefined(); + expect(isClaimedForRead(RECEIPT)).toBe(false); }); - it('holds a caller until the upgrade finishes', async () => { + it('claims a receipt without waiting, so an upload never queues behind a capture', () => { start(RECEIPT); - expect(isUpgrading(RECEIPT)).toBe(true); + expect(isClaimedForRead(RECEIPT)).toBe(false); - let hasWaited = false; - const waited = waitFor(RECEIPT, CAP_MS).then(() => { - hasWaited = true; - }); - await Promise.resolve(); - expect(hasWaited).toBe(false); + // Nothing is awaited here on purpose: this is the whole of what the upload path does, and it runs + // inside the request the sequential queue is processing. + claimForRead(RECEIPT); - finish(RECEIPT); - await waited; - expect(isUpgrading(RECEIPT)).toBe(false); + expect(isClaimedForRead(RECEIPT)).toBe(true); + // The upgrade is still running, it just no longer owns the file. + expect(isUpgrading(RECEIPT)).toBe(true); + }); + + it('ignores a claim on a receipt nothing is upgrading, so an ordinary upload is unaffected', () => { + claimForRead(RECEIPT); + expect(isClaimedForRead(RECEIPT)).toBe(false); }); - it('lets a caller through once it has waited long enough, so a stalled upgrade blocks nothing', async () => { - jest.useFakeTimers(); + it('keeps a claim for the rest of the upgrade, so a second reader cannot hand the file back', () => { start(RECEIPT); + claimForRead(RECEIPT); + claimForRead(RECEIPT); - const waited = waitFor(RECEIPT, CAP_MS); - await jest.advanceTimersByTimeAsync(CAP_MS); + expect(isClaimedForRead(RECEIPT)).toBe(true); + }); - await expect(waited).resolves.toBeUndefined(); - // The upgrade is still running, it just no longer has anyone waiting on it. - expect(isUpgrading(RECEIPT)).toBe(true); + it('forgets the claim once the upgrade is over, so the next one starts clean', () => { + start(RECEIPT); + claimForRead(RECEIPT); + finish(RECEIPT); + + start(RECEIPT); + expect(isClaimedForRead(RECEIPT)).toBe(false); }); it('tells subscribers when a receipt starts and stops changing', () => { @@ -57,12 +63,51 @@ describe('receiptUpgrades', () => { expect(listener).toHaveBeenCalledTimes(2); }); - it('ignores a second start for the same receipt, so one upgrade cannot orphan another', async () => { + it('ignores a second start for the same receipt, so one upgrade cannot orphan another', () => { start(RECEIPT); + claimForRead(RECEIPT); + // A second start must not reset the claim the first upgrade already handed out. start(RECEIPT); - finish(RECEIPT); + expect(isClaimedForRead(RECEIPT)).toBe(true); + }); + + describe('version count', () => { + const KEPT_SNAPSHOT = 'receipt_kept_snapshot.jpg'; + const SWAPPED = 'receipt_swapped.jpg'; + + it('stays put for an upgrade that finished without replacing the file', () => { + start(KEPT_SNAPSHOT); + finish(KEPT_SNAPSHOT); + + // A view keys its image source on this, so bumping it here would re-decode a file that never + // moved — on the deadline-miss path, which is the common one. + expect(getUpgradeCount(KEPT_SNAPSHOT)).toBe(0); + }); - await expect(waitFor(RECEIPT, CAP_MS)).resolves.toBeUndefined(); + it('moves only when the bytes behind the receipt actually changed', () => { + const listener = jest.fn(); + const unsubscribe = subscribe(listener); + + start(SWAPPED); + recordUpgrade(SWAPPED); + finish(SWAPPED); + + expect(getUpgradeCount(SWAPPED)).toBe(1); + // start, recordUpgrade and finish each tell the views something changed. + expect(listener).toHaveBeenCalledTimes(3); + unsubscribe(); + }); + + it('forgets the oldest receipts rather than growing for the whole launch', () => { + const first = 'receipt_first_of_many.jpg'; + recordUpgrade(first); + for (let index = 0; index < 60; index++) { + recordUpgrade(`receipt_bulk_${index}.jpg`); + } + + expect(getUpgradeCount(first)).toBe(0); + expect(getUpgradeCount('receipt_bulk_59.jpg')).toBe(1); + }); }); }); diff --git a/tests/unit/rotatePhotoToUprightTest.ts b/tests/unit/rotatePhotoToUprightTest.ts index b01bc41dcbca..446ce09c1dfb 100644 --- a/tests/unit/rotatePhotoToUprightTest.ts +++ b/tests/unit/rotatePhotoToUprightTest.ts @@ -1,7 +1,7 @@ import {getUprightRotation} from '@pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright'; describe('getUprightRotation', () => { - it('only re-encodes a still whose metadata already describes it upright, because the decoder applied that rotation', () => { + it('only re-encodes a photo whose metadata already describes it upright, because the decoder applied that rotation', () => { // Measured on a Galaxy S20 FE. 1920x1440 on disk with metadata asking for 90, so it decodes as // 1440x1920 and turning it again would put it back on its side. expect(getUprightRotation({width: 1920, height: 1440, rotation: 90})).toBe(0); @@ -13,18 +13,36 @@ describe('getUprightRotation', () => { expect(getUprightRotation({width: 1920, height: 1440, rotation: 180})).toBe(90); }); - it('leaves a still that is already upright with no metadata alone, rather than re-encoding it', () => { + it('turns the opposite hold the other way, rather than leaving it 180 out', () => { + // `PhotoFile.orientation` is counter-clockwise, so `landscape-left` (270 CCW) is the 90 clockwise + // the Galaxy S20 FE needs, and `landscape-right` (90 CCW) has to go the other way. + expect(getUprightRotation({width: 1920, height: 1440, rotation: 0}, 'landscape-left')).toBe(90); + expect(getUprightRotation({width: 1920, height: 1440, rotation: 0}, 'landscape-right')).toBe(270); + }); + + it('keeps the verified quarter-turn for a hold that cannot make a landscape frame upright', () => { + // Only a quarter-turn makes a landscape frame portrait, so these fall back rather than turning 0 or + // 180 and leaving the receipt on its side. + expect(getUprightRotation({width: 1920, height: 1440, rotation: 0}, 'portrait')).toBe(90); + expect(getUprightRotation({width: 1920, height: 1440, rotation: 0}, 'portrait-upside-down')).toBe(90); + }); + + it('ignores the hold for a frame that already decodes upright, since only the decoded frame matters', () => { + expect(getUprightRotation({width: 1440, height: 1920, rotation: 0}, 'landscape-right')).toBeUndefined(); + }); + + it('leaves a photo that is already upright with no metadata alone, rather than re-encoding it', () => { // iOS, where react-native-image-size reports the displayed size and no rotation. expect(getUprightRotation({width: 2160, height: 2880})).toBeUndefined(); expect(getUprightRotation({width: 1440, height: 1920, rotation: 0})).toBeUndefined(); }); - it('re-encodes an upright still that still carries metadata, so consumers that ignore it see it upright too', () => { + it('re-encodes an upright photo that still carries metadata, so consumers that ignore it see it upright too', () => { expect(getUprightRotation({width: 1440, height: 1920, rotation: 180})).toBe(0); expect(getUprightRotation({width: 1440, height: 1920, rotation: 90})).toBe(90); }); - it('leaves a square still with no metadata alone', () => { + it('leaves a square photo with no metadata alone', () => { expect(getUprightRotation({width: 1440, height: 1440, rotation: 0})).toBeUndefined(); }); }); From 64b1ad3ac50a702824ee3d95862785c1a43b45c0 Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 14 Sep 2026 16:44:37 +0200 Subject: [PATCH 09/23] Keep the photo as captured when it needs no turn --- .../utils/rotatePhotoToUpright.ts | 20 +++++++++++++------ tests/unit/rotatePhotoToUprightTest.ts | 16 ++++++++------- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts b/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts index bb227f9714d2..7de1e22372a9 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts @@ -1,5 +1,6 @@ import {JPEG_QUALITY} from '@libs/fileDownload/FileUtils'; import getPhotoSource from '@libs/fileDownload/getPhotoSource'; +import Log from '@libs/Log'; import type {Orientation} from 'react-native-vision-camera'; @@ -27,9 +28,6 @@ type PhotoSize = { * Measure the frame the way the image loader hands it over, since the loader applies the file's rotation * metadata while decoding: a 1920x1440 photo whose metadata asks for 90 decodes as 1440x1920, and turning * that again puts it back on its side. - * - * A returned `0` is not a no-op. Re-encoding bakes the rotation into the pixels and drops the tag, which - * the confirmation preview and the receipt on the server both ignore. */ function getUprightRotation({width, height, rotation = 0}: PhotoSize, orientation?: Orientation): number | undefined { const isSideways = rotation === 90 || rotation === 270; @@ -45,8 +43,7 @@ function getUprightRotation({width, height, rotation = 0}: PhotoSize, orientatio const quarterTurn = orientation === 'landscape-right' ? 270 : 90; const angle = decodedWidth > decodedHeight ? quarterTurn : 0; - if (!angle && !rotation) { - // Upright already, with nothing in metadata to bake in, so re-encoding would only lose quality. + if (!angle) { return undefined; } @@ -62,6 +59,7 @@ function getUprightRotation({width, height, rotation = 0}: PhotoSize, orientatio */ function rotatePhotoToUpright(stillPath: string, orientation?: Orientation): Promise { const sourceUri = getPhotoSource(stillPath); + const startedAt = Date.now(); return ImageSize.getSize(sourceUri).then((imageSize) => { const angle = getUprightRotation(imageSize, orientation); @@ -77,7 +75,17 @@ function rotatePhotoToUpright(stillPath: string, orientation?: Orientation): Pro return context .renderAsync() .then((image) => image.saveAsync({compress: JPEG_QUALITY, format: SaveFormat.JPEG})) - .then((result) => result.uri); + .then((result) => { + // Reached only by a photo that genuinely decoded on its side, so `durationMs` is what + // `ROTATE_TIMEOUT_MS` has to cover. + Log.info('[PhotoUpgrade] rotated the full-resolution photo', false, { + durationMs: Date.now() - startedAt, + angle, + exifRotation: imageSize.rotation, + source: `${imageSize.width}x${imageSize.height}`, + }); + return result.uri; + }); }); } diff --git a/tests/unit/rotatePhotoToUprightTest.ts b/tests/unit/rotatePhotoToUprightTest.ts index 446ce09c1dfb..6b36f4beadce 100644 --- a/tests/unit/rotatePhotoToUprightTest.ts +++ b/tests/unit/rotatePhotoToUprightTest.ts @@ -1,11 +1,9 @@ import {getUprightRotation} from '@pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright'; describe('getUprightRotation', () => { - it('only re-encodes a photo whose metadata already describes it upright, because the decoder applied that rotation', () => { - // Measured on a Galaxy S20 FE. 1920x1440 on disk with metadata asking for 90, so it decodes as - // 1440x1920 and turning it again would put it back on its side. - expect(getUprightRotation({width: 1920, height: 1440, rotation: 90})).toBe(0); - expect(getUprightRotation({width: 1920, height: 1440, rotation: 270})).toBe(0); + it('leaves a photo whose metadata already describes it upright as captured, rather than re-encoding it', () => { + expect(getUprightRotation({width: 1920, height: 1440, rotation: 90})).toBeUndefined(); + expect(getUprightRotation({width: 1920, height: 1440, rotation: 270})).toBeUndefined(); }); it('turns a landscape sensor buffer that carries no rotation of its own', () => { @@ -37,8 +35,12 @@ describe('getUprightRotation', () => { expect(getUprightRotation({width: 1440, height: 1920, rotation: 0})).toBeUndefined(); }); - it('re-encodes an upright photo that still carries metadata, so consumers that ignore it see it upright too', () => { - expect(getUprightRotation({width: 1440, height: 1920, rotation: 180})).toBe(0); + it('leaves an upright photo as captured even when it still carries metadata', () => { + expect(getUprightRotation({width: 1440, height: 1920, rotation: 180})).toBeUndefined(); + }); + + it('still turns a frame that decodes landscape, whatever its metadata says', () => { + // 1440x1920 asking for 90 decodes as 1920x1440, genuinely on its side. expect(getUprightRotation({width: 1440, height: 1920, rotation: 90})).toBe(90); }); From ed7189bee4052ea9c2e94a36d1f67ad37c071eae Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 14 Sep 2026 16:53:48 +0200 Subject: [PATCH 10/23] fix spell check --- cspell.json | 1 + 1 file changed, 1 insertion(+) diff --git a/cspell.json b/cspell.json index f41de7c5f970..7d9b0f712abf 100644 --- a/cspell.json +++ b/cspell.json @@ -120,6 +120,7 @@ "EUVAT", "elif", "evals", + "EXIF", "EXPENSIDEV", "EXPENSIFYAPI", "EXPENSIFYPDBUSINESS", From 6524a699d25c0ac5309300d2c24e0fc1ce7c32f4 Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 15 Sep 2026 11:52:25 +0200 Subject: [PATCH 11/23] fix:recover the receipt without waiting for an upload --- src/libs/ReceiptStorage/index.native.ts | 17 ++++++++++++- src/libs/ReceiptStorage/index.ts | 1 + src/libs/ReceiptStorage/types.ts | 6 +++++ src/setup/index.ts | 5 ++++ tests/unit/libs/receiptStorageTest.ts | 34 +++++++++++++++++++++++++ 5 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/libs/ReceiptStorage/index.native.ts b/src/libs/ReceiptStorage/index.native.ts index eb68b0e4c0b7..440387ebe485 100644 --- a/src/libs/ReceiptStorage/index.native.ts +++ b/src/libs/ReceiptStorage/index.native.ts @@ -16,6 +16,9 @@ import {claimForRead} from './receiptUpgrades'; // the app data container on most upgrades, so an absolute path stored before the upgrade names a // directory the device no longer has, even though iOS carried the file itself across. +/** Do not let a busy launch defer recovery forever. */ +const SWEEP_IDLE_TIMEOUT_MS = 10000; + const STAGED_SUFFIX = '.staged'; const BACKUP_SUFFIX = '.backup'; @@ -317,6 +320,18 @@ const locate: ReceiptStorage['locate'] = async (source) => { return uri; }; -const receiptStorage: ReceiptStorage = {adopt, overwrite, discard, locate, settle, toLocalUri, resolve}; +const sweepLeftovers: ReceiptStorage['sweepLeftovers'] = async () => { + const dir = getReceiptsUploadFolderPath(); + if (!dir) { + return; + } + + await new Promise((onIdle) => { + requestIdleCallback(() => onIdle(), {timeout: SWEEP_IDLE_TIMEOUT_MS}); + }); + await whenLeftoversSwept(dir); +}; + +const receiptStorage: ReceiptStorage = {adopt, overwrite, discard, locate, settle, toLocalUri, resolve, sweepLeftovers}; export default receiptStorage; diff --git a/src/libs/ReceiptStorage/index.ts b/src/libs/ReceiptStorage/index.ts index e67845fd4630..c346eafb652a 100644 --- a/src/libs/ReceiptStorage/index.ts +++ b/src/libs/ReceiptStorage/index.ts @@ -10,6 +10,7 @@ const receiptStorage: ReceiptStorage = { settle: () => Promise.resolve(), toLocalUri: (durableName) => durableName, resolve: (source) => (typeof source === 'string' ? source : undefined), + sweepLeftovers: () => Promise.resolve(), }; export default receiptStorage; diff --git a/src/libs/ReceiptStorage/types.ts b/src/libs/ReceiptStorage/types.ts index 8638341dc68e..1123d3fe1c76 100644 --- a/src/libs/ReceiptStorage/types.ts +++ b/src/libs/ReceiptStorage/types.ts @@ -52,6 +52,12 @@ type ReceiptStorage = { /** Re-roots a stored source onto the current folder. A remote source passes through unchanged. */ resolve: (source: ReceiptSource | null | undefined) => string | undefined; + + /** + * Restores receipts a dead swap left under a backup name and clears stale temporary copies. Startup + * schedules this too, so recovery does not depend on a later upload reading the folder. + */ + sweepLeftovers: () => Promise; }; export default ReceiptStorage; diff --git a/src/setup/index.ts b/src/setup/index.ts index 6ec4453f8a68..940b35dc8303 100644 --- a/src/setup/index.ts +++ b/src/setup/index.ts @@ -3,6 +3,7 @@ import {finishCloudflareSignInFromURL} from '@libs/CloudflareAccess/finishSignIn import intlPolyfill from '@libs/IntlPolyfill'; import registerMiddlewares from '@libs/Middleware/register'; import {startMainQueue} from '@libs/Network'; +import ReceiptStorage from '@libs/ReceiptStorage'; import registerReportActionsPagination from '@libs/registerReportActionsPagination'; import {setDeviceID} from '@userActions/Device'; @@ -101,6 +102,10 @@ export default function () { cleanupPreMountedDraftReports(); + // An interrupted swap can leave the receipt only under its backup name. Recovers it without waiting for + // the next upload, and waits for idle first so the folder listing does not compete with startup. + ReceiptStorage.sweepLeftovers(); + // Register the commands after Onyx is initialized so every JS runtime can process paginated // responses. Initial snapshots remain asynchronous and gate only pagination, not app startup. registerReportActionsPagination(); diff --git a/tests/unit/libs/receiptStorageTest.ts b/tests/unit/libs/receiptStorageTest.ts index 98683dd52b6a..e63727eed490 100644 --- a/tests/unit/libs/receiptStorageTest.ts +++ b/tests/unit/libs/receiptStorageTest.ts @@ -324,6 +324,40 @@ describe('ReceiptStorage', () => { // Started all the same, so a launch that only ever reads still gets its folder tidied. expect(hasListed).toBe(true); }); + + it('restores a stranded receipt during the deferred startup sweep, without needing an upload', async () => { + const storage = loadFreshStorage(); + const existing = new Set([`${FOLDER}/${RECEIPT}.backup`, `${FOLDER}/${RECEIPT}.staged`]); + mockReadDir.mockResolvedValue([{name: `${RECEIPT}.backup`}, {name: `${RECEIPT}.staged`}]); + mockExists.mockImplementation((path: string) => Promise.resolve(existing.has(path))); + mockMv.mockImplementation((from: string, to: string) => { + existing.delete(from); + existing.add(to); + return Promise.resolve(); + }); + mockUnlink.mockImplementation((path: string) => { + existing.delete(path); + return Promise.resolve(); + }); + + const originalRequestIdleCallback = global.requestIdleCallback; + global.requestIdleCallback = ((callback: IdleRequestCallback) => { + callback({didTimeout: false, timeRemaining: () => 50}); + return 0; + }) as typeof requestIdleCallback; + + try { + await storage.sweepLeftovers(); + } finally { + global.requestIdleCallback = originalRequestIdleCallback; + } + + expect(mockMv).toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}.backup`, `${FOLDER}/${RECEIPT}`); + expect(mockUnlink).toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}.staged`); + expect(existing.has(`${FOLDER}/${RECEIPT}`)).toBe(true); + expect(existing.has(`${FOLDER}/${RECEIPT}.backup`)).toBe(false); + expect(existing.has(`${FOLDER}/${RECEIPT}.staged`)).toBe(false); + }); }); describe('locate', () => { From 16838e3a5873c8ad3ca8e42374f679c41ce6d02e Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 15 Sep 2026 12:43:56 +0200 Subject: [PATCH 12/23] fix unit tests --- src/libs/ReceiptStorage/index.native.ts | 20 ++++++++++++-------- src/libs/ReceiptStorage/types.ts | 5 +++-- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/libs/ReceiptStorage/index.native.ts b/src/libs/ReceiptStorage/index.native.ts index 440387ebe485..8be37149418a 100644 --- a/src/libs/ReceiptStorage/index.native.ts +++ b/src/libs/ReceiptStorage/index.native.ts @@ -321,15 +321,19 @@ const locate: ReceiptStorage['locate'] = async (source) => { }; const sweepLeftovers: ReceiptStorage['sweepLeftovers'] = async () => { - const dir = getReceiptsUploadFolderPath(); - if (!dir) { - return; - } + try { + const dir = getReceiptsUploadFolderPath(); + if (!dir) { + return; + } - await new Promise((onIdle) => { - requestIdleCallback(() => onIdle(), {timeout: SWEEP_IDLE_TIMEOUT_MS}); - }); - await whenLeftoversSwept(dir); + await new Promise((onIdle) => { + requestIdleCallback(() => onIdle(), {timeout: SWEEP_IDLE_TIMEOUT_MS}); + }); + await whenLeftoversSwept(dir); + } catch (error) { + Log.warn('[ReceiptStorage] could not sweep leftover receipt copies at startup', {error: error instanceof Error ? error.message : String(error)}); + } }; const receiptStorage: ReceiptStorage = {adopt, overwrite, discard, locate, settle, toLocalUri, resolve, sweepLeftovers}; diff --git a/src/libs/ReceiptStorage/types.ts b/src/libs/ReceiptStorage/types.ts index 1123d3fe1c76..17986ecfb08e 100644 --- a/src/libs/ReceiptStorage/types.ts +++ b/src/libs/ReceiptStorage/types.ts @@ -54,8 +54,9 @@ type ReceiptStorage = { resolve: (source: ReceiptSource | null | undefined) => string | undefined; /** - * Restores receipts a dead swap left under a backup name and clears stale temporary copies. Startup - * schedules this too, so recovery does not depend on a later upload reading the folder. + * Restores receipts an interrupted swap left under a backup name and clears stale temporary copies. + * Startup schedules this too, so recovery does not depend on a later upload reading the folder. Never + * rejects, since nothing awaits it. */ sweepLeftovers: () => Promise; }; From 47b2611a83e0d1ea029ecfc5edb6d49f17282a70 Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 17 Sep 2026 12:27:41 +0200 Subject: [PATCH 13/23] add telemtry and tests --- src/CONST/index.ts | 13 ++ .../hooks/usePhotoUpgrade.ts | 79 +++++++- tests/unit/hooks/usePhotoUpgrade.test.ts | 181 ++++++++++++++++++ tests/unit/libs/receiptStorageTest.ts | 27 +++ 4 files changed, 297 insertions(+), 3 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 4dfc5132a368..b97d39628ded 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2357,6 +2357,7 @@ const CONST = { SPAN_SHUTTER_TO_CONFIRMATION: 'ManualShutterToConfirmation', SPAN_RECEIPT_CAPTURE: 'ManualReceiptCapture', SPAN_RECEIPT_PREPARE: 'ManualReceiptPrepare', + SPAN_RECEIPT_UPGRADE: 'ManualReceiptUpgrade', SPAN_SCAN_PROCESS_AND_NAVIGATE: 'ManualScanProcessAndNavigate', SPAN_CONFIRMATION_MOUNT: 'ManualConfirmationMount', SPAN_CONFIRMATION_LIST_READY: 'ManualConfirmationListReady', @@ -2463,6 +2464,9 @@ const CONST = { ATTRIBUTE_IS_MULTI_SCAN: 'is_multi_scan', ATTRIBUTE_CAPTURE_METHOD: 'capture_method', ATTRIBUTE_FLASH_USED: 'flash_used', + /** Set on the capture span, which ends in ~120ms and so survives a background that cancels the upgrade span. */ + ATTRIBUTE_UPGRADE_ATTEMPTED: 'upgrade_attempted', + ATTRIBUTE_UPGRADE_OUTCOME: 'upgrade_outcome', ATTRIBUTE_PHOTO_WIDTH: 'photo_width', ATTRIBUTE_PHOTO_HEIGHT: 'photo_height', ATTRIBUTE_SOURCE: 'source', @@ -2472,6 +2476,15 @@ const CONST = { PHOTO: 'photo', SNAPSHOT: 'snapshot', }, + UPGRADE_OUTCOME: { + UPGRADED: 'upgraded', + CAPTURE_FAILED: 'capture_failed', + CAPTURE_TIMED_OUT: 'capture_timed_out', + ROTATE_TIMED_OUT: 'rotate_timed_out', + CLAIMED_FOR_UPLOAD: 'claimed_for_upload', + SWAP_FAILED: 'swap_failed', + STACKED_CAPTURE_SKIPPED: 'stacked_capture_skipped', + }, SPAN_PLATFORM: { NATIVE: 'native', WEB: 'web', diff --git a/src/pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade.ts b/src/pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade.ts index 23ebde04814e..7937fa18b498 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade.ts @@ -1,9 +1,12 @@ import Log from '@libs/Log'; import ReceiptStorage from '@libs/ReceiptStorage'; import {finish as finishUpgrade, isClaimedForRead as wasReceiptClaimed, recordUpgrade, start as startUpgrade} from '@libs/ReceiptStorage/receiptUpgrades'; +import {endSpanWithAttributes, getSpan, startSpan} from '@libs/telemetry/activeSpans'; import rotatePhotoToUpright from '@pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright'; +import CONST from '@src/CONST'; + import type {Camera, PhotoFile} from 'react-native-vision-camera'; import {useEffect, useRef, useState} from 'react'; @@ -75,8 +78,19 @@ type PendingPhoto = { /** Gives up on the photo, so whatever lands afterwards is deleted instead of kept. */ discard: () => void; + + /** Shutter time, so the span covers the capture rather than starting when the receipt is handed over. */ + startedAt: number; + + /** Why the capture produced nothing, read once the race has settled. */ + getFailureOutcome: () => string; }; +/** Named per receipt: an upgrade outlives its capture, so a later one must not end this one's span. */ +function toUpgradeSpanId(durableName: string) { + return `${CONST.TELEMETRY.SPAN_RECEIPT_UPGRADE}_${durableName}`; +} + function abandonPending(pending: PendingPhoto) { pending.discard(); discardWhenItLands(pending); @@ -139,9 +153,23 @@ function usePhotoUpgrade() { // baseline. A receipt keeping its snapshot is the cheaper loss. if (photosInFlightRef.current > 0) { Log.info('[PhotoUpgrade] a full-resolution capture is still running, so this receipt keeps its snapshot'); + // No receipt exists yet, so there is no upgrade span to end. The capture span is still open and + // is the only place this outcome can be recorded. + getSpan(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE)?.setAttributes({ + [CONST.TELEMETRY.ATTRIBUTE_UPGRADE_ATTEMPTED]: false, + [CONST.TELEMETRY.ATTRIBUTE_UPGRADE_OUTCOME]: CONST.TELEMETRY.UPGRADE_OUTCOME.STACKED_CAPTURE_SKIPPED, + }); return; } + // The denominator for every outcome below. It rides on the capture span rather than the upgrade span + // because backgrounding the app cancels every open span, and the upgrade's own span is open for + // seconds while this one has already ended. Without it, a lost outcome is indistinguishable from a + // capture that never tried, and the success rate reads high. + getSpan(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE)?.setAttributes({[CONST.TELEMETRY.ATTRIBUTE_UPGRADE_ATTEMPTED]: true}); + + const startedAt = Date.now(); + let failureOutcome: string = CONST.TELEMETRY.UPGRADE_OUTCOME.CAPTURE_FAILED; let isDiscarded = false; // Temp directory, not the receipts folder, so ReceiptStorage stays its only writer and a discarded @@ -165,6 +193,7 @@ function usePhotoUpgrade() { }, (error: unknown) => { Log.warn('[PhotoUpgrade] full-resolution capture failed', {error: error instanceof Error ? error.message : String(error)}); + failureOutcome = CONST.TELEMETRY.UPGRADE_OUTCOME.CAPTURE_FAILED; return undefined; }, ); @@ -175,6 +204,7 @@ function usePhotoUpgrade() { const deadline = new Promise((resolve) => { deadlineTimeout = setTimeout(() => { isDiscarded = true; + failureOutcome = CONST.TELEMETRY.UPGRADE_OUTCOME.CAPTURE_TIMED_OUT; resolve(undefined); }, PHOTO_CAPTURE_TIMEOUT_MS); }); @@ -197,6 +227,8 @@ function usePhotoUpgrade() { discard: () => { isDiscarded = true; }, + startedAt, + getFailureOutcome: () => failureOutcome, }; captureCountRef.current += 1; setHasPendingPhotoCapture(true); @@ -205,16 +237,42 @@ function usePhotoUpgrade() { const upgradeReceiptWithPhoto = (durableName: string) => { const pending = pendingPhotoRef.current; if (!pending) { + // The only branch that would otherwise say nothing at all, which once left a capture with no + // `[PhotoUpgrade]` line and no way to tell what had happened to it. + Log.info('[PhotoUpgrade] no pending photo for this receipt, so it keeps its snapshot', false, {durableName}); return; } pendingPhotoRef.current = undefined; + const spanId = toUpgradeSpanId(durableName); + // The branches below are not mutually exclusive: a swap that already reported an outcome can still + // reject afterwards. Reporting once is this hook's business rather than a detail of `endSpan`. + let hasReportedOutcome = false; + const reportOutcome = (outcome: string, attributes: Record = {}) => { + if (hasReportedOutcome) { + return; + } + hasReportedOutcome = true; + endSpanWithAttributes(spanId, {[CONST.TELEMETRY.ATTRIBUTE_UPGRADE_OUTCOME]: outcome, ...attributes}); + }; + // Started from the shutter, so the span covers the capture as well as the swap. It deliberately has + // no parent: the upgrade outlives the screen, and its shutter span has usually ended by now. + startSpan(spanId, { + name: CONST.TELEMETRY.SPAN_RECEIPT_UPGRADE, + op: CONST.TELEMETRY.SPAN_RECEIPT_UPGRADE, + startTime: pending.startedAt, + attributes: { + [CONST.TELEMETRY.ATTRIBUTE_PLATFORM]: CONST.TELEMETRY.SPAN_PLATFORM.NATIVE, + }, + }); + // From here a reader can claim these bytes, and a view can refresh once they change. startUpgrade(durableName); pending.promise .then((photo) => { if (!photo) { + reportOutcome(pending.getFailureOutcome()); return undefined; } @@ -228,6 +286,10 @@ function usePhotoUpgrade() { // An upload is sending the snapshot. Renaming now would hand it the old bytes, the // new ones, or a missing file. if (wasReceiptClaimed(durableName)) { + // Logged like every other outcome: a background cancels the span above, and then the + // log is the only record that this receipt was upgraded right up to the rename. + Log.info('[PhotoUpgrade] keeping the snapshot, upgrade claimed for upload before the swap started', false, {durableName}); + reportOutcome(CONST.TELEMETRY.UPGRADE_OUTCOME.CLAIMED_FOR_UPLOAD); discardPhoto(photo.path); if (uprightPath) { discardPhoto(uprightPath); @@ -244,6 +306,11 @@ function usePhotoUpgrade() { height: photo.height, wasRotated: !!uprightPath, }); + // Source dimensions, so the resolution the upgrade actually wins is queryable per device. + reportOutcome(CONST.TELEMETRY.UPGRADE_OUTCOME.UPGRADED, { + [CONST.TELEMETRY.ATTRIBUTE_PHOTO_WIDTH]: photo.width, + [CONST.TELEMETRY.ATTRIBUTE_PHOTO_HEIGHT]: photo.height, + }); // Only now have the bytes changed, which is what reloads the unchanged path. recordUpgrade(durableName); @@ -266,9 +333,15 @@ function usePhotoUpgrade() { .catch((error: unknown) => { // A swap that backed out for an upload is not a failure, and gets its own outcome. const wasClaimed = wasReceiptClaimed(durableName); - Log.warn(`[PhotoUpgrade] keeping the snapshot, upgrade ${wasClaimed ? 'claimed for upload' : 'failed'}`, { - error: error instanceof Error ? error.message : String(error), - }); + const reason = error instanceof Error ? error.message : String(error); + Log.warn(`[PhotoUpgrade] keeping the snapshot, upgrade ${wasClaimed ? 'claimed for upload' : 'failed'}`, {error: reason}); + let outcome: string = CONST.TELEMETRY.UPGRADE_OUTCOME.SWAP_FAILED; + if (wasClaimed) { + outcome = CONST.TELEMETRY.UPGRADE_OUTCOME.CLAIMED_FOR_UPLOAD; + } else if (reason.includes('rotate did not finish')) { + outcome = CONST.TELEMETRY.UPGRADE_OUTCOME.ROTATE_TIMED_OUT; + } + reportOutcome(outcome); pending.discard(); discardWhenItLands(pending); }) diff --git a/tests/unit/hooks/usePhotoUpgrade.test.ts b/tests/unit/hooks/usePhotoUpgrade.test.ts index 184aa83142a1..373de99b0daf 100644 --- a/tests/unit/hooks/usePhotoUpgrade.test.ts +++ b/tests/unit/hooks/usePhotoUpgrade.test.ts @@ -2,6 +2,8 @@ import {act, renderHook} from '@testing-library/react-native'; import usePhotoUpgrade from '@pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade'; +import CONST from '@src/CONST'; + import type {Camera, Orientation, PhotoFile} from 'react-native-vision-camera'; const mockReplace = jest.fn, [string, string, (() => boolean) | undefined]>(); @@ -26,6 +28,17 @@ jest.mock('@libs/ReceiptStorage', () => ({ }, })); +const mockStartSpan = jest.fn]>(); +const mockEndSpanWithAttributes = jest.fn]>(); +const mockSetAttributes = jest.fn]>(); +const mockGetSpan = jest.fn<{setAttributes: (attributes: Record) => void} | undefined, [string]>(); + +jest.mock('@libs/telemetry/activeSpans', () => ({ + startSpan: (spanId: string, options: Record) => mockStartSpan(spanId, options), + endSpanWithAttributes: (spanId: string, attributes: Record) => mockEndSpanWithAttributes(spanId, attributes), + getSpan: (spanId: string) => mockGetSpan(spanId), +})); + const mockRotate = jest.fn, [string, Orientation | undefined]>(); jest.mock('@pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright', () => ({ @@ -35,6 +48,7 @@ jest.mock('@pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright jest.mock('react-native-fs', () => ({TemporaryDirectoryPath: '/tmp'})); +const UPGRADE_SPAN_ID = `${CONST.TELEMETRY.SPAN_RECEIPT_UPGRADE}_receipt_1234.jpg`; const DURABLE_NAME = 'receipt_1234.jpg'; const PHOTO_PATH = '/tmp/still.jpg'; const UPRIGHT_PATH = '/tmp/ImageManipulator/upright.jpg'; @@ -82,6 +96,7 @@ describe('usePhotoUpgrade', () => { mockDiscard.mockResolvedValue(undefined); mockRotate.mockResolvedValue(UPRIGHT_PATH); mockWasClaimed.mockReturnValue(false); + mockGetSpan.mockReturnValue({setAttributes: mockSetAttributes}); }); afterEach(() => { @@ -421,4 +436,170 @@ describe('usePhotoUpgrade', () => { }); expect(takePhoto).toHaveBeenCalledTimes(2); }); + describe('telemetry', () => { + /** The outcome of the last ended upgrade span, which is what a dashboard groups by. */ + function readOutcome() { + const call = mockEndSpanWithAttributes.mock.calls.at(-1); + return {spanId: call?.[0], attributes: call?.[1]}; + } + + it('reports an upgrade that landed, with the resolution it won', async () => { + const {camera, landPhoto} = buildCamera(); + const {result} = renderHook(() => usePhotoUpgrade()); + + act(() => { + result.current.startPhotoCapture(camera); + }); + act(() => { + result.current.upgradeReceiptWithPhoto(DURABLE_NAME); + }); + // Named per receipt, so a later capture cannot end this one's span. + expect(mockStartSpan).toHaveBeenCalledWith(UPGRADE_SPAN_ID, expect.objectContaining({name: CONST.TELEMETRY.SPAN_RECEIPT_UPGRADE})); + + await landPhoto(); + + expect(readOutcome()).toEqual({ + spanId: UPGRADE_SPAN_ID, + attributes: { + [CONST.TELEMETRY.ATTRIBUTE_UPGRADE_OUTCOME]: CONST.TELEMETRY.UPGRADE_OUTCOME.UPGRADED, + [CONST.TELEMETRY.ATTRIBUTE_PHOTO_WIDTH]: 1920, + [CONST.TELEMETRY.ATTRIBUTE_PHOTO_HEIGHT]: 1440, + }, + }); + }); + + it('separates a capture that missed its deadline from one that failed', async () => { + const {camera, failCapture} = buildCamera(); + const {result} = renderHook(() => usePhotoUpgrade()); + + act(() => { + result.current.startPhotoCapture(camera); + }); + act(() => { + result.current.upgradeReceiptWithPhoto(DURABLE_NAME); + }); + await act(async () => { + jest.advanceTimersByTime(PHOTO_CAPTURE_TIMEOUT_MS); + }); + + expect(readOutcome().attributes).toEqual({[CONST.TELEMETRY.ATTRIBUTE_UPGRADE_OUTCOME]: CONST.TELEMETRY.UPGRADE_OUTCOME.CAPTURE_TIMED_OUT}); + + mockEndSpanWithAttributes.mockClear(); + const second = buildCamera(); + const {result: secondResult} = renderHook(() => usePhotoUpgrade()); + act(() => { + secondResult.current.startPhotoCapture(second.camera); + }); + act(() => { + secondResult.current.upgradeReceiptWithPhoto(DURABLE_NAME); + }); + await second.failCapture(); + await failCapture(); + + expect(readOutcome().attributes).toEqual({[CONST.TELEMETRY.ATTRIBUTE_UPGRADE_OUTCOME]: CONST.TELEMETRY.UPGRADE_OUTCOME.CAPTURE_FAILED}); + }); + + it('reports a receipt an upload claimed, which is a backed-out swap rather than a failure', async () => { + const {camera, landPhoto} = buildCamera(); + const {result} = renderHook(() => usePhotoUpgrade()); + + act(() => { + result.current.startPhotoCapture(camera); + }); + act(() => { + result.current.upgradeReceiptWithPhoto(DURABLE_NAME); + }); + mockWasClaimed.mockReturnValue(true); + await landPhoto(); + + expect(readOutcome().attributes).toEqual({[CONST.TELEMETRY.ATTRIBUTE_UPGRADE_OUTCOME]: CONST.TELEMETRY.UPGRADE_OUTCOME.CLAIMED_FOR_UPLOAD}); + }); + + it('reports a rotate that never settled, the one failure seen repeatedly on device', async () => { + const {camera, landPhoto} = buildCamera(); + const {result} = renderHook(() => usePhotoUpgrade()); + mockRotate.mockReturnValue(new Promise(() => {})); + + act(() => { + result.current.startPhotoCapture(camera); + }); + act(() => { + result.current.upgradeReceiptWithPhoto(DURABLE_NAME); + }); + await landPhoto(); + await act(async () => { + jest.advanceTimersByTime(ROTATE_TIMEOUT_MS); + }); + + expect(readOutcome().attributes).toEqual({[CONST.TELEMETRY.ATTRIBUTE_UPGRADE_OUTCOME]: CONST.TELEMETRY.UPGRADE_OUTCOME.ROTATE_TIMED_OUT}); + }); + + it('marks the attempt on the capture span, so a lost outcome is still countable', () => { + const {camera} = buildCamera(); + const {result} = renderHook(() => usePhotoUpgrade()); + + act(() => { + result.current.startPhotoCapture(camera); + }); + + // The capture span ends in milliseconds, so it survives the background that would cancel the + // upgrade span. It carries the denominator the success rate is computed against. + expect(mockGetSpan).toHaveBeenCalledWith(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE); + expect(mockSetAttributes).toHaveBeenCalledWith({[CONST.TELEMETRY.ATTRIBUTE_UPGRADE_ATTEMPTED]: true}); + }); + + it('reports one outcome per upgrade, even when the swap rejects after it already reported', async () => { + const {camera, landPhoto} = buildCamera(); + const {result} = renderHook(() => usePhotoUpgrade()); + // `recordUpgrade` runs in the same `then` as the reported outcome, so throwing there is what + // actually reaches the outer catch. A rejected `discard` would not: `discardPhoto` swallows it. + mockReplace.mockResolvedValue(DURABLE_NAME); + // `Once`, because `clearAllMocks` between tests clears calls but keeps implementations. + mockRecordUpgrade.mockImplementationOnce(() => { + throw new Error('bookkeeping failed after the bytes had already changed'); + }); + + act(() => { + result.current.startPhotoCapture(camera); + }); + act(() => { + result.current.upgradeReceiptWithPhoto(DURABLE_NAME); + }); + await landPhoto(); + + // The catch below runs and computes `swap_failed`, but the upgrade already reported and the + // receipt really was upgraded, so the first outcome has to stand. + expect(mockRecordUpgrade).toHaveBeenCalled(); + expect(mockEndSpanWithAttributes).toHaveBeenCalledTimes(1); + expect(readOutcome().attributes).toEqual({ + [CONST.TELEMETRY.ATTRIBUTE_UPGRADE_OUTCOME]: CONST.TELEMETRY.UPGRADE_OUTCOME.UPGRADED, + [CONST.TELEMETRY.ATTRIBUTE_PHOTO_WIDTH]: 1920, + [CONST.TELEMETRY.ATTRIBUTE_PHOTO_HEIGHT]: 1440, + }); + }); + + it('records a shutter that skipped the upgrade on the capture span, since no receipt exists yet', () => { + const {camera} = buildCamera(); + const {result} = renderHook(() => usePhotoUpgrade()); + + act(() => { + result.current.startPhotoCapture(camera); + }); + act(() => { + result.current.upgradeReceiptWithPhoto(DURABLE_NAME); + }); + // The first capture is still running, so this shutter keeps its snapshot. + act(() => { + result.current.startPhotoCapture(camera); + }); + + expect(mockGetSpan).toHaveBeenCalledWith(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE); + expect(mockSetAttributes).toHaveBeenCalledWith({ + [CONST.TELEMETRY.ATTRIBUTE_UPGRADE_ATTEMPTED]: false, + [CONST.TELEMETRY.ATTRIBUTE_UPGRADE_OUTCOME]: CONST.TELEMETRY.UPGRADE_OUTCOME.STACKED_CAPTURE_SKIPPED, + }); + // A skipped shutter must never end the span of the upgrade already running. + expect(mockEndSpanWithAttributes).not.toHaveBeenCalled(); + }); + }); }); diff --git a/tests/unit/libs/receiptStorageTest.ts b/tests/unit/libs/receiptStorageTest.ts index e63727eed490..420f24931790 100644 --- a/tests/unit/libs/receiptStorageTest.ts +++ b/tests/unit/libs/receiptStorageTest.ts @@ -281,6 +281,33 @@ describe('ReceiptStorage', () => { return storage; } + it('never waits on the swap that asked for it, since `overwrite` registers itself before awaiting the sweep', async () => { + const storage = loadFreshStorage(); + const existing = new Set([`${FOLDER}/${RECEIPT}.backup`]); + mockReadDir.mockResolvedValue([{name: `${RECEIPT}.backup`}]); + mockExists.mockImplementation((path: string) => Promise.resolve(existing.has(path))); + mockMv.mockImplementation((from: string, to: string) => { + existing.delete(from); + existing.add(to); + return Promise.resolve(); + }); + + // The receipt is missing under its own name, so the sweep has to restore it while a swap over + // that same path is already registered. If the sweep ever waits for a running swap, it waits on + // the caller waiting on it and neither ever finishes, so this asserts completion, not a result. + const swap = storage.overwrite(RECEIPT, '/var/mobile/tmp/still.jpg'); + let stall: ReturnType | undefined; + const stalled = new Promise((resolve) => { + stall = setTimeout(() => resolve('deadlocked'), 1000); + }); + + await expect(Promise.race([swap.then(() => 'settled'), stalled])).resolves.toBe('settled'); + await expect(swap).resolves.toBe(RECEIPT); + if (stall) { + clearTimeout(stall); + } + }); + it('puts back a stranded backup and deletes the copies nothing owns before it renames anything', async () => { const storage = loadFreshStorage(); const existing = new Set([`${FOLDER}/${RECEIPT}.backup`, `${FOLDER}/other.jpg`, `${FOLDER}/other.jpg.staged`]); From c74076b5f7a94548a307a02b8f0fdab885e47134 Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 17 Sep 2026 12:46:53 +0200 Subject: [PATCH 14/23] Fix spelling in a comment --- .../request/step/IOURequestStepScan/hooks/usePhotoUpgrade.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade.ts b/src/pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade.ts index 7937fa18b498..5dc17ee37e2a 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade.ts @@ -163,7 +163,7 @@ function usePhotoUpgrade() { } // The denominator for every outcome below. It rides on the capture span rather than the upgrade span - // because backgrounding the app cancels every open span, and the upgrade's own span is open for + // because sending the app to the background cancels every open span, and the upgrade's own span is open for // seconds while this one has already ended. Without it, a lost outcome is indistinguishable from a // capture that never tried, and the success rate reads high. getSpan(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE)?.setAttributes({[CONST.TELEMETRY.ATTRIBUTE_UPGRADE_ATTEMPTED]: true}); From 238d47b8babb516ea07b6b04b2fe4ada889d0124 Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 21 Sep 2026 14:12:45 +0200 Subject: [PATCH 15/23] Drop unnecessary comments --- src/CONST/index.ts | 1 - .../hooks/useReceiptThumbnailSource.ts | 2 - src/hooks/useLocalReceiptThumbnail.ts | 4 - src/hooks/useReceiptUpgradeCount.ts | 10 --- src/libs/ReceiptStorage/index.native.ts | 58 --------------- src/libs/ReceiptStorage/index.ts | 1 - src/libs/ReceiptStorage/receiptUpgrades.ts | 28 ------- src/libs/ReceiptStorage/types.ts | 43 ----------- .../prepareRequestPayload/index.native.ts | 5 -- .../components/Camera/index.native.tsx | 5 -- .../components/Camera/types.ts | 7 -- .../components/CameraViewport.tsx | 3 - .../components/ScanEditReceipt.tsx | 2 - .../components/ScanSkipConfirmation.tsx | 2 - .../hooks/usePhotoUpgrade.ts | 74 +------------------ .../utils/rotatePhotoToUpright.ts | 32 +------- src/setup/index.ts | 2 - 17 files changed, 8 insertions(+), 271 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index b9806f981529..fba4d5d1e626 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2471,7 +2471,6 @@ const CONST = { ATTRIBUTE_IS_MULTI_SCAN: 'is_multi_scan', ATTRIBUTE_CAPTURE_METHOD: 'capture_method', ATTRIBUTE_FLASH_USED: 'flash_used', - /** Set on the capture span, which ends in ~120ms and so survives a background that cancels the upgrade span. */ ATTRIBUTE_UPGRADE_ATTEMPTED: 'upgrade_attempted', ATTRIBUTE_UPGRADE_OUTCOME: 'upgrade_outcome', ATTRIBUTE_PHOTO_WIDTH: 'photo_width', diff --git a/src/components/MoneyRequestConfirmationListFooter/hooks/useReceiptThumbnailSource.ts b/src/components/MoneyRequestConfirmationListFooter/hooks/useReceiptThumbnailSource.ts index a47a7b799fd1..0044a4c2499a 100644 --- a/src/components/MoneyRequestConfirmationListFooter/hooks/useReceiptThumbnailSource.ts +++ b/src/components/MoneyRequestConfirmationListFooter/hooks/useReceiptThumbnailSource.ts @@ -44,8 +44,6 @@ function useReceiptThumbnailSource({transaction, receiptPath, receiptFilename}: // double-render or layout thrash. const resolvedReceiptImageStr = resolvedReceiptImage != null ? String(resolvedReceiptImage) : undefined; - // Pinning the source keeps a late thumbnail from flashing, but a receipt replaced by a better capture - // has to get through, so the count of upgrades is part of what is pinned. const upgradeCount = useReceiptUpgradeCount(resolvedReceiptImageStr); const [initialLocalSource, setInitialLocalSource] = useState<{source: string | undefined; resolvedImage: string | undefined; upgradeCount: number}>({ source: undefined, diff --git a/src/hooks/useLocalReceiptThumbnail.ts b/src/hooks/useLocalReceiptThumbnail.ts index 414680f70817..04205a816098 100644 --- a/src/hooks/useLocalReceiptThumbnail.ts +++ b/src/hooks/useLocalReceiptThumbnail.ts @@ -44,16 +44,12 @@ function useLocalReceiptThumbnail(sourceUri: string | undefined, isLocalFile: bo const [, startTransition] = useTransition(); const retainedUriRef = useRef(undefined); - // A receipt swapped for a better capture keeps its path, so the image loaders would serve the decode - // they already have. Counting the upgrades gives the same file a source they treat as new. const upgradeCount = useReceiptUpgradeCount(sourceUri); // Resolve cached thumbnails synchronously during render (fast path) const cachedUri = sourceUri ? thumbnailCache.get(sourceUri) : undefined; const resultForCurrentSource = asyncResult?.source === sourceUri ? asyncResult : undefined; const resolvedUri = cachedUri ?? resultForCurrentSource?.uri; - // The suffix is for the image loaders only. Nothing stores it, so a view handed this source can pass it - // back in without the receipt losing its identity. const thumbnailUri = resolvedUri && upgradeCount > 0 ? `${resolvedUri.split('#').at(0) ?? resolvedUri}#upgraded${upgradeCount}` : resolvedUri; const shouldGenerate = !!sourceUri && isLocalFile && !cachedUri; diff --git a/src/hooks/useReceiptUpgradeCount.ts b/src/hooks/useReceiptUpgradeCount.ts index 64734e815547..99b33390baa3 100644 --- a/src/hooks/useReceiptUpgradeCount.ts +++ b/src/hooks/useReceiptUpgradeCount.ts @@ -2,20 +2,10 @@ import {getUpgradeCount, subscribe} from '@libs/ReceiptStorage/receiptUpgrades'; import {useSyncExternalStore} from 'react'; -/** - * The receipt's durable name, from any URI that points at it. The fragment matters here: a view that has - * already been handed an upgraded source passes it back in, and keying on it would count a different - * receipt every time the file changes. - */ function toDurableName(sourceUri: string | undefined): string { return sourceUri?.split('/').pop()?.split('#').at(0)?.split('?').at(0) ?? ''; } -/** - * How many times the receipt at this URI has been replaced by a better capture during this launch. A view - * that shows the receipt can use it to tell one version of the file from the next, since the path itself - * never changes. - */ function useReceiptUpgradeCount(sourceUri: string | undefined): number { return useSyncExternalStore(subscribe, () => getUpgradeCount(toDurableName(sourceUri))); } diff --git a/src/libs/ReceiptStorage/index.native.ts b/src/libs/ReceiptStorage/index.native.ts index 8be37149418a..58b9a2fb40b4 100644 --- a/src/libs/ReceiptStorage/index.native.ts +++ b/src/libs/ReceiptStorage/index.native.ts @@ -16,29 +16,15 @@ import {claimForRead} from './receiptUpgrades'; // the app data container on most upgrades, so an absolute path stored before the upgrade names a // directory the device no longer has, even though iOS carried the file itself across. -/** Do not let a busy launch defer recovery forever. */ const SWEEP_IDLE_TIMEOUT_MS = 10000; const STAGED_SUFFIX = '.staged'; const BACKUP_SUFFIX = '.backup'; -/** - * Swaps running right now, by target path, from the moment `overwrite` is called. Owning an entry stops a - * second swap over the same receipt and keeps the leftover sweep off one mid-swap. - */ const swapsInFlight = new Map>(); -/** - * Swaps past their last claim check, by target path. From that point the receipt's own name is about to - * move and no guard can call it off, so this is the only part of a swap a reader ever waits for. - */ const committedSwaps = new Map>(); -/** - * Waits for a committed swap over this path, if one is running. Its outcome is not the caller's business: - * `overwrite` reports its own failures and leaves the receipt in place either way, so the caller checks the - * file for itself once the renames are done. - */ async function waitForCommittedSwap(target: string) { await committedSwaps.get(target)?.catch(() => {}); } @@ -88,10 +74,6 @@ const discard: ReceiptStorage['discard'] = async (uriOrPath) => { await RNFS.unlink(path); }; -/** - * Deletes a file left over by a swap. Never rethrows, since the receipt is already in its final state and - * a stale copy beside it is not worth failing over. - */ async function discardLeftover(path: string) { try { await discard(path); @@ -100,19 +82,12 @@ async function discardLeftover(path: string) { } } -/** - * Puts a receipt back when a swap was interrupted between its two renames, which leaves the file under the - * backup name with nothing at the receipt's own path. Resolves to whether the receipt is readable now. - */ async function restoreInterruptedSwap(target: string, {shouldWaitForRunningSwap = true} = {}): Promise { if (await RNFS.exists(target)) { return true; } - // A swap running right now is between its own two renames, so the receipt is missing for a moment - // rather than stranded. Moving the backup back here would undo the file that swap is installing. if (shouldWaitForRunningSwap && swapsInFlight.has(target)) { - // Housekeeping, not a read, so it waits for the staging half too. await swapsInFlight.get(target)?.catch(() => {}); return RNFS.exists(target); } @@ -126,7 +101,6 @@ async function restoreInterruptedSwap(target: string, {shouldWaitForRunningSwap await RNFS.moveFile(backupPath, target); } catch (error) { Log.warn('[ReceiptStorage] could not put back a receipt left by an interrupted swap', {error: error instanceof Error ? error.message : String(error)}); - // Something else may have put the receipt back in the meantime, which is a success, not a failure. return RNFS.exists(target); } @@ -145,14 +119,11 @@ async function swapIntoPlace(dir: string, durableName: string, target: string, u await discard(backupPath); await RNFS.moveFile(fileURIToPath(uriOrPath), stagedPath); - // Last moment at which nothing under the receipt's own name has moved, and everything above yields, so - // an upload can have claimed it since the caller's check. Staging only added a file beside it. if (shouldAbort?.()) { await discardLeftover(stagedPath); throw new Error('[ReceiptStorage] an upload claimed the receipt, so it was left as it stands'); } - // Same synchronous block as the check above, so a claim either stops the swap or finds this and waits. let releaseCommitted: () => void = () => {}; committedSwaps.set( target, @@ -165,14 +136,12 @@ async function swapIntoPlace(dir: string, durableName: string, target: string, u await RNFS.moveFile(target, backupPath); await RNFS.moveFile(stagedPath, target); } catch (error) { - // A full disk is the likeliest reason the swap failed, so free the staged bytes before restoring. await discardLeftover(stagedPath); if (await RNFS.exists(target)) { throw error; } - // The receipt exists only under the backup name now, so a failed move back is what loses it. try { await RNFS.moveFile(backupPath, target); } catch (restoreError) { @@ -182,40 +151,27 @@ async function swapIntoPlace(dir: string, durableName: string, target: string, u throw error; } finally { - // Either the new bytes are under the receipt's name or the original has been put back. Deleting the - // spare copy below never touches that name, so a reader has nothing left to wait for. committedSwaps.delete(target); releaseCommitted(); } - // The receipt is in place, so the copy set aside can go. await discardLeftover(backupPath); return verify(dir, durableName); } -/** - * Clears what a swap leaves behind by dying part-way: a `.staged` copy nothing consumed, or a `.backup` - * holding the only copy of a receipt. Left alone, a full-size `.backup` stays forever and looks like a - * receipt to anything listing the directory. - */ async function sweepInterruptedSwaps(dir: string) { const names = (await RNFS.readDir(dir)).map((entry) => entry.name); - // A `.backup` is the original receipt under a second name, so restore it wherever the receipt's own - // path is empty. Must never wait on a running swap: `overwrite` awaits the sweep after registering - // itself, so a wait here waits on the caller waiting on us. await Promise.all( names.filter((name) => name.endsWith(BACKUP_SUFFIX)).map((name) => restoreInterruptedSwap(`${dir}/${name.slice(0, -BACKUP_SUFFIX.length)}`, {shouldWaitForRunningSwap: false})), ); - // Anything still under a temporary name has no owner, whether or not the restore above took from it. await Promise.all(names.filter((name) => name.endsWith(STAGED_SUFFIX) || name.endsWith(BACKUP_SUFFIX)).map((name) => discardLeftover(`${dir}/${name}`))); } let leftoversSwept: Promise | undefined; -/** Runs the sweep once per launch. A sweep that fails is logged and not retried: it is only housekeeping. */ function whenLeftoversSwept(dir: string): Promise { leftoversSwept ??= sweepInterruptedSwaps(dir).catch((error: unknown) => { Log.warn('[ReceiptStorage] could not sweep leftover receipt copies', {error: error instanceof Error ? error.message : String(error)}); @@ -231,17 +187,12 @@ const overwrite: ReceiptStorage['overwrite'] = async (durableName, uriOrPath, sh const target = `${dir}/${durableName}`; - // Two swaps would clear each other's staged and backup names, leaving the loser renaming from a path - // holding the only copy. if (swapsInFlight.has(target)) { throw new Error('[ReceiptStorage] a swap is already running for this receipt'); } - // Registered before the first await: the checks below read the filesystem, and a reader arriving - // during them must not see the path as free. const swap = (async () => { await whenLeftoversSwept(dir); - // This swap already owns `target`, so consulting the map here would be waiting on itself. await restoreInterruptedSwap(target, {shouldWaitForRunningSwap: false}); await verify(dir, durableName); return swapIntoPlace(dir, durableName, target, uriOrPath, shouldAbort); @@ -273,7 +224,6 @@ const settle: ReceiptStorage['settle'] = async (durableName) => { return; } - // An upgrade still capturing or rotating gives up here, and the receipt is sent as it stands. claimForRead(durableName); const dir = getReceiptsUploadFolderPath(); @@ -281,8 +231,6 @@ const settle: ReceiptStorage['settle'] = async (durableName) => { return; } - // A swap past its own claim check is committed to its renames, so the only safe thing is to let it - // finish. await waitForCommittedSwap(`${dir}/${durableName}`); }; @@ -292,13 +240,10 @@ const locate: ReceiptStorage['locate'] = async (source) => { return undefined; } - // Claimed before reading, so the file cannot move underneath the read. if (isLocalFile(uri)) { const dir = getReceiptsUploadFolderPath(); const target = fileURIToPath(uri); - // Not awaited: this runs inside the request the sequential queue is processing, and the sweep is - // housekeeping for the folder as a whole. The receipt being read has its own recovery below. if (dir) { whenLeftoversSwept(dir).catch(() => {}); } @@ -306,13 +251,10 @@ const locate: ReceiptStorage['locate'] = async (source) => { await settle(target.split('/').pop() ?? ''); } - // The reason-only helper, so a stat failure here is reported once, by the caller that drops the receipt. if ((await checkFileExistsWithReason(uri)).exists) { return uri; } - // A receipt that is missing may only be missing from its own name, so look for an interrupted swap - // before the caller writes it off. if (!isLocalFile(uri) || !(await restoreInterruptedSwap(fileURIToPath(uri)))) { return undefined; } diff --git a/src/libs/ReceiptStorage/index.ts b/src/libs/ReceiptStorage/index.ts index c346eafb652a..8474b40b6c47 100644 --- a/src/libs/ReceiptStorage/index.ts +++ b/src/libs/ReceiptStorage/index.ts @@ -3,7 +3,6 @@ import type ReceiptStorage from './types'; /** Web has no filesystem to move receipts into, and a blob URL already lives as long as the document. */ const receiptStorage: ReceiptStorage = { adopt: (uriOrPath) => Promise.resolve(uriOrPath), - // Web captures through a canvas at full resolution, so there is nothing to swap or clean up. overwrite: (durableName) => Promise.resolve(durableName), discard: () => Promise.resolve(), locate: (source) => Promise.resolve(typeof source === 'string' ? source : undefined), diff --git a/src/libs/ReceiptStorage/receiptUpgrades.ts b/src/libs/ReceiptStorage/receiptUpgrades.ts index e27c0c916957..638aa5465bb4 100644 --- a/src/libs/ReceiptStorage/receiptUpgrades.ts +++ b/src/libs/ReceiptStorage/receiptUpgrades.ts @@ -1,26 +1,13 @@ -/** - * Tracks receipts whose file is about to be replaced by a better capture. - * - * Nothing here waits for an upgrade. Every reader is a request the sequential queue is processing, so a - * wait would put camera and rotation time in front of every later write, for an optional improvement. A - * reader claims the receipt instead, and the upgrade drops its photo. - * - * Keyed by durable name. Nothing survives a relaunch, and a receipt with no entry is not changing. - */ - import Log from '@libs/Log'; -/** Most receipts one launch keeps a version count for. Trimmed oldest-first beyond this. */ const MAX_TRACKED_UPGRADE_COUNTS = 50; type PendingUpgrade = { - /** Set once a reader has claimed the receipt, so the file must not change under it. */ isClaimed: boolean; }; const upgradesInFlight = new Map(); -/** How many times each receipt has been upgraded this launch, so a view can tell one version from the next. */ const upgradeCounts = new Map(); const listeners = new Set<() => void>(); @@ -31,7 +18,6 @@ function notify() { } } -/** Announces that a receipt is being upgraded. */ function start(durableName: string) { if (upgradesInFlight.has(durableName)) { return; @@ -41,7 +27,6 @@ function start(durableName: string) { notify(); } -/** Announces that a receipt has stopped changing, whether the upgrade landed or not. */ function finish(durableName: string) { const upgrade = upgradesInFlight.get(durableName); if (!upgrade) { @@ -52,15 +37,9 @@ function finish(durableName: string) { notify(); } -/** - * Records that a receipt's bytes actually changed. A view keys its image source on this count, so it moves - * only when the file did. - */ function recordUpgrade(durableName: string) { upgradeCounts.set(durableName, (upgradeCounts.get(durableName) ?? 0) + 1); - // Map iteration is insertion-ordered, and re-setting a key keeps its original position, so the front - // holds the receipts least likely to still be on screen. while (upgradeCounts.size > MAX_TRACKED_UPGRADE_COUNTS) { const oldest = upgradeCounts.keys().next().value; if (oldest === undefined) { @@ -72,12 +51,6 @@ function recordUpgrade(durableName: string) { notify(); } -/** - * Claims the receipt's current bytes, so the upgrade leaves the file alone. Returns straight away. - * - * The upgrade honours this at its last guard before the first rename. A claim landing after that guard - * cannot stop the swap, which is the one case a reader still has to wait out. - */ function claimForRead(durableName: string) { const upgrade = upgradesInFlight.get(durableName); if (!upgrade || upgrade.isClaimed) { @@ -92,7 +65,6 @@ function getUpgradeCount(durableName: string): number { return upgradeCounts.get(durableName) ?? 0; } -/** Whether a reader has claimed this receipt, meaning its bytes are already on their way to the server. */ function isClaimedForRead(durableName: string): boolean { return upgradesInFlight.get(durableName)?.isClaimed ?? false; } diff --git a/src/libs/ReceiptStorage/types.ts b/src/libs/ReceiptStorage/types.ts index 17986ecfb08e..5ab98a0b34c5 100644 --- a/src/libs/ReceiptStorage/types.ts +++ b/src/libs/ReceiptStorage/types.ts @@ -1,63 +1,20 @@ import type {ReceiptSource} from '@src/types/onyx/Transaction'; -/* - * A receipt is identified by its durable name for as long as it exists, and a better capture replaces the - * bytes behind that name rather than arriving as a second file. - * - * Pointing the receipt at a new file instead has no single place to update the pointer: the upload reads - * the source off the in-memory receipt object, and the replace-receipt screen never goes through the code - * that builds it. Overwriting in place reaches every reader without touching any of them, at the cost of - * the coordination in `receiptUpgrades` and the staged swap below. - */ - -/** Owns the receipts folder. The only code that writes a file there, names one, or resolves one. */ type ReceiptStorage = { - /** Moves a file into the receipts folder and returns its durable name. Rejects when the file did not land. */ adopt: (uriOrPath: string, fileName?: string) => Promise; - /** - * Swaps the bytes behind an adopted receipt, keeping its durable name so the confirmation preview, the - * upload and replace-receipt all read the new file. Rejects if the receipt is not in durable storage, - * another swap over the same receipt is already running, or the swap fails — in every case leaving the - * original in place. - * - * `shouldAbort` is consulted once more at the last moment before the receipt's own path changes. The - * work before that point yields several times, so a reader can claim the receipt after the caller's own - * check; asking again here means the swap backs out instead of renaming underneath a read in progress. - * Past that point the swap is committed, and a reader arriving late waits for the renames instead. - */ overwrite: (durableName: string, uriOrPath: string, shouldAbort?: () => boolean) => Promise; - /** Deletes a temporary file the app is done with. Resolves even when the file is already gone. */ discard: (uriOrPath: string) => Promise; - /** - * Resolves a stored source to a URI that can be read right now, or `undefined` when the file is gone. - * Also puts a receipt back when a swap was interrupted and left it under a temporary name. - */ locate: (source: ReceiptSource | null | undefined) => Promise; - /** - * Claims the receipt's current bytes for a caller about to send them, and resolves once its name is - * stable. An optional upgrade still preparing a better file gives up rather than being waited for, so - * the only wait here is a swap already committed to its renames — two renames inside one directory. - * - * `locate` claims on behalf of the callers it resolves a path for; a caller that sends the file object - * itself has to claim here directly. - */ settle: (durableName: string) => Promise; - /** Valid for this launch only, so never store the result. */ toLocalUri: (durableName: string) => string; - /** Re-roots a stored source onto the current folder. A remote source passes through unchanged. */ resolve: (source: ReceiptSource | null | undefined) => string | undefined; - /** - * Restores receipts an interrupted swap left under a backup name and clears stale temporary copies. - * Startup schedules this too, so recovery does not depend on a later upload reading the folder. Never - * rejects, since nothing awaits it. - */ sweepLeftovers: () => Promise; }; diff --git a/src/libs/prepareRequestPayload/index.native.ts b/src/libs/prepareRequestPayload/index.native.ts index 730d5fe80ca2..2ebdc3d0e836 100644 --- a/src/libs/prepareRequestPayload/index.native.ts +++ b/src/libs/prepareRequestPayload/index.native.ts @@ -42,8 +42,6 @@ const prepareRequestPayload: PrepareRequestPayload = (command, data, initiatedOf return ReceiptStorage.locate(source).then((localUri) => { if (!localUri) { const transactionID = typeof data.transactionID === 'string' ? data.transactionID : undefined; - // `locate` reports whether the receipt is readable, not why a stat failed, so the reason is - // read back here. This only runs once the receipt is already lost, never on the upload path. return checkFileExistsWithReason(ReceiptStorage.resolve(source) ?? source).then(({error}) => { logReceiptDropped({receiptTraceId, transactionID, command, source, fileName: name, statError: error}); }); @@ -58,9 +56,6 @@ const prepareRequestPayload: PrepareRequestPayload = (command, data, initiatedOf }); } - // ReplaceReceipt sends the file object rather than a receipt source, so it never reaches - // `locate` and claims here instead. Claiming here rather than before the action keeps the - // optimistic write and queue entry immediate, so a receipt replaced offline is never lost. if (name) { return ReceiptStorage.settle(name).then(appendValueAsIs); } diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx index bbf4d3f10812..80ee6d6c937c 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx @@ -173,17 +173,12 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou const path = getReceiptsUploadFolderPath(); - // `takeSnapshot` saves a screen-sized screenshot of the preview, so on that path a full-resolution - // `takePhoto` runs alongside it and replaces the receipt file once it lands. Nothing below awaits it. const shouldUpgradeToPhoto = canUpgradeReceiptQuality && !isMultiScanEnabled && !shouldTakePhoto({flash, hasFlash, isInLandscapeMode}); // The snapshot goes first so its request reaches the native queue ahead of the still. On iOS it // reads the most recent video frame, which a photo capture can interrupt. const receiptCapture = captureReceipt(camera.current, {flash, hasFlash, isPlatformMuted, path, isInLandscapeMode}); - // Handlers attached before the photo capture starts: a synchronous throw from `startPhotoCapture` - // would otherwise leave this promise unhandled, losing the receipt with no alert. The native - // snapshot request is already out, so this does not change the order they reach the camera. receiptCapture .then((photo: PhotoFile) => { endSpanWithAttributes(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE, { diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/types.ts b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/types.ts index d3c312647f39..4f66fc2d47b9 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/types.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/types.ts @@ -26,13 +26,6 @@ type CameraProps = { /** Called when the user taps the multi-scan submit button on the preview ribbon. Used by native and mobile web. */ onMultiScanSubmit?: () => void; - /** - * Whether a portrait, flash-off capture may be upgraded to the full-resolution photo after the shutter. - * - * Only a screen that stays mounted while the capture finishes can do this, since closing the camera - * session cancels the photo. Screens that pop or dismiss on capture opt out, because attempting it - * costs a capture and a temporary file and records a quality miss that never had a chance. - */ canUpgradeReceiptQuality?: boolean; }; diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx index 4dcfff538b8c..ba395fd7ff12 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx @@ -60,7 +60,6 @@ type CameraViewportProps = { /** Whether a photo has been captured (forces camera inactive) */ didCapturePhoto: boolean; - /** Whether a full-resolution photo capture is in flight, which keeps the camera session alive */ hasPendingPhotoCapture?: boolean; /** Callback fired when the camera finishes initializing */ @@ -119,8 +118,6 @@ function CameraViewport({ zoom={device.neutralZoom} photo cameraTabIndex={1} - // Closing the session cancels an in-flight `takePhoto` with "Camera is closed.", so a - // running capture keeps it open. This flag and losing focus would both close it. forceInactive={isAttachmentPickerActive || (didCapturePhoto && !hasPendingPhotoCapture)} shouldStayActiveWhenBlurred={hasPendingPhotoCapture} onInitialized={onInitialized} diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/ScanEditReceipt.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/ScanEditReceipt.tsx index f2878f2842f9..de0c4485afc1 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/ScanEditReceipt.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/ScanEditReceipt.tsx @@ -111,8 +111,6 @@ function ScanEditReceipt({report, transactionID, backTo, isEditing}: ScanEditRec onPicked={validateFiles} onAttachmentPickerStatusChange={setIsLoaderVisible} isReplacingReceipt - // This screen goes back on capture, which unmounts the camera and cancels a photo still in - // flight, so the upgrade could not land here even when it is wanted. canUpgradeReceiptQuality={false} /> diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx index 50fbd0fa6049..203e3116b7a4 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx @@ -454,8 +454,6 @@ function ScanSkipConfirmation({report, action, iouType, reportID, transactionID, onAttachmentPickerStatusChange={setIsLoaderVisible} onMultiScanSubmit={submitMultiScan} shouldAcceptMultipleFiles - // Submitting dismisses this screen, which unmounts the camera and cancels a photo still in - // flight, so the upgrade could not land here even when it is wanted. canUpgradeReceiptQuality={false} /> { Log.warn('[PhotoUpgrade] could not delete the abandoned photo', {error: error instanceof Error ? error.message : String(error)}); }); } -/** Callers call `discard` first, so a photo landing before this runs is deleted by the capture handler. */ function discardWhenItLands(pending: PendingPhoto) { pending.promise .then((photo) => { @@ -37,15 +30,9 @@ function discardWhenItLands(pending: PendingPhoto) { } discardPhoto(photo.path); }) - .catch(() => { - // `promise` resolves instead of rejecting, so there is nothing to clean up here. - }); + .catch(() => {}); } -/** - * Rejects when `promise` outlives `capMs`, so a step that never settles cannot hold the upgrade open. - * `discardLateResult` deletes a result that arrives afterwards, which nothing else is left to clean up. - */ function withDeadline(promise: Promise, capMs: number, step: string, discardLateResult?: (value: T) => void): Promise { let cap: ReturnType; let hasTimedOut = false; @@ -64,29 +51,22 @@ function withDeadline(promise: Promise, capMs: number, step: string, disca } discardLateResult(value); }) - .catch(() => { - // A rejection is the race's to report, and there is no result to clean up. - }); + .catch(() => {}); } return Promise.race([promise, deadline]).finally(() => clearTimeout(cap)); } type PendingPhoto = { - /** Resolves `undefined` when the capture failed or missed the deadline. */ promise: Promise; - /** Gives up on the photo, so whatever lands afterwards is deleted instead of kept. */ discard: () => void; - /** Shutter time, so the span covers the capture rather than starting when the receipt is handed over. */ startedAt: number; - /** Why the capture produced nothing, read once the race has settled. */ getFailureOutcome: () => string; }; -/** Named per receipt: an upgrade outlives its capture, so a later one must not end this one's span. */ function toUpgradeSpanId(durableName: string) { return `${CONST.TELEMETRY.SPAN_RECEIPT_UPGRADE}_${durableName}`; } @@ -96,28 +76,16 @@ function abandonPending(pending: PendingPhoto) { discardWhenItLands(pending); } -/** - * Upgrades a receipt captured with `takeSnapshot`, a screen-sized screenshot of the preview, to the - * full-resolution `takePhoto` photo. - * - * Nothing awaits `startPhotoCapture`, so the snapshot keeps driving navigation, and the photo is swapped - * in under the same durable name once it lands. `hasPendingPhotoCapture` holds the camera session open: - * closing it mid-capture cancels the photo with "Camera is closed." - */ function usePhotoUpgrade() { const [hasPendingPhotoCapture, setHasPendingPhotoCapture] = useState(false); const pendingPhotoRef = useRef(undefined); - // Holds the camera session open across the race. Counted rather than a flag because it is released - // when the race settles, which can be while the capture behind it is still running. + // `hasPendingPhotoCapture` holds the vision-camera session open: closing it mid-capture cancels the + // photo with "Camera is closed." const captureCountRef = useRef(0); - // Captures the camera has not finished yet. Not the same as `captureCountRef`, which follows the race - // below and reaches zero once that gives up — the capture itself runs on past that, and cannot be - // called off. const photosInFlightRef = useRef(0); - // A capture nobody claimed outlives the screen, so its file has to be cleaned up from here. useEffect( () => () => { const pending = pendingPhotoRef.current; @@ -139,8 +107,6 @@ function usePhotoUpgrade() { }; const startPhotoCapture = (camera: Camera) => { - // This capture was never handed a receipt, and the shutter below replaces it. Cleared before the - // guard, so a capture that skips the upgrade cannot inherit this one's photo. const orphaned = pendingPhotoRef.current; pendingPhotoRef.current = undefined; if (orphaned) { @@ -153,8 +119,6 @@ function usePhotoUpgrade() { // baseline. A receipt keeping its snapshot is the cheaper loss. if (photosInFlightRef.current > 0) { Log.info('[PhotoUpgrade] a full-resolution capture is still running, so this receipt keeps its snapshot'); - // No receipt exists yet, so there is no upgrade span to end. The capture span is still open and - // is the only place this outcome can be recorded. getSpan(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE)?.setAttributes({ [CONST.TELEMETRY.ATTRIBUTE_UPGRADE_ATTEMPTED]: false, [CONST.TELEMETRY.ATTRIBUTE_UPGRADE_OUTCOME]: CONST.TELEMETRY.UPGRADE_OUTCOME.STACKED_CAPTURE_SKIPPED, @@ -162,21 +126,14 @@ function usePhotoUpgrade() { return; } - // The denominator for every outcome below. It rides on the capture span rather than the upgrade span - // because sending the app to the background cancels every open span, and the upgrade's own span is open for - // seconds while this one has already ended. Without it, a lost outcome is indistinguishable from a - // capture that never tried, and the success rate reads high. getSpan(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE)?.setAttributes({[CONST.TELEMETRY.ATTRIBUTE_UPGRADE_ATTEMPTED]: true}); const startedAt = Date.now(); let failureOutcome: string = CONST.TELEMETRY.UPGRADE_OUTCOME.CAPTURE_FAILED; let isDiscarded = false; - // Temp directory, not the receipts folder, so ReceiptStorage stays its only writer and a discarded - // photo never sits among the receipts. const nativeCapture = camera.takePhoto({flash: 'off', enableShutterSound: false, path: RNFS.TemporaryDirectoryPath}); - // Released when the capture settles, which is what the guard above reads. photosInFlightRef.current += 1; const releasePhotoSlot = () => { photosInFlightRef.current -= 1; @@ -198,8 +155,6 @@ function usePhotoUpgrade() { }, ); - // Outlives the screen on purpose: the swap finishes after the scan page is gone, and a late - // arrival still needs deleting. let deadlineTimeout: ReturnType; const deadline = new Promise((resolve) => { deadlineTimeout = setTimeout(() => { @@ -209,7 +164,6 @@ function usePhotoUpgrade() { }, PHOTO_CAPTURE_TIMEOUT_MS); }); - // Released when the race settles, not when the swap finishes: the rotate and swap need no camera. const promise = Promise.race([capture, deadline]).then((photo) => { clearTimeout(deadlineTimeout); releaseSession(); @@ -237,16 +191,12 @@ function usePhotoUpgrade() { const upgradeReceiptWithPhoto = (durableName: string) => { const pending = pendingPhotoRef.current; if (!pending) { - // The only branch that would otherwise say nothing at all, which once left a capture with no - // `[PhotoUpgrade]` line and no way to tell what had happened to it. Log.info('[PhotoUpgrade] no pending photo for this receipt, so it keeps its snapshot', false, {durableName}); return; } pendingPhotoRef.current = undefined; const spanId = toUpgradeSpanId(durableName); - // The branches below are not mutually exclusive: a swap that already reported an outcome can still - // reject afterwards. Reporting once is this hook's business rather than a detail of `endSpan`. let hasReportedOutcome = false; const reportOutcome = (outcome: string, attributes: Record = {}) => { if (hasReportedOutcome) { @@ -255,8 +205,6 @@ function usePhotoUpgrade() { hasReportedOutcome = true; endSpanWithAttributes(spanId, {[CONST.TELEMETRY.ATTRIBUTE_UPGRADE_OUTCOME]: outcome, ...attributes}); }; - // Started from the shutter, so the span covers the capture as well as the swap. It deliberately has - // no parent: the upgrade outlives the screen, and its shutter span has usually ended by now. startSpan(spanId, { name: CONST.TELEMETRY.SPAN_RECEIPT_UPGRADE, op: CONST.TELEMETRY.SPAN_RECEIPT_UPGRADE, @@ -266,7 +214,6 @@ function usePhotoUpgrade() { }, }); - // From here a reader can claim these bytes, and a view can refresh once they change. startUpgrade(durableName); pending.promise @@ -277,17 +224,12 @@ function usePhotoUpgrade() { } return withDeadline(rotatePhotoToUpright(photo.path, photo.orientation), ROTATE_TIMEOUT_MS, 'rotate', (latePath) => { - // The swap gave up on this rotate, so the copy it went on to write is left orphaned. if (!latePath) { return; } discardPhoto(latePath); }).then((uprightPath) => { - // An upload is sending the snapshot. Renaming now would hand it the old bytes, the - // new ones, or a missing file. if (wasReceiptClaimed(durableName)) { - // Logged like every other outcome: a background cancels the span above, and then the - // log is the only record that this receipt was upgraded right up to the rename. Log.info('[PhotoUpgrade] keeping the snapshot, upgrade claimed for upload before the swap started', false, {durableName}); reportOutcome(CONST.TELEMETRY.UPGRADE_OUTCOME.CLAIMED_FOR_UPLOAD); discardPhoto(photo.path); @@ -297,8 +239,6 @@ function usePhotoUpgrade() { return undefined; } - // Asked again immediately before the rename, since everything between the two checks - // yields. return ReceiptStorage.overwrite(durableName, uprightPath ?? photo.path, () => wasReceiptClaimed(durableName)) .then(() => { Log.info('[PhotoUpgrade] receipt upgraded to the full-resolution photo', false, { @@ -306,23 +246,18 @@ function usePhotoUpgrade() { height: photo.height, wasRotated: !!uprightPath, }); - // Source dimensions, so the resolution the upgrade actually wins is queryable per device. reportOutcome(CONST.TELEMETRY.UPGRADE_OUTCOME.UPGRADED, { [CONST.TELEMETRY.ATTRIBUTE_PHOTO_WIDTH]: photo.width, [CONST.TELEMETRY.ATTRIBUTE_PHOTO_HEIGHT]: photo.height, }); - // Only now have the bytes changed, which is what reloads the unchanged path. recordUpgrade(durableName); if (!uprightPath) { return; } - // `overwrite` moved the rotated copy, leaving the original behind. Its - // cleanup must not fail an upgrade that already went through. discardPhoto(photo.path); }) .catch((error: unknown) => { - // `overwrite` can reject before it moves the rotated copy. if (uprightPath) { discardPhoto(uprightPath); } @@ -331,7 +266,6 @@ function usePhotoUpgrade() { }); }) .catch((error: unknown) => { - // A swap that backed out for an upload is not a failure, and gets its own outcome. const wasClaimed = wasReceiptClaimed(durableName); const reason = error instanceof Error ? error.message : String(error); Log.warn(`[PhotoUpgrade] keeping the snapshot, upgrade ${wasClaimed ? 'claimed for upload' : 'failed'}`, {error: reason}); diff --git a/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts b/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts index 7de1e22372a9..5ce67e5e6f96 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts @@ -7,38 +7,23 @@ import type {Orientation} from 'react-native-vision-camera'; import {ImageManipulator, SaveFormat} from 'expo-image-manipulator'; import ImageSize from 'react-native-image-size'; -/** - * Turns a full-resolution camera photo upright before it replaces a scanned receipt. - */ - type PhotoSize = { - /** Pixel width of the photo as it sits on disk */ width: number; - /** Pixel height of the photo as it sits on disk */ height: number; - /** Clockwise degrees the file's metadata asks for, which only Android reports */ rotation?: number; }; -/** - * Clockwise degrees to turn the photo by, or `undefined` to keep the file as captured. - * - * Measure the frame the way the image loader hands it over, since the loader applies the file's rotation - * metadata while decoding: a 1920x1440 photo whose metadata asks for 90 decodes as 1440x1920, and turning - * that again puts it back on its side. - */ function getUprightRotation({width, height, rotation = 0}: PhotoSize, orientation?: Orientation): number | undefined { + // The image loader applies the file's rotation metadata while decoding, so measure the frame the way it + // hands it over: a 1920x1440 photo whose metadata asks for 90 decodes as 1440x1920. const isSideways = rotation === 90 || rotation === 270; const decodedWidth = isSideways ? height : width; const decodedHeight = isSideways ? width : height; - // This upgrade only runs on the portrait path, so a frame that decodes landscape carries no rotation - // and needs the sensor's turn. Only a quarter-turn makes it portrait, so the hold picks the direction. - // - // `PhotoFile.orientation` is the display orientation relative to the sensor, as a counter-clockwise - // angle (see VisionCamera's `RotationHelper`): a portrait capture off a landscape sensor reports + // VisionCamera's `PhotoFile.orientation` is the display orientation relative to the sensor, as a + // counter-clockwise angle (see its `RotationHelper`): a portrait capture off a landscape sensor reports // `landscape-left`, so 270 counter-clockwise, so 90 clockwise. The opposite hold turns the other way. const quarterTurn = orientation === 'landscape-right' ? 270 : 90; const angle = decodedWidth > decodedHeight ? quarterTurn : 0; @@ -50,13 +35,6 @@ function getUprightRotation({width, height, rotation = 0}: PhotoSize, orientatio return angle; } -/** - * Bakes the rotation of a full-resolution camera photo into its pixels. Rejects instead of falling back to - * the photo as captured, since a receipt lying on its side is worse than the snapshot the caller has. - * - * @param orientation decides the direction of a quarter-turn - * @returns path of an upright temporary copy, or `undefined` when the photo needs no turn - */ function rotatePhotoToUpright(stillPath: string, orientation?: Orientation): Promise { const sourceUri = getPhotoSource(stillPath); const startedAt = Date.now(); @@ -76,8 +54,6 @@ function rotatePhotoToUpright(stillPath: string, orientation?: Orientation): Pro .renderAsync() .then((image) => image.saveAsync({compress: JPEG_QUALITY, format: SaveFormat.JPEG})) .then((result) => { - // Reached only by a photo that genuinely decoded on its side, so `durationMs` is what - // `ROTATE_TIMEOUT_MS` has to cover. Log.info('[PhotoUpgrade] rotated the full-resolution photo', false, { durationMs: Date.now() - startedAt, angle, diff --git a/src/setup/index.ts b/src/setup/index.ts index 940b35dc8303..08ec1d842410 100644 --- a/src/setup/index.ts +++ b/src/setup/index.ts @@ -102,8 +102,6 @@ export default function () { cleanupPreMountedDraftReports(); - // An interrupted swap can leave the receipt only under its backup name. Recovers it without waiting for - // the next upload, and waits for idle first so the folder listing does not compete with startup. ReceiptStorage.sweepLeftovers(); // Register the commands after Onyx is initialized so every JS runtime can process paginated From b2300b15b111f812cd197cc9f967501432e22d98 Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 24 Sep 2026 15:19:27 +0200 Subject: [PATCH 16/23] address review comments --- src/hooks/useReceiptUpgradeCount.ts | 4 +- src/libs/ReceiptStorage/index.native.ts | 18 +- src/libs/ReceiptStorage/index.ts | 1 + src/libs/ReceiptStorage/receiptUpgrades.ts | 4 + src/libs/ReceiptStorage/types.ts | 2 + src/libs/actions/IOU/Receipt.ts | 20 +- .../validateReceiptFile/index.native.ts | 11 +- .../components/Camera/types.ts | 1 + .../components/CameraViewport.tsx | 1 + .../hooks/usePhotoUpgrade.ts | 6 +- .../utils/rotatePhotoToUpright.ts | 4 + tests/unit/libs/receiptReadDuringSwapTest.ts | 274 ++++++++++++++++++ tests/unit/libs/receiptStorageTest.ts | 156 ++++++++++ 13 files changed, 489 insertions(+), 13 deletions(-) create mode 100644 tests/unit/libs/receiptReadDuringSwapTest.ts diff --git a/src/hooks/useReceiptUpgradeCount.ts b/src/hooks/useReceiptUpgradeCount.ts index 99b33390baa3..1cbe83539b7b 100644 --- a/src/hooks/useReceiptUpgradeCount.ts +++ b/src/hooks/useReceiptUpgradeCount.ts @@ -2,12 +2,12 @@ import {getUpgradeCount, subscribe} from '@libs/ReceiptStorage/receiptUpgrades'; import {useSyncExternalStore} from 'react'; -function toDurableName(sourceUri: string | undefined): string { +function toUpgradeKey(sourceUri: string | undefined): string { return sourceUri?.split('/').pop()?.split('#').at(0)?.split('?').at(0) ?? ''; } function useReceiptUpgradeCount(sourceUri: string | undefined): number { - return useSyncExternalStore(subscribe, () => getUpgradeCount(toDurableName(sourceUri))); + return useSyncExternalStore(subscribe, () => getUpgradeCount(toUpgradeKey(sourceUri))); } export default useReceiptUpgradeCount; diff --git a/src/libs/ReceiptStorage/index.native.ts b/src/libs/ReceiptStorage/index.native.ts index 58b9a2fb40b4..25642bd02736 100644 --- a/src/libs/ReceiptStorage/index.native.ts +++ b/src/libs/ReceiptStorage/index.native.ts @@ -262,6 +262,22 @@ const locate: ReceiptStorage['locate'] = async (source) => { return uri; }; +const recheckAfterSwap: ReceiptStorage['recheckAfterSwap'] = async (source) => { + const uri = resolve(source); + if (!uri || !isLocalFile(uri)) { + return false; + } + + const target = fileURIToPath(uri); + try { + await swapsInFlight.get(target)?.catch(() => {}); + return await restoreInterruptedSwap(target); + } catch (error) { + Log.warn('[ReceiptStorage] could not recheck a receipt after a failed read', {error: error instanceof Error ? error.message : String(error)}); + return false; + } +}; + const sweepLeftovers: ReceiptStorage['sweepLeftovers'] = async () => { try { const dir = getReceiptsUploadFolderPath(); @@ -278,6 +294,6 @@ const sweepLeftovers: ReceiptStorage['sweepLeftovers'] = async () => { } }; -const receiptStorage: ReceiptStorage = {adopt, overwrite, discard, locate, settle, toLocalUri, resolve, sweepLeftovers}; +const receiptStorage: ReceiptStorage = {adopt, overwrite, discard, locate, settle, recheckAfterSwap, toLocalUri, resolve, sweepLeftovers}; export default receiptStorage; diff --git a/src/libs/ReceiptStorage/index.ts b/src/libs/ReceiptStorage/index.ts index 8474b40b6c47..86dab1f3a3be 100644 --- a/src/libs/ReceiptStorage/index.ts +++ b/src/libs/ReceiptStorage/index.ts @@ -7,6 +7,7 @@ const receiptStorage: ReceiptStorage = { discard: () => Promise.resolve(), locate: (source) => Promise.resolve(typeof source === 'string' ? source : undefined), settle: () => Promise.resolve(), + recheckAfterSwap: () => Promise.resolve(false), toLocalUri: (durableName) => durableName, resolve: (source) => (typeof source === 'string' ? source : undefined), sweepLeftovers: () => Promise.resolve(), diff --git a/src/libs/ReceiptStorage/receiptUpgrades.ts b/src/libs/ReceiptStorage/receiptUpgrades.ts index 638aa5465bb4..2b7da307cfb3 100644 --- a/src/libs/ReceiptStorage/receiptUpgrades.ts +++ b/src/libs/ReceiptStorage/receiptUpgrades.ts @@ -1,3 +1,7 @@ +/** + * Tracks in-flight receipt upgrades and how many times each receipt has been replaced, so views can + * re-decode a file whose bytes changed under an unchanged path and uploads can claim a receipt. + */ import Log from '@libs/Log'; const MAX_TRACKED_UPGRADE_COUNTS = 50; diff --git a/src/libs/ReceiptStorage/types.ts b/src/libs/ReceiptStorage/types.ts index 5ab98a0b34c5..8512bb5309bd 100644 --- a/src/libs/ReceiptStorage/types.ts +++ b/src/libs/ReceiptStorage/types.ts @@ -11,6 +11,8 @@ type ReceiptStorage = { settle: (durableName: string) => Promise; + recheckAfterSwap: (source: ReceiptSource | null | undefined) => Promise; + toLocalUri: (durableName: string) => string; resolve: (source: ReceiptSource | null | undefined) => string | undefined; diff --git a/src/libs/actions/IOU/Receipt.ts b/src/libs/actions/IOU/Receipt.ts index 99416ca7a3e9..d5f238c67043 100644 --- a/src/libs/actions/IOU/Receipt.ts +++ b/src/libs/actions/IOU/Receipt.ts @@ -344,6 +344,22 @@ function setMoneyRequestReceipt( }); } +function readReceiptFile(receiptFilename: string, receiptPath: ReceiptSource, receiptType: string | undefined, onSuccess: (file: File) => void, onFailure: () => void) { + const path = ReceiptStorage.resolve(receiptPath) ?? receiptPath.toString(); + let didFail = false; + const markFailed = () => { + didFail = true; + }; + + return readFileAsync(path, receiptFilename, onSuccess, markFailed, receiptType).then(() => { + if (!didFail) { + return; + } + + return ReceiptStorage.recheckAfterSwap(receiptPath).then((isPresent) => (isPresent ? readFileAsync(path, receiptFilename, onSuccess, onFailure, receiptType) : onFailure())); + }); +} + // eslint-disable-next-line rulesdir/no-negated-variables function navigateToStartStepIfScanFileCannotBeRead( receiptFilename: string | undefined, @@ -379,7 +395,7 @@ function navigateToStartStepIfScanFileCannotBeRead( } navigateToStartMoneyRequestStep(requestType, iouType, transactionID, reportID); }; - readFileAsync(ReceiptStorage.resolve(receiptPath) ?? receiptPath.toString(), receiptFilename, onSuccess, onFailure, receiptType); + return readReceiptFile(receiptFilename, receiptPath, receiptType, onSuccess, onFailure); } function checkIfLocalFileIsAccessible( @@ -394,7 +410,7 @@ function checkIfLocalFileIsAccessible( return Promise.resolve(); } - return readFileAsync(ReceiptStorage.resolve(receiptPath) ?? receiptPath.toString(), receiptFilename, onSuccess, onFailure, receiptType); + return readReceiptFile(receiptFilename, receiptPath, receiptType, onSuccess, onFailure); } export {checkIfLocalFileIsAccessible, detachReceipt, navigateToStartStepIfScanFileCannotBeRead, replaceReceipt, setMoneyRequestReceipt}; diff --git a/src/libs/fileDownload/validateReceiptFile/index.native.ts b/src/libs/fileDownload/validateReceiptFile/index.native.ts index 22af1fef7ce7..55e31d8627db 100644 --- a/src/libs/fileDownload/validateReceiptFile/index.native.ts +++ b/src/libs/fileDownload/validateReceiptFile/index.native.ts @@ -24,14 +24,11 @@ function validateReceiptFile( const localPath = ReceiptStorage.resolve(receiptPath) ?? receiptPathString; - return checkFileExists(localPath).then((exists) => { - if (!exists) { - onFailure(); - return; - } + const succeed = () => onSuccess({uri: localPath, name: receiptFilename, type: receiptType, source: localPath} as File); - onSuccess({uri: localPath, name: receiptFilename, type: receiptType, source: localPath} as File); - }); + return checkFileExists(localPath) + .then((exists) => exists || ReceiptStorage.recheckAfterSwap(receiptPath).then((isPresent) => isPresent && checkFileExists(localPath))) + .then((exists) => (exists ? succeed() : onFailure())); } export default validateReceiptFile; diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/types.ts b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/types.ts index 4f66fc2d47b9..44285d9f4d56 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/types.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/types.ts @@ -26,6 +26,7 @@ type CameraProps = { /** Called when the user taps the multi-scan submit button on the preview ribbon. Used by native and mobile web. */ onMultiScanSubmit?: () => void; + /** Whether the shutter may take a full-resolution photo to upgrade the fast snapshot. Defaults to true; off for replace-receipt and multi-scan. */ canUpgradeReceiptQuality?: boolean; }; diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx index ba395fd7ff12..2be995fd966e 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx @@ -60,6 +60,7 @@ type CameraViewportProps = { /** Whether a photo has been captured (forces camera inactive) */ didCapturePhoto: boolean; + /** Whether a full-resolution capture is still running; keeps the camera session active even after didCapturePhoto so the capture is not cancelled */ hasPendingPhotoCapture?: boolean; /** Callback fired when the camera finishes initializing */ diff --git a/src/pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade.ts b/src/pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade.ts index 56d7d58b0fc7..7623e468b793 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade.ts @@ -1,3 +1,7 @@ +/** + * Captures a full-resolution photo alongside the fast snapshot and, once a receipt exists, swaps the + * higher-quality photo in behind it. Owns capture deadlines, rotation, and upgrade telemetry. + */ import Log from '@libs/Log'; import ReceiptStorage from '@libs/ReceiptStorage'; import {finish as finishUpgrade, isClaimedForRead as wasReceiptClaimed, recordUpgrade, start as startUpgrade} from '@libs/ReceiptStorage/receiptUpgrades'; @@ -115,7 +119,7 @@ function usePhotoUpgrade() { // Abandoning a capture does not stop it: `discard` only marks the result for deletion, and // vision-camera cannot cancel a `takePhoto`. A second one started over a capture still running - // makes them contend, and the next `takeSnapshot` pays for it — 6.6s measured against an 80ms + // makes them contend, and the next `takeSnapshot` pays for it. 6.6s measured against an 80ms // baseline. A receipt keeping its snapshot is the cheaper loss. if (photosInFlightRef.current > 0) { Log.info('[PhotoUpgrade] a full-resolution capture is still running, so this receipt keeps its snapshot'); diff --git a/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts b/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts index 5ce67e5e6f96..b9aa44a9a708 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts @@ -1,3 +1,7 @@ +/** + * Rotates a captured still so the receipt reads upright, reconciling the decoded frame's orientation + * with VisionCamera's display orientation. Returns undefined when the photo is already upright. + */ import {JPEG_QUALITY} from '@libs/fileDownload/FileUtils'; import getPhotoSource from '@libs/fileDownload/getPhotoSource'; import Log from '@libs/Log'; diff --git a/tests/unit/libs/receiptReadDuringSwapTest.ts b/tests/unit/libs/receiptReadDuringSwapTest.ts new file mode 100644 index 000000000000..465bdafa3ab8 --- /dev/null +++ b/tests/unit/libs/receiptReadDuringSwapTest.ts @@ -0,0 +1,274 @@ +import {checkIfLocalFileIsAccessible, navigateToStartStepIfScanFileCannotBeRead} from '@libs/actions/IOU/Receipt'; +import type ValidateReceiptFileNative from '@libs/fileDownload/validateReceiptFile/index.native'; +import fileURIToPath from '@libs/fileURIToPath'; +import ReceiptStorage from '@libs/ReceiptStorage'; + +import CONST from '@src/CONST'; + +const FOLDER = '/var/mobile/Containers/Data/Application/AAAA-1111/Documents/Receipts-Upload'; +const RECEIPT = 'CAM-1.jpg'; +const RECEIPT_PATH = `${FOLDER}/${RECEIPT}`; +const RECEIPT_URI = `file://${RECEIPT_PATH}`; + +const mockExisting = new Set(); +const mockDirectories = new Set(); +const mockIsOnDisk = (path?: string) => mockExisting.has(fileURIToPath(path ?? '')); + +let mockHoldRead: Promise | undefined; +const mockStat = (path?: string) => (mockHoldRead ? mockHoldRead.then(() => mockIsOnDisk(path)) : Promise.resolve(mockIsOnDisk(path))); + +jest.mock('react-native-fs', () => ({ + exists: (path: string) => Promise.resolve(mockExisting.has(path) || mockDirectories.has(path)), + moveFile: jest.fn(), + mkdir: () => Promise.resolve(), + unlink: (path: string) => { + mockExisting.delete(path); + return Promise.resolve(); + }, + readDir: () => Promise.resolve([]), +})); + +jest.mock('@libs/fileDownload/checkFileExists', () => ({ + __esModule: true, + default: (path?: string) => mockStat(path), + checkFileExistsWithReason: (path?: string) => mockStat(path).then((exists) => ({exists})), +})); + +jest.mock('@libs/fileDownload/FileUtils', () => ({ + ...jest.requireActual>('@libs/fileDownload/FileUtils'), + readFileAsync: (path: string, fileName: string, onSuccess: (file: {uri: string}) => void, onFailure: () => void) => { + if (mockIsOnDisk(path)) { + onSuccess({uri: path}); + } else { + onFailure(); + } + return Promise.resolve(); + }, +})); + +jest.mock('@libs/getReceiptsUploadFolderPath', () => ({ + __esModule: true, + default: () => FOLDER, +})); + +// Jest resolves the bare specifier to the web implementation, which has no swap to race. +jest.mock('@libs/ReceiptStorage', () => jest.requireActual>('@libs/ReceiptStorage/index.native.ts')); + +jest.mock('@libs/Navigation/Navigation', () => ({navigate: jest.fn()})); + +jest.mock('@libs/IOUUtils', () => ({ + ...jest.requireActual>('@libs/IOUUtils'), + navigateToStartMoneyRequestStep: jest.fn(), +})); + +const {default: validateReceiptFile}: {default: typeof ValidateReceiptFileNative} = jest.requireActual('@libs/fileDownload/validateReceiptFile/index.native.ts'); + +const mockMoveFile: jest.Mock, [string, string]> = jest.requireMock<{moveFile: jest.Mock, [string, string]>}>('react-native-fs').moveFile; + +async function holdSwapWithReceiptMissing() { + let release: () => void = () => {}; + const receiptMissing = new Promise((resolve) => { + mockMoveFile.mockImplementation((from: string, to: string) => { + mockExisting.delete(from); + if (from === `${RECEIPT_PATH}.staged` && to === RECEIPT_PATH) { + resolve(); + return new Promise((settle) => { + release = () => { + mockExisting.add(to); + settle(); + }; + }); + } + mockExisting.add(to); + return Promise.resolve(); + }); + }); + + const swap = ReceiptStorage.overwrite(RECEIPT, '/var/mobile/tmp/still.jpg'); + await receiptMissing; + + return { + finishSwap: () => { + release(); + return swap; + }, + }; +} + +describe('reading a receipt while an upgrade swaps it', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockExisting.clear(); + mockDirectories.clear(); + mockExisting.add(RECEIPT_PATH); + mockExisting.add('/var/mobile/tmp/still.jpg'); + mockHoldRead = undefined; + }); + + const flushPendingWork = () => + new Promise((resolve) => { + setImmediate(resolve); + }); + + it('confirms the receipt is readable instead of reporting it unreadable', async () => { + // Given an upgrade swap caught between its two renames, with nothing at the receipt's own path + const {finishSwap} = await holdSwapWithReceiptMissing(); + expect(mockExisting.has(RECEIPT_PATH)).toBe(false); + + // When the confirmation page checks the receipt can be read + const onSuccess = jest.fn(); + const onFailure = jest.fn(); + const check = checkIfLocalFileIsAccessible(RECEIPT, RECEIPT_URI, 'image/jpeg', onSuccess, onFailure); + await finishSwap(); + await check; + + // Then the check sees the upgraded receipt. A failure here restarts the request and drops the receipt. + expect(onFailure).not.toHaveBeenCalled(); + expect(onSuccess).toHaveBeenCalledWith(expect.objectContaining({uri: RECEIPT_URI})); + }); + + it('keeps the receipt when the participants step checks it mid-swap', async () => { + // Given an upgrade swap caught between its two renames + const {finishSwap} = await holdSwapWithReceiptMissing(); + + // When the participants step checks the scanned file can be read + const onSuccess = jest.fn(); + const onFailure = jest.fn(); + const check = navigateToStartStepIfScanFileCannotBeRead(RECEIPT, RECEIPT_URI, onSuccess, CONST.IOU.REQUEST_TYPE.MANUAL, CONST.IOU.TYPE.SUBMIT, '1', '2', 'image/jpeg', onFailure); + await finishSwap(); + await check; + + // Then the receipt is kept. The failure path clears it from the transaction and sends the user back to scan. + expect(onFailure).not.toHaveBeenCalled(); + expect(onSuccess).toHaveBeenCalled(); + }); + + it('passes receipt validation on the confirmation page', async () => { + // Given an upgrade swap caught between its two renames + const {finishSwap} = await holdSwapWithReceiptMissing(); + + // When the confirmation page validates the receipt file before submit + const onSuccess = jest.fn(); + const onFailure = jest.fn(); + const check = validateReceiptFile(RECEIPT, RECEIPT_URI, 'image/jpeg', onSuccess, onFailure); + await finishSwap(); + await check; + + // Then validation passes rather than flagging a receipt that is only being swapped + expect(onFailure).not.toHaveBeenCalled(); + expect(onSuccess).toHaveBeenCalledWith(expect.objectContaining({uri: RECEIPT_URI})); + }); + + it('keeps the receipt when a validation that started before the swap lands in its gap', async () => { + // Given an upgrade swap still staging its new file, before it has touched the receipt + const holds: Record<'staging' | 'secondRename', () => void> = { + staging: () => {}, + secondRename: () => {}, + }; + let stagingStarted: () => void = () => {}; + let gapOpened: () => void = () => {}; + const isStaging = new Promise((resolve) => { + stagingStarted = resolve; + }); + const isInGap = new Promise((resolve) => { + gapOpened = resolve; + }); + mockMoveFile.mockImplementation((from: string, to: string) => { + const move = () => { + mockExisting.delete(from); + mockExisting.add(to); + }; + const hold = (name: keyof typeof holds, signal: () => void) => { + signal(); + return new Promise((settle) => { + holds[name] = () => { + move(); + settle(); + }; + }); + }; + if (to === `${RECEIPT_PATH}.staged`) { + return hold('staging', stagingStarted); + } + if (from === `${RECEIPT_PATH}.staged` && to === RECEIPT_PATH) { + return hold('secondRename', gapOpened); + } + move(); + return Promise.resolve(); + }); + const swap = ReceiptStorage.overwrite(RECEIPT, '/var/mobile/tmp/still.jpg'); + await isStaging; + + // And a validation that has started reading the file, slowly + let finishRead: () => void = () => {}; + mockHoldRead = new Promise((resolve) => { + finishRead = resolve; + }); + const onSuccess = jest.fn(); + const onFailure = jest.fn(); + const check = validateReceiptFile(RECEIPT, RECEIPT_URI, 'image/jpeg', onSuccess, onFailure); + await flushPendingWork(); + + // When the swap reaches its gap and the read lands there, finding nothing at the receipt's path + holds.staging(); + await isInGap; + expect(mockExisting.has(RECEIPT_PATH)).toBe(false); + mockHoldRead = undefined; + finishRead(); + await flushPendingWork(); + + // Then the missed read is not reported while the swap is still running + expect(onFailure).not.toHaveBeenCalled(); + expect(onSuccess).not.toHaveBeenCalled(); + + // And once the swap is done, the recheck finds the upgraded receipt and it passes + holds.secondRename(); + await swap; + await check; + expect(onFailure).not.toHaveBeenCalled(); + expect(onSuccess).toHaveBeenCalledWith(expect.objectContaining({uri: RECEIPT_URI})); + }); + + it('still reports a receipt that is really gone', async () => { + // Given a receipt that is missing with no swap and no backup + mockExisting.clear(); + + // When the confirmation page validates it + const onSuccess = jest.fn(); + const onFailure = jest.fn(); + await validateReceiptFile(RECEIPT, RECEIPT_URI, 'image/jpeg', onSuccess, onFailure); + + // Then the failure goes through, so a lost receipt is still sent back to scan + expect(onFailure).toHaveBeenCalled(); + expect(onSuccess).not.toHaveBeenCalled(); + }); + + it('still reports an unreadable receipt from the participants step', async () => { + // Given a receipt that is missing with no swap and no backup + mockExisting.clear(); + + // When the participants step checks it can be read + const onSuccess = jest.fn(); + const onFailure = jest.fn(); + await navigateToStartStepIfScanFileCannotBeRead(RECEIPT, RECEIPT_URI, onSuccess, CONST.IOU.REQUEST_TYPE.MANUAL, CONST.IOU.TYPE.SUBMIT, '1', '2', 'image/jpeg', onFailure); + + // Then the failure goes through once, after the recheck finds nothing + expect(onFailure).toHaveBeenCalledTimes(1); + expect(onSuccess).not.toHaveBeenCalled(); + }); + + it('rejects a directory at the receipt path even after the recheck', async () => { + // Given a directory where the receipt file should be, which exists but is not a file + mockExisting.clear(); + mockDirectories.add(RECEIPT_PATH); + + // When the confirmation page validates the receipt + const onSuccess = jest.fn(); + const onFailure = jest.fn(); + await validateReceiptFile(RECEIPT, RECEIPT_URI, 'image/jpeg', onSuccess, onFailure); + + // Then the recheck finding something at the path does not make it a valid receipt + expect(onSuccess).not.toHaveBeenCalled(); + expect(onFailure).toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/libs/receiptStorageTest.ts b/tests/unit/libs/receiptStorageTest.ts index 420f24931790..57ef6d6f4903 100644 --- a/tests/unit/libs/receiptStorageTest.ts +++ b/tests/unit/libs/receiptStorageTest.ts @@ -564,6 +564,162 @@ describe('ReceiptStorage', () => { }); }); + describe('recheckAfterSwap', () => { + const RECEIPT_URI = `file://${FOLDER}/CAM-1.jpg`; + const RECEIPT_PATH = `${FOLDER}/CAM-1.jpg`; + + const flushPendingWork = () => + new Promise((resolve) => { + setImmediate(resolve); + }); + + function holdSwapWithReceiptMissing() { + const existing = new Set([RECEIPT_PATH]); + let release: () => void = () => {}; + const receiptMissing = new Promise((resolve) => { + mockMv.mockImplementation((from: string, to: string) => { + existing.delete(from); + if (from === `${RECEIPT_PATH}.staged` && to === RECEIPT_PATH) { + resolve(); + return new Promise((settle) => { + release = () => { + existing.add(to); + settle(); + }; + }); + } + existing.add(to); + return Promise.resolve(); + }); + }); + mockExists.mockImplementation((path: string) => Promise.resolve(existing.has(path))); + return {existing, receiptMissing, release: () => release()}; + } + + it('confirms a receipt that is where it should be', async () => { + // Given a receipt on disk with nothing swapping it + mockExists.mockResolvedValue(true); + + // When a reader that failed rechecks it + const isPresent = await ReceiptStorage.recheckAfterSwap(RECEIPT_URI); + + // Then the receipt counts as present and nothing is moved + expect(isPresent).toBe(true); + expect(mockMv).not.toHaveBeenCalled(); + }); + + it('waits for a swap caught between its two renames, then confirms the receipt', async () => { + // Given a swap that has moved the receipt aside and not yet moved the upgraded file in + const {existing, receiptMissing, release} = holdSwapWithReceiptMissing(); + const swap = ReceiptStorage.overwrite('CAM-1.jpg', '/var/mobile/tmp/still.jpg'); + await receiptMissing; + + // When a reader that failed in that gap rechecks the receipt + let hasAnswered = false; + const recheck = ReceiptStorage.recheckAfterSwap(RECEIPT_URI).then((isPresent) => { + hasAnswered = true; + return isPresent; + }); + await flushPendingWork(); + + // Then it answers only once the swap is done, and the receipt is there + expect(hasAnswered).toBe(false); + release(); + await swap; + await expect(recheck).resolves.toBe(true); + expect(existing.has(RECEIPT_PATH)).toBe(true); + }); + + it('waits for a swap that is still staging, so the retried read cannot land in its gap', async () => { + // Given a swap still staging its new file, with the receipt still at its own path + const existing = new Set([RECEIPT_PATH]); + let finishStaging: () => void = () => {}; + const stagingStarted = new Promise((resolve) => { + mockMv.mockImplementation((from: string, to: string) => { + const move = () => { + existing.delete(from); + existing.add(to); + }; + if (to === `${RECEIPT_PATH}.staged`) { + resolve(); + return new Promise((settle) => { + finishStaging = () => { + move(); + settle(); + }; + }); + } + move(); + return Promise.resolve(); + }); + }); + mockExists.mockImplementation((path: string) => Promise.resolve(existing.has(path))); + const swap = ReceiptStorage.overwrite('CAM-1.jpg', '/var/mobile/tmp/still.jpg'); + await stagingStarted; + + // When a reader whose read failed for another reason rechecks the receipt + let hasAnswered = false; + const recheck = ReceiptStorage.recheckAfterSwap(RECEIPT_URI).then((isPresent) => { + hasAnswered = true; + return isPresent; + }); + await flushPendingWork(); + + // Then it does not answer while the swap can still rename the receipt, and answers once the swap is done + expect(hasAnswered).toBe(false); + finishStaging(); + await swap; + await expect(recheck).resolves.toBe(true); + }); + + it('leaves a registered upgrade alone, since a reader rechecking the receipt is not an upload claiming it', async () => { + // Given an upgrade that is still capturing + startUpgrade('CAM-1.jpg'); + + // When a reader rechecks the receipt + await ReceiptStorage.recheckAfterSwap(RECEIPT_URI); + + // Then the upgrade is not claimed, so it can still swap the better photo in. `locate` would call it off. + expect(isClaimedForRead('CAM-1.jpg')).toBe(false); + + finishUpgrade('CAM-1.jpg'); + }); + + it('puts back a receipt stranded under the backup name by an interrupted swap', async () => { + // Given a swap the app died in, with the receipt left under its backup name + mockExists.mockImplementation((path: string) => Promise.resolve(path === `${RECEIPT_PATH}.backup`)); + + // When a reader rechecks the receipt + await expect(ReceiptStorage.recheckAfterSwap(RECEIPT_URI)).resolves.toBe(true); + + // Then the backup is moved back, so the retried read finds the receipt + expect(mockMv).toHaveBeenCalledWith(`${RECEIPT_PATH}.backup`, RECEIPT_PATH); + }); + + it('reports a receipt that is gone and has no backup, so the reader can fail for real', async () => { + // Given a receipt that is missing with no swap and no backup + mockExists.mockResolvedValue(false); + + // When a reader rechecks it + const isPresent = await ReceiptStorage.recheckAfterSwap(RECEIPT_URI); + + // Then it is reported gone + expect(isPresent).toBe(false); + }); + + it('reports a remote source as gone without touching the filesystem', async () => { + // Given a remote receipt whose read failed + const remoteSource = 'https://www.expensify.com/receipts/w_9.jpg'; + + // When the reader rechecks it + const isPresent = await ReceiptStorage.recheckAfterSwap(remoteSource); + + // Then there is no local swap to wait for, so the failure stands + expect(isPresent).toBe(false); + expect(mockExists).not.toHaveBeenCalled(); + }); + }); + describe('discard', () => { it('deletes a temporary file', async () => { await ReceiptStorage.discard('file:///var/mobile/tmp/still.jpg'); From 5370d051625f317f98161ec5575b1c23c25b2d59 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 25 Sep 2026 16:24:59 +0200 Subject: [PATCH 17/23] Skip the sweep without a receipts folder and use swap-specific suffixes --- src/libs/ReceiptStorage/index.native.ts | 11 +- tests/unit/libs/receiptReadDuringSwapTest.ts | 6 +- tests/unit/libs/receiptStorageTest.ts | 125 +++++++++++++------ 3 files changed, 97 insertions(+), 45 deletions(-) diff --git a/src/libs/ReceiptStorage/index.native.ts b/src/libs/ReceiptStorage/index.native.ts index 25642bd02736..5071f522d21a 100644 --- a/src/libs/ReceiptStorage/index.native.ts +++ b/src/libs/ReceiptStorage/index.native.ts @@ -18,8 +18,10 @@ import {claimForRead} from './receiptUpgrades'; const SWEEP_IDLE_TIMEOUT_MS = 10000; -const STAGED_SUFFIX = '.staged'; -const BACKUP_SUFFIX = '.backup'; +// Specific enough that no adopted file carries them: `adopt` keeps the picked file's extension, so a plain +// `.backup` attachment would otherwise look like a swap leftover to the sweep. +const STAGED_SUFFIX = '.receipt-swap-staged'; +const BACKUP_SUFFIX = '.receipt-swap-backup'; const swapsInFlight = new Map>(); @@ -161,6 +163,11 @@ async function swapIntoPlace(dir: string, durableName: string, target: string, u } async function sweepInterruptedSwaps(dir: string) { + // Nothing was ever scanned or adopted, so there is nothing to sweep, and `readDir` would reject. + if (!(await RNFS.exists(dir))) { + return; + } + const names = (await RNFS.readDir(dir)).map((entry) => entry.name); await Promise.all( diff --git a/tests/unit/libs/receiptReadDuringSwapTest.ts b/tests/unit/libs/receiptReadDuringSwapTest.ts index 465bdafa3ab8..bdf4ab65623a 100644 --- a/tests/unit/libs/receiptReadDuringSwapTest.ts +++ b/tests/unit/libs/receiptReadDuringSwapTest.ts @@ -70,7 +70,7 @@ async function holdSwapWithReceiptMissing() { const receiptMissing = new Promise((resolve) => { mockMoveFile.mockImplementation((from: string, to: string) => { mockExisting.delete(from); - if (from === `${RECEIPT_PATH}.staged` && to === RECEIPT_PATH) { + if (from === `${RECEIPT_PATH}.receipt-swap-staged` && to === RECEIPT_PATH) { resolve(); return new Promise((settle) => { release = () => { @@ -187,10 +187,10 @@ describe('reading a receipt while an upgrade swaps it', () => { }; }); }; - if (to === `${RECEIPT_PATH}.staged`) { + if (to === `${RECEIPT_PATH}.receipt-swap-staged`) { return hold('staging', stagingStarted); } - if (from === `${RECEIPT_PATH}.staged` && to === RECEIPT_PATH) { + if (from === `${RECEIPT_PATH}.receipt-swap-staged` && to === RECEIPT_PATH) { return hold('secondRename', gapOpened); } move(); diff --git a/tests/unit/libs/receiptStorageTest.ts b/tests/unit/libs/receiptStorageTest.ts index 57ef6d6f4903..76f6cb8f28cd 100644 --- a/tests/unit/libs/receiptStorageTest.ts +++ b/tests/unit/libs/receiptStorageTest.ts @@ -108,25 +108,25 @@ describe('ReceiptStorage', () => { expect(name).toBe(RECEIPT); expect(mockMv.mock.calls).toEqual([ - [STILL, `${FOLDER}/${RECEIPT}.staged`], - [`${FOLDER}/${RECEIPT}`, `${FOLDER}/${RECEIPT}.backup`], - [`${FOLDER}/${RECEIPT}.staged`, `${FOLDER}/${RECEIPT}`], + [STILL, `${FOLDER}/${RECEIPT}.receipt-swap-staged`], + [`${FOLDER}/${RECEIPT}`, `${FOLDER}/${RECEIPT}.receipt-swap-backup`], + [`${FOLDER}/${RECEIPT}.receipt-swap-staged`, `${FOLDER}/${RECEIPT}`], ]); }); it('drops the file it moved aside once the swap went through, so a capture leaves one receipt behind', async () => { - mockExists.mockImplementation((path: string) => Promise.resolve(path === `${FOLDER}/${RECEIPT}` || path === `${FOLDER}/${RECEIPT}.backup`)); + mockExists.mockImplementation((path: string) => Promise.resolve(path === `${FOLDER}/${RECEIPT}` || path === `${FOLDER}/${RECEIPT}.receipt-swap-backup`)); await ReceiptStorage.overwrite(RECEIPT, STILL); - expect(mockUnlink).toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}.backup`); + expect(mockUnlink).toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}.receipt-swap-backup`); }); it('puts the original receipt back when the swap fails, rather than leaving the receipt missing', async () => { const existing = new Set([`${FOLDER}/${RECEIPT}`]); mockExists.mockImplementation((path: string) => Promise.resolve(existing.has(path))); mockMv.mockImplementation((from: string, to: string) => { - if (from === `${FOLDER}/${RECEIPT}.staged` && to === `${FOLDER}/${RECEIPT}`) { + if (from === `${FOLDER}/${RECEIPT}.receipt-swap-staged` && to === `${FOLDER}/${RECEIPT}`) { return Promise.reject(new Error('no space left on device')); } existing.delete(from); @@ -136,8 +136,8 @@ describe('ReceiptStorage', () => { await expect(ReceiptStorage.overwrite(RECEIPT, STILL)).rejects.toThrow('no space left on device'); - expect(mockMv).toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}.backup`, `${FOLDER}/${RECEIPT}`); - expect(mockUnlink).toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}.staged`); + expect(mockMv).toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}.receipt-swap-backup`, `${FOLDER}/${RECEIPT}`); + expect(mockUnlink).toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}.receipt-swap-staged`); }); it('reports where the receipt was left when the swap fails and the restore fails too', async () => { @@ -152,11 +152,11 @@ describe('ReceiptStorage', () => { return Promise.resolve(); }); - await expect(ReceiptStorage.overwrite(RECEIPT, STILL)).rejects.toThrow(`it is left at ${FOLDER}/${RECEIPT}.backup`); + await expect(ReceiptStorage.overwrite(RECEIPT, STILL)).rejects.toThrow(`it is left at ${FOLDER}/${RECEIPT}.receipt-swap-backup`); // Freeing the staged copy gives a full disk room for the restore, so it happens even when the // restore then fails. - expect(mockUnlink).toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}.staged`); + expect(mockUnlink).toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}.receipt-swap-staged`); }); it('still reports success when only the housekeeping delete fails, because the receipt is already swapped', async () => { @@ -167,7 +167,7 @@ describe('ReceiptStorage', () => { }); it('restores a receipt stranded under the backup name by a swap the app died in the middle of', async () => { - const existing = new Set([`${FOLDER}/${RECEIPT}.backup`]); + const existing = new Set([`${FOLDER}/${RECEIPT}.receipt-swap-backup`]); mockExists.mockImplementation((path: string) => Promise.resolve(existing.has(path))); mockMv.mockImplementation((from: string, to: string) => { existing.delete(from); @@ -178,7 +178,7 @@ describe('ReceiptStorage', () => { await expect(ReceiptStorage.overwrite(RECEIPT, STILL)).resolves.toBe(RECEIPT); // The stranded copy is the only one left, so it goes back before any cleanup can delete it. - expect(mockMv).toHaveBeenNthCalledWith(1, `${FOLDER}/${RECEIPT}.backup`, `${FOLDER}/${RECEIPT}`); + expect(mockMv).toHaveBeenNthCalledWith(1, `${FOLDER}/${RECEIPT}.receipt-swap-backup`, `${FOLDER}/${RECEIPT}`); expect(mockMv.mock.invocationCallOrder.at(0) ?? 0).toBeLessThan(mockUnlink.mock.invocationCallOrder.at(0) ?? Number.MAX_SAFE_INTEGER); }); @@ -213,8 +213,8 @@ describe('ReceiptStorage', () => { // Staging only put a file beside the receipt, so backing out means dropping that copy and // leaving the receipt's own name untouched. - expect(mockUnlink).toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}.staged`); - expect(mockMv).not.toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}`, `${FOLDER}/${RECEIPT}.backup`); + expect(mockUnlink).toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}.receipt-swap-staged`); + expect(mockMv).not.toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}`, `${FOLDER}/${RECEIPT}.receipt-swap-backup`); // The receipt itself is exactly where it was. expect(existing.has(`${FOLDER}/${RECEIPT}`)).toBe(true); }); @@ -223,7 +223,7 @@ describe('ReceiptStorage', () => { mockExists.mockImplementation(onlyTheReceiptExists); await expect(ReceiptStorage.overwrite(RECEIPT, STILL, () => false)).resolves.toBe(RECEIPT); - expect(mockMv).toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}.staged`, `${FOLDER}/${RECEIPT}`); + expect(mockMv).toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}.receipt-swap-staged`, `${FOLDER}/${RECEIPT}`); }); it('refuses a second swap over a receipt already being swapped, rather than clearing its staged copy', async () => { @@ -233,7 +233,7 @@ describe('ReceiptStorage', () => { mockMv.mockImplementation((from: string, to: string) => { existing.delete(from); existing.add(to); - if (to === `${FOLDER}/${RECEIPT}.staged`) { + if (to === `${FOLDER}/${RECEIPT}.receipt-swap-staged`) { resolve(); return new Promise((settle) => { finishFirstSwap = settle; @@ -283,8 +283,8 @@ describe('ReceiptStorage', () => { it('never waits on the swap that asked for it, since `overwrite` registers itself before awaiting the sweep', async () => { const storage = loadFreshStorage(); - const existing = new Set([`${FOLDER}/${RECEIPT}.backup`]); - mockReadDir.mockResolvedValue([{name: `${RECEIPT}.backup`}]); + const existing = new Set([FOLDER, `${FOLDER}/${RECEIPT}.receipt-swap-backup`]); + mockReadDir.mockResolvedValue([{name: `${RECEIPT}.receipt-swap-backup`}]); mockExists.mockImplementation((path: string) => Promise.resolve(existing.has(path))); mockMv.mockImplementation((from: string, to: string) => { existing.delete(from); @@ -303,6 +303,8 @@ describe('ReceiptStorage', () => { await expect(Promise.race([swap.then(() => 'settled'), stalled])).resolves.toBe('settled'); await expect(swap).resolves.toBe(RECEIPT); + // The sweep actually listed the folder, so the restore above went through it and not overwrite's own path. + expect(mockReadDir).toHaveBeenCalledWith(FOLDER); if (stall) { clearTimeout(stall); } @@ -310,8 +312,8 @@ describe('ReceiptStorage', () => { it('puts back a stranded backup and deletes the copies nothing owns before it renames anything', async () => { const storage = loadFreshStorage(); - const existing = new Set([`${FOLDER}/${RECEIPT}.backup`, `${FOLDER}/other.jpg`, `${FOLDER}/other.jpg.staged`]); - mockReadDir.mockResolvedValue([{name: `${RECEIPT}.backup`}, {name: 'other.jpg'}, {name: 'other.jpg.staged'}]); + const existing = new Set([FOLDER, `${FOLDER}/${RECEIPT}.receipt-swap-backup`, `${FOLDER}/other.jpg`, `${FOLDER}/other.jpg.receipt-swap-staged`]); + mockReadDir.mockResolvedValue([{name: `${RECEIPT}.receipt-swap-backup`}, {name: 'other.jpg'}, {name: 'other.jpg.receipt-swap-staged'}]); mockExists.mockImplementation((path: string) => Promise.resolve(existing.has(path))); mockMv.mockImplementation((from: string, to: string) => { existing.delete(from); @@ -323,9 +325,9 @@ describe('ReceiptStorage', () => { // has to find it there, which means the sweep must finish before `verify` runs. await expect(storage.overwrite(RECEIPT, '/var/mobile/tmp/still.jpg')).resolves.toBe(RECEIPT); - expect(mockMv).toHaveBeenNthCalledWith(1, `${FOLDER}/${RECEIPT}.backup`, `${FOLDER}/${RECEIPT}`); + expect(mockMv).toHaveBeenNthCalledWith(1, `${FOLDER}/${RECEIPT}.receipt-swap-backup`, `${FOLDER}/${RECEIPT}`); // A staged copy beside a receipt that is already in place has no owner. - expect(mockUnlink).toHaveBeenCalledWith(`${FOLDER}/other.jpg.staged`); + expect(mockUnlink).toHaveBeenCalledWith(`${FOLDER}/other.jpg.receipt-swap-staged`); expect(mockUnlink).not.toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}`); // A second write does not pay for the directory listing again. @@ -352,10 +354,53 @@ describe('ReceiptStorage', () => { expect(hasListed).toBe(true); }); + it('skips the sweep when the receipts folder does not exist yet', async () => { + // Given a fresh install that has never scanned or adopted a receipt, so the folder was never created + const storage = loadFreshStorage(); + mockExists.mockResolvedValue(false); + const originalRequestIdleCallback = global.requestIdleCallback; + global.requestIdleCallback = ((callback: IdleRequestCallback) => { + callback({didTimeout: false, timeRemaining: () => 50}); + return 0; + }) as typeof requestIdleCallback; + + // When the startup sweep runs + try { + await storage.sweepLeftovers(); + } finally { + global.requestIdleCallback = originalRequestIdleCallback; + } + + // Then it never lists the missing folder, which would reject and log a warning on every launch + expect(mockReadDir).not.toHaveBeenCalled(); + }); + + it('leaves an adopted attachment alone even when its own extension is .backup or .staged', async () => { + // Given attachments whose picked names kept a `.backup` or `.staged` extension through `adopt` + const storage = loadFreshStorage(); + mockReadDir.mockResolvedValue([{name: 'db_123.backup'}, {name: 'notes_456.staged'}]); + const originalRequestIdleCallback = global.requestIdleCallback; + global.requestIdleCallback = ((callback: IdleRequestCallback) => { + callback({didTimeout: false, timeRemaining: () => 50}); + return 0; + }) as typeof requestIdleCallback; + + // When the startup sweep runs + try { + await storage.sweepLeftovers(); + } finally { + global.requestIdleCallback = originalRequestIdleCallback; + } + + // Then neither is renamed or deleted, so a queued upload still finds its file + expect(mockMv).not.toHaveBeenCalled(); + expect(mockUnlink).not.toHaveBeenCalled(); + }); + it('restores a stranded receipt during the deferred startup sweep, without needing an upload', async () => { const storage = loadFreshStorage(); - const existing = new Set([`${FOLDER}/${RECEIPT}.backup`, `${FOLDER}/${RECEIPT}.staged`]); - mockReadDir.mockResolvedValue([{name: `${RECEIPT}.backup`}, {name: `${RECEIPT}.staged`}]); + const existing = new Set([FOLDER, `${FOLDER}/${RECEIPT}.receipt-swap-backup`, `${FOLDER}/${RECEIPT}.receipt-swap-staged`]); + mockReadDir.mockResolvedValue([{name: `${RECEIPT}.receipt-swap-backup`}, {name: `${RECEIPT}.receipt-swap-staged`}]); mockExists.mockImplementation((path: string) => Promise.resolve(existing.has(path))); mockMv.mockImplementation((from: string, to: string) => { existing.delete(from); @@ -379,11 +424,11 @@ describe('ReceiptStorage', () => { global.requestIdleCallback = originalRequestIdleCallback; } - expect(mockMv).toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}.backup`, `${FOLDER}/${RECEIPT}`); - expect(mockUnlink).toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}.staged`); + expect(mockMv).toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}.receipt-swap-backup`, `${FOLDER}/${RECEIPT}`); + expect(mockUnlink).toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}.receipt-swap-staged`); expect(existing.has(`${FOLDER}/${RECEIPT}`)).toBe(true); - expect(existing.has(`${FOLDER}/${RECEIPT}.backup`)).toBe(false); - expect(existing.has(`${FOLDER}/${RECEIPT}.staged`)).toBe(false); + expect(existing.has(`${FOLDER}/${RECEIPT}.receipt-swap-backup`)).toBe(false); + expect(existing.has(`${FOLDER}/${RECEIPT}.receipt-swap-staged`)).toBe(false); }); }); @@ -404,10 +449,10 @@ describe('ReceiptStorage', () => { it('puts back a receipt stranded under the backup name by an interrupted swap', async () => { mockCheckFileExists.mockResolvedValue(false); - mockExists.mockImplementation((path: string) => Promise.resolve(path === `${RECEIPT_PATH}.backup`)); + mockExists.mockImplementation((path: string) => Promise.resolve(path === `${RECEIPT_PATH}.receipt-swap-backup`)); await expect(ReceiptStorage.locate(RECEIPT_URI)).resolves.toBe(RECEIPT_URI); - expect(mockMv).toHaveBeenCalledWith(`${RECEIPT_PATH}.backup`, RECEIPT_PATH); + expect(mockMv).toHaveBeenCalledWith(`${RECEIPT_PATH}.receipt-swap-backup`, RECEIPT_PATH); }); it('reads straight through while a swap is still staging, since that half can be called off', async () => { @@ -415,7 +460,7 @@ describe('ReceiptStorage', () => { let finishStaging: () => void = () => {}; const stagingStarted = new Promise((resolve) => { mockMv.mockImplementation((from: string, to: string) => { - if (to === `${RECEIPT_PATH}.staged`) { + if (to === `${RECEIPT_PATH}.receipt-swap-staged`) { resolve(); return new Promise((settle) => { finishStaging = settle; @@ -451,7 +496,7 @@ describe('ReceiptStorage', () => { let finishRename: () => void = () => {}; const renameStarted = new Promise((resolve) => { mockMv.mockImplementation((from: string, to: string) => { - if (to === `${RECEIPT_PATH}.backup`) { + if (to === `${RECEIPT_PATH}.receipt-swap-backup`) { resolve(); return new Promise((settle) => { finishRename = settle; @@ -508,12 +553,12 @@ describe('ReceiptStorage', () => { mockMv.mockImplementation((from: string, to: string) => { existing.delete(from); existing.add(to); - if (to === `${RECEIPT_PATH}.backup`) { + if (to === `${RECEIPT_PATH}.receipt-swap-backup`) { // The receipt is now missing from its own path, which is the gap a reader can land in. resolve(); return new Promise((settle) => { finishSwap = () => { - existing.delete(`${RECEIPT_PATH}.staged`); + existing.delete(`${RECEIPT_PATH}.receipt-swap-staged`); existing.add(RECEIPT_PATH); settle(); }; @@ -534,13 +579,13 @@ describe('ReceiptStorage', () => { // The swapped-in file is what the reader gets, and the backup was never moved back over it. await expect(located).resolves.toBe(RECEIPT_URI); - expect(mockMv).not.toHaveBeenCalledWith(`${RECEIPT_PATH}.backup`, RECEIPT_PATH); + expect(mockMv).not.toHaveBeenCalledWith(`${RECEIPT_PATH}.receipt-swap-backup`, RECEIPT_PATH); }); it('treats a receipt that reappeared while the restore was failing as readable', async () => { mockCheckFileExists.mockResolvedValue(false); let hasMoved = false; - mockExists.mockImplementation((path: string) => Promise.resolve(path === `${RECEIPT_PATH}.backup` || (path === RECEIPT_PATH && hasMoved))); + mockExists.mockImplementation((path: string) => Promise.resolve(path === `${RECEIPT_PATH}.receipt-swap-backup` || (path === RECEIPT_PATH && hasMoved))); mockMv.mockImplementation(() => { hasMoved = true; return Promise.reject(new Error('file exists')); @@ -579,7 +624,7 @@ describe('ReceiptStorage', () => { const receiptMissing = new Promise((resolve) => { mockMv.mockImplementation((from: string, to: string) => { existing.delete(from); - if (from === `${RECEIPT_PATH}.staged` && to === RECEIPT_PATH) { + if (from === `${RECEIPT_PATH}.receipt-swap-staged` && to === RECEIPT_PATH) { resolve(); return new Promise((settle) => { release = () => { @@ -640,7 +685,7 @@ describe('ReceiptStorage', () => { existing.delete(from); existing.add(to); }; - if (to === `${RECEIPT_PATH}.staged`) { + if (to === `${RECEIPT_PATH}.receipt-swap-staged`) { resolve(); return new Promise((settle) => { finishStaging = () => { @@ -687,13 +732,13 @@ describe('ReceiptStorage', () => { it('puts back a receipt stranded under the backup name by an interrupted swap', async () => { // Given a swap the app died in, with the receipt left under its backup name - mockExists.mockImplementation((path: string) => Promise.resolve(path === `${RECEIPT_PATH}.backup`)); + mockExists.mockImplementation((path: string) => Promise.resolve(path === `${RECEIPT_PATH}.receipt-swap-backup`)); // When a reader rechecks the receipt await expect(ReceiptStorage.recheckAfterSwap(RECEIPT_URI)).resolves.toBe(true); // Then the backup is moved back, so the retried read finds the receipt - expect(mockMv).toHaveBeenCalledWith(`${RECEIPT_PATH}.backup`, RECEIPT_PATH); + expect(mockMv).toHaveBeenCalledWith(`${RECEIPT_PATH}.receipt-swap-backup`, RECEIPT_PATH); }); it('reports a receipt that is gone and has no backup, so the reader can fail for real', async () => { From 7af3dd54473341d9dc2bcc07c2474de7e0e20ce7 Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 29 Sep 2026 16:55:50 +0200 Subject: [PATCH 18/23] Address review comments --- src/hooks/useReceiptUpgradeCount.ts | 9 +- src/libs/ReceiptStorage/index.native.ts | 60 ++++- .../components/Camera/index.native.tsx | 2 + .../components/CameraViewport.tsx | 12 +- .../unit/hooks/useReceiptUpgradeCount.test.ts | 11 + tests/unit/libs/receiptReadDuringSwapTest.ts | 1 + tests/unit/libs/receiptStorageTest.ts | 237 ++++++++++++------ 7 files changed, 247 insertions(+), 85 deletions(-) diff --git a/src/hooks/useReceiptUpgradeCount.ts b/src/hooks/useReceiptUpgradeCount.ts index 1cbe83539b7b..5c4bf0a9363b 100644 --- a/src/hooks/useReceiptUpgradeCount.ts +++ b/src/hooks/useReceiptUpgradeCount.ts @@ -2,11 +2,14 @@ import {getUpgradeCount, subscribe} from '@libs/ReceiptStorage/receiptUpgrades'; import {useSyncExternalStore} from 'react'; -function toUpgradeKey(sourceUri: string | undefined): string { - return sourceUri?.split('/').pop()?.split('#').at(0)?.split('?').at(0) ?? ''; +function toUpgradeKey(sourceUri: unknown): string { + if (typeof sourceUri !== 'string') { + return ''; + } + return sourceUri.split('/').pop()?.split('#').at(0)?.split('?').at(0) ?? ''; } -function useReceiptUpgradeCount(sourceUri: string | undefined): number { +function useReceiptUpgradeCount(sourceUri: string | number | undefined): number { return useSyncExternalStore(subscribe, () => getUpgradeCount(toUpgradeKey(sourceUri))); } diff --git a/src/libs/ReceiptStorage/index.native.ts b/src/libs/ReceiptStorage/index.native.ts index 5071f522d21a..e0be83baf917 100644 --- a/src/libs/ReceiptStorage/index.native.ts +++ b/src/libs/ReceiptStorage/index.native.ts @@ -23,6 +23,10 @@ const SWEEP_IDLE_TIMEOUT_MS = 10000; const STAGED_SUFFIX = '.receipt-swap-staged'; const BACKUP_SUFFIX = '.receipt-swap-backup'; +// RNFS `readDir` on the receipts folder costs ~1ms per file on Android and blocks every other native call +// while it runs, so the sweep lists this folder of pending swaps instead. +const PENDING_SWAPS_FOLDER = '.pending-receipt-swaps'; + const swapsInFlight = new Map>(); const committedSwaps = new Map>(); @@ -76,11 +80,13 @@ const discard: ReceiptStorage['discard'] = async (uriOrPath) => { await RNFS.unlink(path); }; -async function discardLeftover(path: string) { +async function discardLeftover(path: string): Promise { try { await discard(path); + return true; } catch (error) { Log.warn('[ReceiptStorage] could not delete a leftover receipt copy', {path, error: error instanceof Error ? error.message : String(error)}); + return false; } } @@ -110,7 +116,7 @@ async function restoreInterruptedSwap(target: string, {shouldWaitForRunningSwap return true; } -async function swapIntoPlace(dir: string, durableName: string, target: string, uriOrPath: string, shouldAbort?: () => boolean): Promise { +async function swapFiles(dir: string, durableName: string, target: string, uriOrPath: string, shouldAbort?: () => boolean): Promise { // Neither platform can move a file onto one that already exists. NSFileManager refuses and Android's // `renameTo` is unreliable, so stage the new bytes beside the receipt, then swap them in with two // renames inside the directory. The original keeps a second name until the swap succeeds. @@ -162,19 +168,57 @@ async function swapIntoPlace(dir: string, durableName: string, target: string, u return verify(dir, durableName); } +async function markSwapStarted(dir: string, durableName: string) { + const pendingSwapsDir = `${dir}/${PENDING_SWAPS_FOLDER}`; + await RNFS.mkdir(pendingSwapsDir); + await RNFS.writeFile(`${pendingSwapsDir}/${durableName}`, '', 'utf8'); +} + +async function markSwapFinished(dir: string, durableName: string, target: string) { + try { + const [isInPlace, hasStaged, hasBackup] = await Promise.all([RNFS.exists(target), RNFS.exists(`${target}${STAGED_SUFFIX}`), RNFS.exists(`${target}${BACKUP_SUFFIX}`)]); + if (!isInPlace || hasStaged || hasBackup) { + return; + } + await discard(`${dir}/${PENDING_SWAPS_FOLDER}/${durableName}`); + } catch (error) { + Log.warn('[ReceiptStorage] could not clear a finished swap from the pending swaps', {error: error instanceof Error ? error.message : String(error)}); + } +} + +async function swapIntoPlace(dir: string, durableName: string, target: string, uriOrPath: string, shouldAbort?: () => boolean): Promise { + await markSwapStarted(dir, durableName); + try { + return await swapFiles(dir, durableName, target, uriOrPath, shouldAbort); + } finally { + await markSwapFinished(dir, durableName, target); + } +} + async function sweepInterruptedSwaps(dir: string) { - // Nothing was ever scanned or adopted, so there is nothing to sweep, and `readDir` would reject. - if (!(await RNFS.exists(dir))) { + const pendingSwapsDir = `${dir}/${PENDING_SWAPS_FOLDER}`; + if (!(await RNFS.exists(pendingSwapsDir))) { return; } - const names = (await RNFS.readDir(dir)).map((entry) => entry.name); + const names = (await RNFS.readDir(pendingSwapsDir)).map((entry) => entry.name); await Promise.all( - names.filter((name) => name.endsWith(BACKUP_SUFFIX)).map((name) => restoreInterruptedSwap(`${dir}/${name.slice(0, -BACKUP_SUFFIX.length)}`, {shouldWaitForRunningSwap: false})), + names.map(async (name) => { + const target = `${dir}/${name}`; + const isRestored = await restoreInterruptedSwap(target, {shouldWaitForRunningSwap: false}); + const isStagedGone = await discardLeftover(`${target}${STAGED_SUFFIX}`); + + if (!isRestored) { + return; + } + const isBackupGone = await discardLeftover(`${target}${BACKUP_SUFFIX}`); + + if (isStagedGone && isBackupGone) { + await discardLeftover(`${pendingSwapsDir}/${name}`); + } + }), ); - - await Promise.all(names.filter((name) => name.endsWith(STAGED_SUFFIX) || name.endsWith(BACKUP_SUFFIX)).map((name) => discardLeftover(`${dir}/${name}`))); } let leftoversSwept: Promise | undefined; diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx index f1f5b5d026be..7a24a795e754 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx @@ -270,6 +270,8 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou flash={flash} hasFlash={hasFlash} setFlash={setFlash} + photoQualityBalance="balanced" + outputOrientation="preview" /> )} diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx index 2853b2d847cf..e0f0fdf907f3 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx @@ -16,7 +16,7 @@ import type {LayoutChangeEvent, ViewStyle} from 'react-native'; import type {GestureType} from 'react-native-gesture-handler'; import type {PermissionStatus} from 'react-native-permissions'; import type {AnimatedStyle} from 'react-native-reanimated'; -import type {Camera, CameraDevice, CameraDeviceFormat} from 'react-native-vision-camera'; +import type {Camera, CameraDevice, CameraDeviceFormat, CameraProps} from 'react-native-vision-camera'; import React from 'react'; import {StyleSheet, View} from 'react-native'; @@ -88,6 +88,12 @@ type CameraViewportProps = { setFlash: (updater: (prev: boolean) => boolean) => void; + /** Trade-off between capture speed and still quality, defaults to "quality" */ + photoQualityBalance?: CameraProps['photoQualityBalance']; + + /** Whether photos follow the device or the preview orientation, defaults to "device" */ + outputOrientation?: CameraProps['outputOrientation']; + /** Extra content rendered below the viewfinder, inside the camera view */ children?: ReactNode; }; @@ -114,6 +120,8 @@ function CameraViewport({ flash, hasFlash, setFlash, + photoQualityBalance = 'quality', + outputOrientation = 'device', children, }: CameraViewportProps) { const theme = useTheme(); @@ -134,6 +142,8 @@ function CameraViewport({ style={styles.flex1} zoom={device.neutralZoom} photo + photoQualityBalance={photoQualityBalance} + outputOrientation={outputOrientation} cameraTabIndex={1} forceInactive={isAttachmentPickerActive || (didCapturePhoto && !hasPendingPhotoCapture)} shouldStayActiveWhenBlurred={hasPendingPhotoCapture} diff --git a/tests/unit/hooks/useReceiptUpgradeCount.test.ts b/tests/unit/hooks/useReceiptUpgradeCount.test.ts index 1302368255d1..8067e8867cb3 100644 --- a/tests/unit/hooks/useReceiptUpgradeCount.test.ts +++ b/tests/unit/hooks/useReceiptUpgradeCount.test.ts @@ -21,6 +21,17 @@ describe('useReceiptUpgradeCount', () => { expect(result.current).toBe(0); }); + it('reports nothing for a bundled asset, which is a numeric module ID on native', () => { + // Given a source that is a bundled image such as ReceiptGeneric, which native resolves to a numeric asset ID + const bundledAsset = 42; + + // When the hook reads its upgrade count + const {result} = renderHook(() => useReceiptUpgradeCount(bundledAsset)); + + // Then it reports no upgrade instead of throwing on `split` + expect(result.current).toBe(0); + }); + it('goes up when the receipt behind the path is replaced, since the path itself never changes', () => { const {result} = renderHook(() => useReceiptUpgradeCount(RECEIPT_URI)); diff --git a/tests/unit/libs/receiptReadDuringSwapTest.ts b/tests/unit/libs/receiptReadDuringSwapTest.ts index bdf4ab65623a..4d3bd2db6207 100644 --- a/tests/unit/libs/receiptReadDuringSwapTest.ts +++ b/tests/unit/libs/receiptReadDuringSwapTest.ts @@ -26,6 +26,7 @@ jest.mock('react-native-fs', () => ({ return Promise.resolve(); }, readDir: () => Promise.resolve([]), + writeFile: () => Promise.resolve(), })); jest.mock('@libs/fileDownload/checkFileExists', () => ({ diff --git a/tests/unit/libs/receiptStorageTest.ts b/tests/unit/libs/receiptStorageTest.ts index 76f6cb8f28cd..9f6ee13d8864 100644 --- a/tests/unit/libs/receiptStorageTest.ts +++ b/tests/unit/libs/receiptStorageTest.ts @@ -6,6 +6,7 @@ const mockMv = jest.fn, [string, string]>(); const mockMkdir = jest.fn, [string]>(); const mockUnlink = jest.fn, [string]>(); const mockReadDir = jest.fn>, [string]>(); +const mockWriteFile = jest.fn, [string, string, string]>(); jest.mock('react-native-fs', () => ({ exists: (path: string) => mockExists(path), @@ -13,6 +14,7 @@ jest.mock('react-native-fs', () => ({ mkdir: (path: string) => mockMkdir(path), unlink: (path: string) => mockUnlink(path), readDir: (path: string) => mockReadDir(path), + writeFile: (path: string, contents: string, encoding: string) => mockWriteFile(path, contents, encoding), })); jest.mock('@libs/NumberUtils', () => ({rand64: () => '1234'})); @@ -48,6 +50,7 @@ describe('ReceiptStorage', () => { mockUnlink.mockResolvedValue(undefined); mockCheckFileExists.mockResolvedValue(true); mockReadDir.mockResolvedValue([]); + mockWriteFile.mockResolvedValue(undefined); }); describe('adopt', () => { @@ -273,6 +276,7 @@ describe('ReceiptStorage', () => { describe('sweeping leftovers from an interrupted swap', () => { const RECEIPT = 'CAM-1.jpg'; + const PENDING_SWAPS = `${FOLDER}/.pending-receipt-swaps`; /** The sweep runs once per launch, so a test that wants to watch it needs a module that has not swept yet. */ function loadFreshStorage(): ReceiptStorageType { @@ -281,62 +285,92 @@ describe('ReceiptStorage', () => { return storage; } - it('never waits on the swap that asked for it, since `overwrite` registers itself before awaiting the sweep', async () => { - const storage = loadFreshStorage(); - const existing = new Set([FOLDER, `${FOLDER}/${RECEIPT}.receipt-swap-backup`]); - mockReadDir.mockResolvedValue([{name: `${RECEIPT}.receipt-swap-backup`}]); + async function runStartupSweep(storage: ReceiptStorageType) { + const originalRequestIdleCallback = global.requestIdleCallback; + global.requestIdleCallback = ((callback: IdleRequestCallback) => { + callback({didTimeout: false, timeRemaining: () => 50}); + return 0; + }) as typeof requestIdleCallback; + try { + await storage.sweepLeftovers(); + } finally { + global.requestIdleCallback = originalRequestIdleCallback; + } + } + + function setUpFakeDisk(paths: string[]): Set { + const existing = new Set(paths); mockExists.mockImplementation((path: string) => Promise.resolve(existing.has(path))); mockMv.mockImplementation((from: string, to: string) => { existing.delete(from); existing.add(to); return Promise.resolve(); }); + mockUnlink.mockImplementation((path: string) => { + existing.delete(path); + return Promise.resolve(); + }); + mockWriteFile.mockImplementation((path: string) => { + existing.add(path); + return Promise.resolve(); + }); + return existing; + } + + it('never waits on the swap that asked for it, since `overwrite` registers itself before awaiting the sweep', async () => { + // Given a receipt a swap the app died in left only under its backup name + const storage = loadFreshStorage(); + setUpFakeDisk([FOLDER, PENDING_SWAPS, `${PENDING_SWAPS}/${RECEIPT}`, `${FOLDER}/${RECEIPT}.receipt-swap-backup`]); + mockReadDir.mockResolvedValue([{name: RECEIPT}]); - // The receipt is missing under its own name, so the sweep has to restore it while a swap over - // that same path is already registered. If the sweep ever waits for a running swap, it waits on - // the caller waiting on it and neither ever finishes, so this asserts completion, not a result. + // When a new swap over that same receipt is the one that starts the sweep const swap = storage.overwrite(RECEIPT, '/var/mobile/tmp/still.jpg'); let stall: ReturnType | undefined; const stalled = new Promise((resolve) => { stall = setTimeout(() => resolve('deadlocked'), 1000); }); + // Then it settles: if the sweep ever waited for a running swap, it would wait on the caller + // waiting on it and neither would finish, so this asserts completion, not a result await expect(Promise.race([swap.then(() => 'settled'), stalled])).resolves.toBe('settled'); await expect(swap).resolves.toBe(RECEIPT); - // The sweep actually listed the folder, so the restore above went through it and not overwrite's own path. - expect(mockReadDir).toHaveBeenCalledWith(FOLDER); + expect(mockReadDir).toHaveBeenCalledWith(PENDING_SWAPS); if (stall) { clearTimeout(stall); } }); - it('puts back a stranded backup and deletes the copies nothing owns before it renames anything', async () => { + it('puts back a stranded backup and deletes the copies nothing owns before the swap renames anything', async () => { + // Given two pending swaps: one receipt only under its backup name, one in place with a staged copy beside it const storage = loadFreshStorage(); - const existing = new Set([FOLDER, `${FOLDER}/${RECEIPT}.receipt-swap-backup`, `${FOLDER}/other.jpg`, `${FOLDER}/other.jpg.receipt-swap-staged`]); - mockReadDir.mockResolvedValue([{name: `${RECEIPT}.receipt-swap-backup`}, {name: 'other.jpg'}, {name: 'other.jpg.receipt-swap-staged'}]); - mockExists.mockImplementation((path: string) => Promise.resolve(existing.has(path))); - mockMv.mockImplementation((from: string, to: string) => { - existing.delete(from); - existing.add(to); - return Promise.resolve(); - }); + const existing = setUpFakeDisk([ + FOLDER, + PENDING_SWAPS, + `${PENDING_SWAPS}/${RECEIPT}`, + `${PENDING_SWAPS}/other.jpg`, + `${FOLDER}/${RECEIPT}.receipt-swap-backup`, + `${FOLDER}/other.jpg`, + `${FOLDER}/other.jpg.receipt-swap-staged`, + ]); + mockReadDir.mockResolvedValue([{name: RECEIPT}, {name: 'other.jpg'}]); - // The app died mid-swap last launch, so this receipt exists only under the backup name. The swap - // has to find it there, which means the sweep must finish before `verify` runs. + // When a swap has to find the stranded receipt, so the sweep must finish before `verify` runs await expect(storage.overwrite(RECEIPT, '/var/mobile/tmp/still.jpg')).resolves.toBe(RECEIPT); + // Then the backup went back first, the ownerless staged copy is gone, and both entries are cleared expect(mockMv).toHaveBeenNthCalledWith(1, `${FOLDER}/${RECEIPT}.receipt-swap-backup`, `${FOLDER}/${RECEIPT}`); - // A staged copy beside a receipt that is already in place has no owner. expect(mockUnlink).toHaveBeenCalledWith(`${FOLDER}/other.jpg.receipt-swap-staged`); expect(mockUnlink).not.toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}`); + expect(existing.has(`${PENDING_SWAPS}/other.jpg`)).toBe(false); + expect(existing.has(`${PENDING_SWAPS}/${RECEIPT}`)).toBe(false); - // A second write does not pay for the directory listing again. mockReadDir.mockClear(); await storage.overwrite(RECEIPT, '/var/mobile/tmp/another.jpg'); expect(mockReadDir).not.toHaveBeenCalled(); }); it('keeps the directory listing off the request path, so a read never waits on housekeeping', async () => { + // Given a launch whose first receipt work is an upload reading its file const storage = loadFreshStorage(); let hasListed = false; mockReadDir.mockImplementation(() => { @@ -345,90 +379,147 @@ describe('ReceiptStorage', () => { }); mockCheckFileExists.mockResolvedValue(true); - // `locate` runs inside the request the sequential queue is processing, so it starts the sweep - // without waiting for it — the receipt it is reading has its own recovery. + // When `locate` runs inside the request the sequential queue is processing await expect(storage.locate(`file://${FOLDER}/${RECEIPT}`)).resolves.toBe(`file://${FOLDER}/${RECEIPT}`); - expect(mockMv).not.toHaveBeenCalled(); - // Started all the same, so a launch that only ever reads still gets its folder tidied. + // Then it read straight through, but still started the sweep so a launch that only reads gets tidied + expect(mockMv).not.toHaveBeenCalled(); expect(hasListed).toBe(true); }); - it('skips the sweep when the receipts folder does not exist yet', async () => { - // Given a fresh install that has never scanned or adopted a receipt, so the folder was never created + it('skips the sweep when no swap has ever been pending', async () => { + // Given a device that has never upgraded a receipt, so the pending swaps folder was never created const storage = loadFreshStorage(); mockExists.mockResolvedValue(false); - const originalRequestIdleCallback = global.requestIdleCallback; - global.requestIdleCallback = ((callback: IdleRequestCallback) => { - callback({didTimeout: false, timeRemaining: () => 50}); - return 0; - }) as typeof requestIdleCallback; // When the startup sweep runs - try { - await storage.sweepLeftovers(); - } finally { - global.requestIdleCallback = originalRequestIdleCallback; - } + await runStartupSweep(storage); - // Then it never lists the missing folder, which would reject and log a warning on every launch + // Then it lists nothing, since `readDir` on a missing folder rejects and logs on every launch expect(mockReadDir).not.toHaveBeenCalled(); }); - it('leaves an adopted attachment alone even when its own extension is .backup or .staged', async () => { - // Given attachments whose picked names kept a `.backup` or `.staged` extension through `adopt` + it('never lists the receipts folder itself, since that costs a stat per receipt ever taken', async () => { + // Given a device with pending swaps from earlier launches const storage = loadFreshStorage(); - mockReadDir.mockResolvedValue([{name: 'db_123.backup'}, {name: 'notes_456.staged'}]); - const originalRequestIdleCallback = global.requestIdleCallback; - global.requestIdleCallback = ((callback: IdleRequestCallback) => { - callback({didTimeout: false, timeRemaining: () => 50}); - return 0; - }) as typeof requestIdleCallback; // When the startup sweep runs - try { - await storage.sweepLeftovers(); - } finally { - global.requestIdleCallback = originalRequestIdleCallback; - } + await runStartupSweep(storage); - // Then neither is renamed or deleted, so a queued upload still finds its file - expect(mockMv).not.toHaveBeenCalled(); - expect(mockUnlink).not.toHaveBeenCalled(); + // Then only the pending swaps folder is listed, because on Android listing the receipts folder blocks every native call for seconds + expect(mockReadDir).toHaveBeenCalledWith(PENDING_SWAPS); + expect(mockReadDir).not.toHaveBeenCalledWith(FOLDER); }); - it('restores a stranded receipt during the deferred startup sweep, without needing an upload', async () => { + it('keeps a stranded backup and its entry when putting it back fails, since it is the only copy of that receipt', async () => { + // Given a receipt that exists only under its backup name, and a rename that fails const storage = loadFreshStorage(); - const existing = new Set([FOLDER, `${FOLDER}/${RECEIPT}.receipt-swap-backup`, `${FOLDER}/${RECEIPT}.receipt-swap-staged`]); - mockReadDir.mockResolvedValue([{name: `${RECEIPT}.receipt-swap-backup`}, {name: `${RECEIPT}.receipt-swap-staged`}]); - mockExists.mockImplementation((path: string) => Promise.resolve(existing.has(path))); - mockMv.mockImplementation((from: string, to: string) => { - existing.delete(from); - existing.add(to); + const backupPath = `${FOLDER}/${RECEIPT}.receipt-swap-backup`; + const existing = setUpFakeDisk([FOLDER, PENDING_SWAPS, `${PENDING_SWAPS}/${RECEIPT}`, backupPath]); + mockReadDir.mockResolvedValue([{name: RECEIPT}]); + mockMv.mockRejectedValue(new Error('rename failed')); + + // When the startup sweep runs + await runStartupSweep(storage); + + // Then it tried the restore but left the backup and the entry for the next launch to retry + expect(mockMv).toHaveBeenCalledWith(backupPath, `${FOLDER}/${RECEIPT}`); + expect(existing.has(backupPath)).toBe(true); + expect(existing.has(`${PENDING_SWAPS}/${RECEIPT}`)).toBe(true); + }); + + it('keeps the entry when a leftover copy cannot be deleted, so a later sweep can still find it', async () => { + // Given a receipt in place with its old backup still beside it, and a delete that fails + const storage = loadFreshStorage(); + const backupPath = `${FOLDER}/${RECEIPT}.receipt-swap-backup`; + const existing = setUpFakeDisk([FOLDER, PENDING_SWAPS, `${PENDING_SWAPS}/${RECEIPT}`, `${FOLDER}/${RECEIPT}`, backupPath]); + mockReadDir.mockResolvedValue([{name: RECEIPT}]); + mockUnlink.mockImplementation((path: string) => { + if (path === backupPath) { + return Promise.reject(new Error('permission denied')); + } + existing.delete(path); return Promise.resolve(); }); + + // When the startup sweep runs + await runStartupSweep(storage); + + // Then the backup is still there and so is the entry that points at it + expect(existing.has(backupPath)).toBe(true); + expect(existing.has(`${PENDING_SWAPS}/${RECEIPT}`)).toBe(true); + }); + + it('keeps the entry when a finished swap could not delete the copy it moved aside', async () => { + // Given a receipt in durable storage, and a delete of the backup that fails + const storage = loadFreshStorage(); + const backupPath = `${FOLDER}/${RECEIPT}.receipt-swap-backup`; + const existing = setUpFakeDisk([FOLDER, `${FOLDER}/${RECEIPT}`]); mockUnlink.mockImplementation((path: string) => { + if (path === backupPath) { + return Promise.reject(new Error('permission denied')); + } existing.delete(path); return Promise.resolve(); }); - const originalRequestIdleCallback = global.requestIdleCallback; - global.requestIdleCallback = ((callback: IdleRequestCallback) => { - callback({didTimeout: false, timeRemaining: () => 50}); - return 0; - }) as typeof requestIdleCallback; + // When the swap runs, which still succeeds because the receipt is already swapped + await expect(storage.overwrite(RECEIPT, '/var/mobile/tmp/still.jpg')).resolves.toBe(RECEIPT); - try { - await storage.sweepLeftovers(); - } finally { - global.requestIdleCallback = originalRequestIdleCallback; - } + // Then the entry stays, so the next launch's sweep deletes the backup instead of it leaking forever + expect(existing.has(backupPath)).toBe(true); + expect(existing.has(`${PENDING_SWAPS}/${RECEIPT}`)).toBe(true); + }); + + it('restores a stranded receipt during the deferred startup sweep, without needing an upload', async () => { + // Given the app died between the two renames of a swap + const storage = loadFreshStorage(); + const existing = setUpFakeDisk([FOLDER, PENDING_SWAPS, `${PENDING_SWAPS}/${RECEIPT}`, `${FOLDER}/${RECEIPT}.receipt-swap-backup`, `${FOLDER}/${RECEIPT}.receipt-swap-staged`]); + mockReadDir.mockResolvedValue([{name: RECEIPT}]); + + // When the startup sweep runs + await runStartupSweep(storage); + // Then the receipt is back under its own name, and nothing of the swap is left expect(mockMv).toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}.receipt-swap-backup`, `${FOLDER}/${RECEIPT}`); - expect(mockUnlink).toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}.receipt-swap-staged`); expect(existing.has(`${FOLDER}/${RECEIPT}`)).toBe(true); expect(existing.has(`${FOLDER}/${RECEIPT}.receipt-swap-backup`)).toBe(false); expect(existing.has(`${FOLDER}/${RECEIPT}.receipt-swap-staged`)).toBe(false); + expect(existing.has(`${PENDING_SWAPS}/${RECEIPT}`)).toBe(false); + }); + + it('marks a swap pending before moving any file and clears the entry once the receipt is in place', async () => { + // Given a receipt in durable storage + const storage = loadFreshStorage(); + const existing = setUpFakeDisk([FOLDER, `${FOLDER}/${RECEIPT}`]); + + // When it is swapped for the full-resolution still + await expect(storage.overwrite(RECEIPT, '/var/mobile/tmp/still.jpg')).resolves.toBe(RECEIPT); + + // Then the entry was written before the first move, so dying mid-swap always leaves one, and is gone after + expect(mockWriteFile).toHaveBeenCalledWith(`${PENDING_SWAPS}/${RECEIPT}`, '', 'utf8'); + expect(mockWriteFile.mock.invocationCallOrder.at(0) ?? Number.MAX_SAFE_INTEGER).toBeLessThan(mockMv.mock.invocationCallOrder.at(0) ?? 0); + expect(existing.has(`${PENDING_SWAPS}/${RECEIPT}`)).toBe(false); + }); + + it('keeps the entry when a failed swap could not put the receipt back', async () => { + // Given a swap whose second rename fails and whose restore fails too + const storage = loadFreshStorage(); + const existing = setUpFakeDisk([FOLDER, `${FOLDER}/${RECEIPT}`]); + mockMv.mockImplementation((from: string, to: string) => { + if (to === `${FOLDER}/${RECEIPT}`) { + return Promise.reject(new Error('no space left on device')); + } + existing.delete(from); + existing.add(to); + return Promise.resolve(); + }); + + // When the swap runs + await expect(storage.overwrite(RECEIPT, '/var/mobile/tmp/still.jpg')).rejects.toThrow('it is left at'); + + // Then the entry stays, so the next launch's sweep finds the receipt under its backup name + expect(existing.has(`${PENDING_SWAPS}/${RECEIPT}`)).toBe(true); }); }); From e09d96f5ab7fc59e21c59168e1482ed48125efd0 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 30 Sep 2026 12:37:52 +0200 Subject: [PATCH 19/23] fix: receipt old preview and blurry image --- src/CONST/index.ts | 3 ++ src/libs/ReceiptStorage/index.native.ts | 5 ++- .../components/Camera/index.native.tsx | 18 +++++++--- tests/unit/libs/receiptStorageTest.ts | 33 +++++++++++++++++++ 4 files changed, 53 insertions(+), 6 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 4ed4bd3f256a..7d267ced09c0 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -415,6 +415,9 @@ const CONST = { RECEIPT_CAMERA: { PHOTO_WIDTH: 2880, PHOTO_HEIGHT: 2160, + // iPhones offer no 4:3 photo size between 3 MP and 12 MP, so the 6 MP target above selects 2016x1512 + IOS_STILL_WIDTH: 4032, + IOS_STILL_HEIGHT: 3024, PHOTO_ASPECT_RATIO: 4 / 3, }, diff --git a/src/libs/ReceiptStorage/index.native.ts b/src/libs/ReceiptStorage/index.native.ts index e0be83baf917..27c3d23150d1 100644 --- a/src/libs/ReceiptStorage/index.native.ts +++ b/src/libs/ReceiptStorage/index.native.ts @@ -102,7 +102,7 @@ async function restoreInterruptedSwap(target: string, {shouldWaitForRunningSwap const backupPath = `${target}${BACKUP_SUFFIX}`; if (!(await RNFS.exists(backupPath))) { - return false; + return RNFS.exists(target); } try { @@ -210,6 +210,9 @@ async function sweepInterruptedSwaps(dir: string) { const isStagedGone = await discardLeftover(`${target}${STAGED_SUFFIX}`); if (!isRestored) { + if (isStagedGone && !(await RNFS.exists(`${target}${BACKUP_SUFFIX}`))) { + await discardLeftover(`${pendingSwapsDir}/${name}`); + } return; } const isBackupGone = await discardLeftover(`${target}${BACKUP_SUFFIX}`); diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx index 7a24a795e754..e7e564d43d6d 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx @@ -84,16 +84,18 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou cameraFocusIndicatorAnimatedStyle, } = useNativeCamera({onFocusStart, onFocusCleanup}); - // Prioritize photoResolution so the format selector picks the configured PHOTO_WIDTH/PHOTO_HEIGHT - // format. videoResolution is platform-specific: - // - iOS: match the photo target — `takeSnapshot` reads from the video pipeline, so a smaller + // Prioritize photoResolution so the format selector picks the configured still size. iOS asks for 12 MP, + // because the upgraded still is what gets compared with the native Camera app. videoResolution is platform-specific: + // - iOS: keep the 6 MP target — `takeSnapshot` reads from the video pipeline, so a smaller // video resolution would degrade the snapshot capture quality. // - Android: keep screen dimensions — `takeSnapshot` is a GPU screenshot of the preview surface // and doesn't depend on video resolution; constraining to screen size avoids burning GPU on a // higher-than-needed preview. const format = useCameraFormat(device, [ {photoAspectRatio: CONST.RECEIPT_CAMERA.PHOTO_ASPECT_RATIO}, - {photoResolution: {width: CONST.RECEIPT_CAMERA.PHOTO_WIDTH, height: CONST.RECEIPT_CAMERA.PHOTO_HEIGHT}}, + Platform.OS === 'ios' + ? {photoResolution: {width: CONST.RECEIPT_CAMERA.IOS_STILL_WIDTH, height: CONST.RECEIPT_CAMERA.IOS_STILL_HEIGHT}} + : {photoResolution: {width: CONST.RECEIPT_CAMERA.PHOTO_WIDTH, height: CONST.RECEIPT_CAMERA.PHOTO_HEIGHT}}, Platform.OS === 'ios' ? {videoResolution: {width: CONST.RECEIPT_CAMERA.PHOTO_WIDTH, height: CONST.RECEIPT_CAMERA.PHOTO_HEIGHT}} : {videoResolution: {width: windowHeight, height: windowWidth}}, @@ -174,6 +176,11 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou const path = getReceiptsUploadFolderPath(); const shouldUpgradeToPhoto = canUpgradeReceiptQuality && !isMultiScanEnabled && !shouldTakePhoto({flash, hasFlash, isInLandscapeMode}); + // TEMP-UPGRADE-GATE + // eslint-disable-next-line no-console + console.log( + `[UPGRADE-GATE] upgrade=${shouldUpgradeToPhoto} canUpgrade=${canUpgradeReceiptQuality} multiScan=${isMultiScanEnabled} flash=${flash} hasFlash=${hasFlash} landscape=${isInLandscapeMode}`, + ); // The snapshot goes first so its request reaches the native queue ahead of the still. On iOS it // reads the most recent video frame, which a photo capture can interrupt. @@ -270,7 +277,8 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou flash={flash} hasFlash={hasFlash} setFlash={setFlash} - photoQualityBalance="balanced" + // With "device", a phone held flat over a receipt keeps its last landscape reading, and the + // still is saved rotated, so photos follow the screen orientation instead. outputOrientation="preview" /> )} diff --git a/tests/unit/libs/receiptStorageTest.ts b/tests/unit/libs/receiptStorageTest.ts index 9f6ee13d8864..e6a153308166 100644 --- a/tests/unit/libs/receiptStorageTest.ts +++ b/tests/unit/libs/receiptStorageTest.ts @@ -471,6 +471,20 @@ describe('ReceiptStorage', () => { expect(existing.has(`${PENDING_SWAPS}/${RECEIPT}`)).toBe(true); }); + it('drops the entry of a receipt that is gone along with its backup, so later launches stop checking it', async () => { + // Given an entry whose receipt and backup were both deleted outside the app + const storage = loadFreshStorage(); + const existing = setUpFakeDisk([FOLDER, PENDING_SWAPS, `${PENDING_SWAPS}/${RECEIPT}`]); + mockReadDir.mockResolvedValue([{name: RECEIPT}]); + + // When the startup sweep runs + await runStartupSweep(storage); + + // Then there is nothing left to put back, so the entry is removed + expect(mockMv).not.toHaveBeenCalled(); + expect(existing.has(`${PENDING_SWAPS}/${RECEIPT}`)).toBe(false); + }); + it('restores a stranded receipt during the deferred startup sweep, without needing an upload', async () => { // Given the app died between the two renames of a swap const storage = loadFreshStorage(); @@ -546,6 +560,25 @@ describe('ReceiptStorage', () => { expect(mockMv).toHaveBeenCalledWith(`${RECEIPT_PATH}.receipt-swap-backup`, RECEIPT_PATH); }); + it('finds a receipt the startup sweep put back between its two checks, instead of sending the upload without it', async () => { + // Given a receipt missing under its own name, whose backup the sweep moves back while the read is checking + mockCheckFileExists.mockResolvedValue(false); + let targetChecks = 0; + mockExists.mockImplementation((path: string) => { + if (path === RECEIPT_PATH) { + targetChecks += 1; + return Promise.resolve(targetChecks > 1); + } + return Promise.resolve(false); + }); + + // When the upload locates the receipt + const located = await ReceiptStorage.locate(RECEIPT_URI); + + // Then it sees the receipt the sweep restored rather than reporting it missing + expect(located).toBe(RECEIPT_URI); + }); + it('reads straight through while a swap is still staging, since that half can be called off', async () => { const existing = new Set([RECEIPT_PATH]); let finishStaging: () => void = () => {}; From 175246eb499d79da838e75bdab381c682f8bb20f Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 5 Oct 2026 14:24:30 +0200 Subject: [PATCH 20/23] Fix the receipt preview flash when the upgrade lands --- src/libs/getImageRecyclingKey.ts | 10 +++++++++- .../components/Camera/index.native.tsx | 11 ++-------- .../components/CameraViewport.tsx | 5 ----- tests/unit/getImageRecyclingKeyTest.ts | 20 +++++++++++++++++++ .../libs/prepareRequestPayloadNativeTest.ts | 8 +++++++- 5 files changed, 38 insertions(+), 16 deletions(-) diff --git a/src/libs/getImageRecyclingKey.ts b/src/libs/getImageRecyclingKey.ts index 7e7fe71e2e87..6457d1a4a132 100644 --- a/src/libs/getImageRecyclingKey.ts +++ b/src/libs/getImageRecyclingKey.ts @@ -3,6 +3,14 @@ import type {ImageRequireSource, ImageURISource} from 'react-native'; type ImageSourceParam = ImageSource | number | ImageSource[] | Omit | ImageRequireSource | undefined; +/** expo-image blanks the view whenever recyclingKey changes, so a fragment that only forces a reload of the same file stays out of the key. */ +function withoutFragment(uri: string): string { + if (uri.startsWith('data:')) { + return uri; + } + return uri.split('#').at(0) ?? uri; +} + /** * Returns a string key for image recycling in FlashList. * Extracts the URI if available, otherwise stringifies the source. @@ -22,7 +30,7 @@ function getImageRecyclingKey(source: ImageSourceParam): string | undefined { } if (typeof source === 'object' && 'uri' in source && source.uri) { - return source.uri; + return withoutFragment(source.uri); } return JSON.stringify(source); diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx index e7e564d43d6d..238748ef5984 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx @@ -84,10 +84,8 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou cameraFocusIndicatorAnimatedStyle, } = useNativeCamera({onFocusStart, onFocusCleanup}); - // Prioritize photoResolution so the format selector picks the configured still size. iOS asks for 12 MP, - // because the upgraded still is what gets compared with the native Camera app. videoResolution is platform-specific: - // - iOS: keep the 6 MP target — `takeSnapshot` reads from the video pipeline, so a smaller - // video resolution would degrade the snapshot capture quality. + // photoResolution picks the format; videoResolution only breaks ties. videoResolution is platform-specific: + // - iOS: `takeSnapshot` returns the raw video frame, so prefer a video size close to 6 MP. // - Android: keep screen dimensions — `takeSnapshot` is a GPU screenshot of the preview surface // and doesn't depend on video resolution; constraining to screen size avoids burning GPU on a // higher-than-needed preview. @@ -176,11 +174,6 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou const path = getReceiptsUploadFolderPath(); const shouldUpgradeToPhoto = canUpgradeReceiptQuality && !isMultiScanEnabled && !shouldTakePhoto({flash, hasFlash, isInLandscapeMode}); - // TEMP-UPGRADE-GATE - // eslint-disable-next-line no-console - console.log( - `[UPGRADE-GATE] upgrade=${shouldUpgradeToPhoto} canUpgrade=${canUpgradeReceiptQuality} multiScan=${isMultiScanEnabled} flash=${flash} hasFlash=${hasFlash} landscape=${isInLandscapeMode}`, - ); // The snapshot goes first so its request reaches the native queue ahead of the still. On iOS it // reads the most recent video frame, which a photo capture can interrupt. diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx index e0f0fdf907f3..101e8a6a6f74 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx @@ -88,9 +88,6 @@ type CameraViewportProps = { setFlash: (updater: (prev: boolean) => boolean) => void; - /** Trade-off between capture speed and still quality, defaults to "quality" */ - photoQualityBalance?: CameraProps['photoQualityBalance']; - /** Whether photos follow the device or the preview orientation, defaults to "device" */ outputOrientation?: CameraProps['outputOrientation']; @@ -120,7 +117,6 @@ function CameraViewport({ flash, hasFlash, setFlash, - photoQualityBalance = 'quality', outputOrientation = 'device', children, }: CameraViewportProps) { @@ -142,7 +138,6 @@ function CameraViewport({ style={styles.flex1} zoom={device.neutralZoom} photo - photoQualityBalance={photoQualityBalance} outputOrientation={outputOrientation} cameraTabIndex={1} forceInactive={isAttachmentPickerActive || (didCapturePhoto && !hasPendingPhotoCapture)} diff --git a/tests/unit/getImageRecyclingKeyTest.ts b/tests/unit/getImageRecyclingKeyTest.ts index cadf9bf56cec..cb3e63bd6266 100644 --- a/tests/unit/getImageRecyclingKeyTest.ts +++ b/tests/unit/getImageRecyclingKeyTest.ts @@ -13,6 +13,26 @@ describe('getImageRecyclingKey', () => { expect(getImageRecyclingKey({uri: 'https://example.com/image.png'})).toBe('https://example.com/image.png'); }); + test('ignores the URI fragment so a forced reload of the same file keeps the key', () => { + // Given a local receipt that was upgraded in place, so its URI only gains a fragment to force a reload + const original = {uri: 'file:///receipts/receipt.jpg'}; + const upgraded = {uri: 'file:///receipts/receipt.jpg#upgraded1'}; + + // When both sources are turned into recycling keys + // Then the keys match, so expo-image keeps showing the old image instead of blanking the view while the new one decodes + expect(getImageRecyclingKey(upgraded)).toBe(getImageRecyclingKey(original)); + expect(getImageRecyclingKey(upgraded)).toBe('file:///receipts/receipt.jpg'); + }); + + test('keeps a data URI whole because a raw # in it is part of the content', () => { + // Given an inline SVG whose colors use unencoded # characters + const source = {uri: 'data:image/svg+xml;utf8,'}; + + // When the source is turned into a recycling key + // Then nothing is cut off, so two different SVGs never share a key + expect(getImageRecyclingKey(source)).toBe(source.uri); + }); + test('returns URI from first element of array source', () => { const source = [{uri: 'https://example.com/first.png'}, {uri: 'https://example.com/second.png'}]; expect(getImageRecyclingKey(source)).toBe('https://example.com/first.png'); diff --git a/tests/unit/libs/prepareRequestPayloadNativeTest.ts b/tests/unit/libs/prepareRequestPayloadNativeTest.ts index aa417edc99f9..c9fab238f83e 100644 --- a/tests/unit/libs/prepareRequestPayloadNativeTest.ts +++ b/tests/unit/libs/prepareRequestPayloadNativeTest.ts @@ -159,7 +159,7 @@ describe('prepareRequestPayload (native)', () => { expect(formData.has('undefinedField')).toBe(false); }); - describe('a receipt sent as a file object, the way ReplaceReceipt sends it', () => { + describe('a receipt sent as a file object without a source, as a request queued by an older app version sends it', () => { /** `clearAllMocks` keeps a `mockReturnValue`, so a deferred one would leak into the next test. */ beforeEach(() => { mockSettle.mockReturnValue(Promise.resolve()); @@ -172,6 +172,7 @@ describe('prepareRequestPayload (native)', () => { }); it('claims the file before reading it, and waits only while a claimed swap finishes renaming', async () => { + // Given a swap that has already committed to renaming the receipt file let releaseCommittedSwap: () => void = () => {}; mockSettle.mockReturnValue( new Promise((resolve) => { @@ -179,6 +180,7 @@ describe('prepareRequestPayload (native)', () => { }), ); + // When an old queued ReplaceReceipt request is prepared with only the file object let hasPrepared = false; const prepared = prepareRequestPayload( 'ReplaceReceipt', @@ -193,6 +195,7 @@ describe('prepareRequestPayload (native)', () => { }); await flushMicrotasks(); + // Then the file is claimed by name and the payload waits for the swap to finish expect(mockSettle).toHaveBeenCalledWith('CAM-1.jpg'); // `settle` only holds the payload while a swap has already committed to its two renames, since // reading the receipt mid-rename would send the old bytes, the new ones, or nothing at all. @@ -205,6 +208,8 @@ describe('prepareRequestPayload (native)', () => { }); it('never reaches locate, since a file object carries no receipt source to resolve', async () => { + // Given an old queued ReplaceReceipt request whose receipt has a uri but no source + // When the payload is prepared await prepareRequestPayload( 'ReplaceReceipt', { @@ -214,6 +219,7 @@ describe('prepareRequestPayload (native)', () => { false, ); + // Then nothing checks the filesystem or reports the receipt as dropped, because there is no source to locate expect(mockCheckFileExists).not.toHaveBeenCalled(); expect(mockLogReceiptDropped).not.toHaveBeenCalled(); }); From cf827994cc7c182aed1e2f8fc2a214f11859d17e Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 7 Oct 2026 10:01:38 +0200 Subject: [PATCH 21/23] Address review comments --- .../components/Camera/index.native.tsx | 5 +++-- .../hooks/usePhotoUpgrade.ts | 13 +++++++++++-- .../utils/rotatePhotoToUpright.ts | 18 +++--------------- 3 files changed, 17 insertions(+), 19 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx index 238748ef5984..0df861abdeab 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx @@ -270,8 +270,9 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou flash={flash} hasFlash={hasFlash} setFlash={setFlash} - // With "device", a phone held flat over a receipt keeps its last landscape reading, and the - // still is saved rotated, so photos follow the screen orientation instead. + // "preview" turns every capture to match the screen, the way the preview is drawn. The default "device" follows the + // phone's physical orientation instead, so a phone held flat over a receipt keeps its last landscape reading and the + // photo comes out sideways. outputOrientation="preview" /> )} diff --git a/src/pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade.ts b/src/pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade.ts index 7623e468b793..0b9f4764f0ce 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade.ts @@ -37,13 +37,22 @@ function discardWhenItLands(pending: PendingPhoto) { .catch(() => {}); } +class DeadlineError extends Error { + step: string; + + constructor(step: string, capMs: number) { + super(`[PhotoUpgrade] ${step} did not finish within ${capMs}ms`); + this.step = step; + } +} + function withDeadline(promise: Promise, capMs: number, step: string, discardLateResult?: (value: T) => void): Promise { let cap: ReturnType; let hasTimedOut = false; const deadline = new Promise((_resolve, reject) => { cap = setTimeout(() => { hasTimedOut = true; - reject(new Error(`[PhotoUpgrade] ${step} did not finish within ${capMs}ms`)); + reject(new DeadlineError(step, capMs)); }, capMs); }); @@ -276,7 +285,7 @@ function usePhotoUpgrade() { let outcome: string = CONST.TELEMETRY.UPGRADE_OUTCOME.SWAP_FAILED; if (wasClaimed) { outcome = CONST.TELEMETRY.UPGRADE_OUTCOME.CLAIMED_FOR_UPLOAD; - } else if (reason.includes('rotate did not finish')) { + } else if (error instanceof DeadlineError && error.step === 'rotate') { outcome = CONST.TELEMETRY.UPGRADE_OUTCOME.ROTATE_TIMED_OUT; } reportOutcome(outcome); diff --git a/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts b/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts index b9aa44a9a708..377b8d3affa2 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts @@ -13,9 +13,7 @@ import ImageSize from 'react-native-image-size'; type PhotoSize = { width: number; - height: number; - rotation?: number; }; @@ -30,13 +28,7 @@ function getUprightRotation({width, height, rotation = 0}: PhotoSize, orientatio // counter-clockwise angle (see its `RotationHelper`): a portrait capture off a landscape sensor reports // `landscape-left`, so 270 counter-clockwise, so 90 clockwise. The opposite hold turns the other way. const quarterTurn = orientation === 'landscape-right' ? 270 : 90; - const angle = decodedWidth > decodedHeight ? quarterTurn : 0; - - if (!angle) { - return undefined; - } - - return angle; + return decodedWidth > decodedHeight ? quarterTurn : undefined; } function rotatePhotoToUpright(stillPath: string, orientation?: Orientation): Promise { @@ -49,12 +41,8 @@ function rotatePhotoToUpright(stillPath: string, orientation?: Orientation): Pro return undefined; } - const context = ImageManipulator.manipulate(sourceUri); - if (angle) { - context.rotate(angle); - } - - return context + return ImageManipulator.manipulate(sourceUri) + .rotate(angle) .renderAsync() .then((image) => image.saveAsync({compress: JPEG_QUALITY, format: SaveFormat.JPEG})) .then((result) => { From 465281c58727c35154fe4601745ffa6039383de1 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 7 Oct 2026 13:40:07 +0200 Subject: [PATCH 22/23] Final code cleanups --- src/CONST/index.ts | 1 + src/libs/ReceiptStorage/receiptUpgrades.ts | 6 +---- src/libs/ReceiptStorage/types.ts | 13 +++++++++++ .../hooks/usePhotoUpgrade.ts | 20 +++++++++++------ .../utils/rotatePhotoToUpright.ts | 18 +++++++++++---- tests/unit/hooks/usePhotoUpgrade.test.ts | 22 ++++++++++++++++++- tests/unit/libs/receiptUpgradesTest.ts | 5 +---- 7 files changed, 64 insertions(+), 21 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 1cb0be3350de..e2d787cbe4d6 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2546,6 +2546,7 @@ const CONST = { CAPTURE_FAILED: 'capture_failed', CAPTURE_TIMED_OUT: 'capture_timed_out', ROTATE_TIMED_OUT: 'rotate_timed_out', + ROTATE_FAILED: 'rotate_failed', CLAIMED_FOR_UPLOAD: 'claimed_for_upload', SWAP_FAILED: 'swap_failed', STACKED_CAPTURE_SKIPPED: 'stacked_capture_skipped', diff --git a/src/libs/ReceiptStorage/receiptUpgrades.ts b/src/libs/ReceiptStorage/receiptUpgrades.ts index 2b7da307cfb3..f93b8fef6aac 100644 --- a/src/libs/ReceiptStorage/receiptUpgrades.ts +++ b/src/libs/ReceiptStorage/receiptUpgrades.ts @@ -73,10 +73,6 @@ function isClaimedForRead(durableName: string): boolean { return upgradesInFlight.get(durableName)?.isClaimed ?? false; } -function isUpgrading(durableName: string): boolean { - return upgradesInFlight.has(durableName); -} - function subscribe(listener: () => void): () => void { listeners.add(listener); return () => { @@ -84,4 +80,4 @@ function subscribe(listener: () => void): () => void { }; } -export {start, finish, recordUpgrade, claimForRead, isUpgrading, isClaimedForRead, getUpgradeCount, subscribe}; +export {start, finish, recordUpgrade, claimForRead, isClaimedForRead, getUpgradeCount, subscribe}; diff --git a/src/libs/ReceiptStorage/types.ts b/src/libs/ReceiptStorage/types.ts index e46e982638f1..b2e27a910951 100644 --- a/src/libs/ReceiptStorage/types.ts +++ b/src/libs/ReceiptStorage/types.ts @@ -1,18 +1,29 @@ import type {ReceiptSource} from '@src/types/onyx/Transaction'; +/** Owns the receipts folder. The only code that writes a file there, names one, or resolves one. */ type ReceiptStorage = { + /** Moves a file into the receipts folder and returns its durable name. Rejects when the file did not land. */ adopt: (uriOrPath: string, fileName?: string) => Promise; + /** + * Replaces a receipt's bytes under the same durable name, putting the original back if the swap fails. + * Gives up before the first rename when `shouldAbort` returns true, e.g. because an upload claimed the receipt. + */ overwrite: (durableName: string, uriOrPath: string, shouldAbort?: () => boolean) => Promise; + /** Deletes a file if it exists. */ discard: (uriOrPath: string) => Promise; + /** Resolves a stored source to a file that exists, waiting for a committed swap and restoring an interrupted one. */ locate: (source: ReceiptSource | null | undefined) => Promise; + /** Claims a receipt for reading, so an upgrade still preparing keeps the snapshot, and waits for a swap already renaming. */ settle: (durableName: string) => Promise; + /** After a failed read, waits for a running swap and restores an interrupted one. Resolves true when the file is back. */ recheckAfterSwap: (source: ReceiptSource | null | undefined) => Promise; + /** Valid for this launch only, so never store the result. */ toLocalUri: (durableName: string) => string; /** @@ -21,8 +32,10 @@ type ReceiptStorage = { */ retain: (source: string) => void; + /** Re-roots a stored source onto the current folder. A remote source passes through unchanged. */ resolve: (source: ReceiptSource | null | undefined) => string | undefined; + /** Deletes copies an interrupted swap left in the receipts folder, once the app is idle after startup. */ sweepLeftovers: () => Promise; }; diff --git a/src/pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade.ts b/src/pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade.ts index 0b9f4764f0ce..a3a389751940 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade.ts @@ -37,12 +37,15 @@ function discardWhenItLands(pending: PendingPhoto) { .catch(() => {}); } -class DeadlineError extends Error { +class UpgradeStepError extends Error { step: string; - constructor(step: string, capMs: number) { - super(`[PhotoUpgrade] ${step} did not finish within ${capMs}ms`); + hasTimedOut: boolean; + + constructor(step: string, message: string, hasTimedOut: boolean) { + super(`[PhotoUpgrade] ${step} ${message}`); this.step = step; + this.hasTimedOut = hasTimedOut; } } @@ -52,7 +55,7 @@ function withDeadline(promise: Promise, capMs: number, step: string, disca const deadline = new Promise((_resolve, reject) => { cap = setTimeout(() => { hasTimedOut = true; - reject(new DeadlineError(step, capMs)); + reject(new UpgradeStepError(step, `did not finish within ${capMs}ms`, true)); }, capMs); }); @@ -236,7 +239,10 @@ function usePhotoUpgrade() { return undefined; } - return withDeadline(rotatePhotoToUpright(photo.path, photo.orientation), ROTATE_TIMEOUT_MS, 'rotate', (latePath) => { + const rotation = rotatePhotoToUpright(photo.path, photo.orientation).catch((error: unknown) => { + throw new UpgradeStepError('rotate', `failed: ${error instanceof Error ? error.message : String(error)}`, false); + }); + return withDeadline(rotation, ROTATE_TIMEOUT_MS, 'rotate', (latePath) => { if (!latePath) { return; } @@ -285,8 +291,8 @@ function usePhotoUpgrade() { let outcome: string = CONST.TELEMETRY.UPGRADE_OUTCOME.SWAP_FAILED; if (wasClaimed) { outcome = CONST.TELEMETRY.UPGRADE_OUTCOME.CLAIMED_FOR_UPLOAD; - } else if (error instanceof DeadlineError && error.step === 'rotate') { - outcome = CONST.TELEMETRY.UPGRADE_OUTCOME.ROTATE_TIMED_OUT; + } else if (error instanceof UpgradeStepError && error.step === 'rotate') { + outcome = error.hasTimedOut ? CONST.TELEMETRY.UPGRADE_OUTCOME.ROTATE_TIMED_OUT : CONST.TELEMETRY.UPGRADE_OUTCOME.ROTATE_FAILED; } reportOutcome(outcome); pending.discard(); diff --git a/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts b/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts index 377b8d3affa2..6af92dbb4aa8 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts @@ -31,6 +31,15 @@ function getUprightRotation({width, height, rotation = 0}: PhotoSize, orientatio return decodedWidth > decodedHeight ? quarterTurn : undefined; } +/** The garbage collector can't see the native memory a 12 MP bitmap holds, so free it as soon as the photo is saved. */ +function releaseQuietly(releasable: {release: () => void}) { + try { + releasable.release(); + } catch (error) { + Log.warn('[PhotoUpgrade] could not release a native image', {error: error instanceof Error ? error.message : String(error)}); + } +} + function rotatePhotoToUpright(stillPath: string, orientation?: Orientation): Promise { const sourceUri = getPhotoSource(stillPath); const startedAt = Date.now(); @@ -41,10 +50,10 @@ function rotatePhotoToUpright(stillPath: string, orientation?: Orientation): Pro return undefined; } - return ImageManipulator.manipulate(sourceUri) - .rotate(angle) + const context = ImageManipulator.manipulate(sourceUri).rotate(angle); + return context .renderAsync() - .then((image) => image.saveAsync({compress: JPEG_QUALITY, format: SaveFormat.JPEG})) + .then((image) => image.saveAsync({compress: JPEG_QUALITY, format: SaveFormat.JPEG}).finally(() => releaseQuietly(image))) .then((result) => { Log.info('[PhotoUpgrade] rotated the full-resolution photo', false, { durationMs: Date.now() - startedAt, @@ -53,7 +62,8 @@ function rotatePhotoToUpright(stillPath: string, orientation?: Orientation): Pro source: `${imageSize.width}x${imageSize.height}`, }); return result.uri; - }); + }) + .finally(() => releaseQuietly(context)); }); } diff --git a/tests/unit/hooks/usePhotoUpgrade.test.ts b/tests/unit/hooks/usePhotoUpgrade.test.ts index 373de99b0daf..0e0335bc08b6 100644 --- a/tests/unit/hooks/usePhotoUpgrade.test.ts +++ b/tests/unit/hooks/usePhotoUpgrade.test.ts @@ -223,7 +223,7 @@ describe('usePhotoUpgrade', () => { jest.runOnlyPendingTimers(); }); - // Left registered, `isUpgrading` would stay true for the rest of the launch. + // Left registered, the receipt would count as upgrading for the rest of the launch. expect(mockFinishUpgrade).toHaveBeenCalledWith(DURABLE_NAME); expect(mockReplace).not.toHaveBeenCalled(); }); @@ -534,6 +534,26 @@ describe('usePhotoUpgrade', () => { expect(readOutcome().attributes).toEqual({[CONST.TELEMETRY.ATTRIBUTE_UPGRADE_OUTCOME]: CONST.TELEMETRY.UPGRADE_OUTCOME.ROTATE_TIMED_OUT}); }); + it('reports a rotate that failed outright apart from the swap, so the outcomes stay comparable', async () => { + // Given a rotate that rejects before the swap starts, e.g. the photo size cannot be read + const {camera, landPhoto} = buildCamera(); + const {result} = renderHook(() => usePhotoUpgrade()); + mockRotate.mockRejectedValue(new Error('could not read the image size')); + + // When the photo lands and the upgrade runs + act(() => { + result.current.startPhotoCapture(camera); + }); + act(() => { + result.current.upgradeReceiptWithPhoto(DURABLE_NAME); + }); + await landPhoto(); + + // Then the outcome names the rotate rather than counting it as a failed swap + expect(readOutcome().attributes).toEqual({[CONST.TELEMETRY.ATTRIBUTE_UPGRADE_OUTCOME]: CONST.TELEMETRY.UPGRADE_OUTCOME.ROTATE_FAILED}); + expect(mockReplace).not.toHaveBeenCalled(); + }); + it('marks the attempt on the capture span, so a lost outcome is still countable', () => { const {camera} = buildCamera(); const {result} = renderHook(() => usePhotoUpgrade()); diff --git a/tests/unit/libs/receiptUpgradesTest.ts b/tests/unit/libs/receiptUpgradesTest.ts index 094efdcbf7e4..2321d2585c06 100644 --- a/tests/unit/libs/receiptUpgradesTest.ts +++ b/tests/unit/libs/receiptUpgradesTest.ts @@ -1,4 +1,4 @@ -import {claimForRead, finish, getUpgradeCount, isClaimedForRead, isUpgrading, recordUpgrade, start, subscribe} from '@libs/ReceiptStorage/receiptUpgrades'; +import {claimForRead, finish, getUpgradeCount, isClaimedForRead, recordUpgrade, start, subscribe} from '@libs/ReceiptStorage/receiptUpgrades'; const RECEIPT = 'receipt_1234.jpg'; @@ -9,7 +9,6 @@ describe('receiptUpgrades', () => { }); it('reports nothing in flight for a receipt that is not being upgraded', () => { - expect(isUpgrading(RECEIPT)).toBe(false); expect(isClaimedForRead(RECEIPT)).toBe(false); }); @@ -22,8 +21,6 @@ describe('receiptUpgrades', () => { claimForRead(RECEIPT); expect(isClaimedForRead(RECEIPT)).toBe(true); - // The upgrade is still running, it just no longer owns the file. - expect(isUpgrading(RECEIPT)).toBe(true); }); it('ignores a claim on a receipt nothing is upgrading, so an ordinary upload is unaffected', () => { From cd951a9ae1d3d299acb9742e1db5765af45d7cc9 Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 8 Oct 2026 12:18:11 +0200 Subject: [PATCH 23/23] fix: mock fetch in SearchEditMultipleTagPageTest --- tests/unit/SearchEditMultipleTagPageTest.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/unit/SearchEditMultipleTagPageTest.tsx b/tests/unit/SearchEditMultipleTagPageTest.tsx index 75145d17d75f..c0a249b444a2 100644 --- a/tests/unit/SearchEditMultipleTagPageTest.tsx +++ b/tests/unit/SearchEditMultipleTagPageTest.tsx @@ -17,6 +17,7 @@ import React from 'react'; import Onyx from 'react-native-onyx'; import createRandomPolicy from '../utils/collections/policies'; +import {getGlobalFetchMock} from '../utils/TestHelper'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; const POLICY_ID = 'A1B2C3'; @@ -122,6 +123,8 @@ async function renderAndTap({ describe('SearchEditMultipleTagPage saveTag (bulk-edit tag deselect, #100538)', () => { beforeEach(async () => { + // The tag picker sends OpenPolicyTagsPage when it mounts, so mock fetch to keep that request off the real network. + global.fetch = getGlobalFetchMock(); mockSelectionList.mockClear(); mockUpdateBulkEditDraftTransaction.mockClear(); await Onyx.clear();