diff --git a/cspell.json b/cspell.json index cd4050470f8c..5886fa28dc4f 100644 --- a/cspell.json +++ b/cspell.json @@ -125,6 +125,7 @@ "EUVAT", "elif", "evals", + "EXIF", "EXPENSIDEV", "EXPENSIFYAPI", "EXPENSIFYPDBUSINESS", diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 41376e1a296a..8ac13f5c218b 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -427,6 +427,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, }, @@ -2425,6 +2428,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', @@ -2535,6 +2539,8 @@ const CONST = { ATTRIBUTE_IS_MULTI_SCAN: 'is_multi_scan', ATTRIBUTE_CAPTURE_METHOD: 'capture_method', ATTRIBUTE_FLASH_USED: 'flash_used', + ATTRIBUTE_UPGRADE_ATTEMPTED: 'upgrade_attempted', + ATTRIBUTE_UPGRADE_OUTCOME: 'upgrade_outcome', ATTRIBUTE_PHOTO_WIDTH: 'photo_width', ATTRIBUTE_PHOTO_HEIGHT: 'photo_height', ATTRIBUTE_SOURCE: 'source', @@ -2550,6 +2556,16 @@ 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', + ROTATE_FAILED: 'rotate_failed', + 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/components/MoneyRequestConfirmationListFooter/hooks/useReceiptThumbnailSource.ts b/src/components/MoneyRequestConfirmationListFooter/hooks/useReceiptThumbnailSource.ts index 296cecabdd33..0044a4c2499a 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,16 @@ 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)) { + + 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..04205a816098 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,13 @@ function useLocalReceiptThumbnail(sourceUri: string | undefined, isLocalFile: bo const [, startTransition] = useTransition(); const retainedUriRef = useRef(undefined); + 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; + 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..5c4bf0a9363b --- /dev/null +++ b/src/hooks/useReceiptUpgradeCount.ts @@ -0,0 +1,16 @@ +import {getUpgradeCount, subscribe} from '@libs/ReceiptStorage/receiptUpgrades'; + +import {useSyncExternalStore} from 'react'; + +function toUpgradeKey(sourceUri: unknown): string { + if (typeof sourceUri !== 'string') { + return ''; + } + return sourceUri.split('/').pop()?.split('#').at(0)?.split('?').at(0) ?? ''; +} + +function useReceiptUpgradeCount(sourceUri: string | number | undefined): number { + 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 f735b5bebfd4..51234e482575 100644 --- a/src/libs/ReceiptStorage/index.native.ts +++ b/src/libs/ReceiptStorage/index.native.ts @@ -1,6 +1,8 @@ +import {checkFileExistsWithReason} from '@libs/fileDownload/checkFileExists'; 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'; @@ -8,11 +10,31 @@ import RNFS from 'react-native-fs'; import type ReceiptStorage from './types'; import {toDurableName} from './durableFolder'; +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. +const SWEEP_IDLE_TIMEOUT_MS = 10000; + +// 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'; + +// 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>(); + +async function waitForCommittedSwap(target: string) { + await committedSwaps.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'); @@ -50,6 +72,194 @@ 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); +}; + +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; + } +} + +async function restoreInterruptedSwap(target: string, {shouldWaitForRunningSwap = true} = {}): Promise { + if (await RNFS.exists(target)) { + return true; + } + + if (shouldWaitForRunningSwap && swapsInFlight.has(target)) { + await swapsInFlight.get(target)?.catch(() => {}); + return RNFS.exists(target); + } + + const backupPath = `${target}${BACKUP_SUFFIX}`; + if (!(await RNFS.exists(backupPath))) { + return RNFS.exists(target); + } + + 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 RNFS.exists(target); + } + + Log.info('[ReceiptStorage] put back a receipt left by an interrupted swap'); + return true; +} + +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. + 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); + await RNFS.moveFile(fileURIToPath(uriOrPath), stagedPath); + + if (shouldAbort?.()) { + await discardLeftover(stagedPath); + throw new Error('[ReceiptStorage] an upload claimed the receipt, so it was left as it stands'); + } + + let releaseCommitted: () => void = () => {}; + committedSwaps.set( + target, + new Promise((resolve) => { + releaseCommitted = resolve; + }), + ); + + try { + await RNFS.moveFile(target, backupPath); + await RNFS.moveFile(stagedPath, target); + } catch (error) { + await discardLeftover(stagedPath); + + if (await RNFS.exists(target)) { + throw error; + } + + 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; + } finally { + committedSwaps.delete(target); + releaseCommitted(); + } + + await discardLeftover(backupPath); + + 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) { + const pendingSwapsDir = `${dir}/${PENDING_SWAPS_FOLDER}`; + if (!(await RNFS.exists(pendingSwapsDir))) { + return; + } + + const names = (await RNFS.readDir(pendingSwapsDir)).map((entry) => entry.name); + + await Promise.all( + names.map(async (name) => { + const target = `${dir}/${name}`; + const isRestored = await restoreInterruptedSwap(target, {shouldWaitForRunningSwap: false}); + 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}`); + + if (isStagedGone && isBackupGone) { + await discardLeftover(`${pendingSwapsDir}/${name}`); + } + }), + ); +} + +let leftoversSwept: Promise | undefined; + +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}`; + + if (swapsInFlight.has(target)) { + throw new Error('[ReceiptStorage] a swap is already running for this receipt'); + } + + const swap = (async () => { + await whenLeftoversSwept(dir); + await restoreInterruptedSwap(target, {shouldWaitForRunningSwap: false}); + await verify(dir, durableName); + return swapIntoPlace(dir, durableName, target, uriOrPath, shouldAbort); + })(); + swapsInFlight.set(target, swap); + + try { + return await swap; + } finally { + swapsInFlight.delete(target); + } +}; + const toLocalUri: ReceiptStorage['toLocalUri'] = (durableName) => `file://${getReceiptsUploadFolderPath()}/${durableName}`; const retain: ReceiptStorage['retain'] = () => {}; @@ -65,6 +275,81 @@ const resolve: ReceiptStorage['resolve'] = (source) => { return durableName ? toLocalUri(durableName) : source; }; -const receiptStorage: ReceiptStorage = {adopt, toLocalUri, retain, resolve}; +const settle: ReceiptStorage['settle'] = async (durableName) => { + if (!durableName) { + return; + } + + claimForRead(durableName); + + const dir = getReceiptsUploadFolderPath(); + if (!dir) { + return; + } + + await waitForCommittedSwap(`${dir}/${durableName}`); +}; + +const locate: ReceiptStorage['locate'] = async (source) => { + const uri = resolve(source); + if (!uri) { + return undefined; + } + + if (isLocalFile(uri)) { + const dir = getReceiptsUploadFolderPath(); + const target = fileURIToPath(uri); + + if (dir) { + whenLeftoversSwept(dir).catch(() => {}); + } + + await settle(target.split('/').pop() ?? ''); + } + + if ((await checkFileExistsWithReason(uri)).exists) { + return uri; + } + + if (!isLocalFile(uri) || !(await restoreInterruptedSwap(fileURIToPath(uri)))) { + return undefined; + } + + 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(); + if (!dir) { + return; + } + + 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, recheckAfterSwap, toLocalUri, retain, resolve, sweepLeftovers}; export default receiptStorage; diff --git a/src/libs/ReceiptStorage/index.ts b/src/libs/ReceiptStorage/index.ts index 8fa98e6d5d15..943d672b93a1 100644 --- a/src/libs/ReceiptStorage/index.ts +++ b/src/libs/ReceiptStorage/index.ts @@ -34,9 +34,15 @@ const resolve: ReceiptStorage['resolve'] = (source) => { const receiptStorage: ReceiptStorage = { adopt: (uriOrPath) => Promise.resolve(uriOrPath), + overwrite: (durableName) => Promise.resolve(durableName), + discard: () => Promise.resolve(), + locate: (source) => Promise.resolve(resolve(source)), + settle: () => Promise.resolve(), + recheckAfterSwap: () => Promise.resolve(false), toLocalUri: (durableName) => durableName, retain, resolve, + sweepLeftovers: () => Promise.resolve(), }; export default receiptStorage; diff --git a/src/libs/ReceiptStorage/receiptUpgrades.ts b/src/libs/ReceiptStorage/receiptUpgrades.ts new file mode 100644 index 000000000000..f93b8fef6aac --- /dev/null +++ b/src/libs/ReceiptStorage/receiptUpgrades.ts @@ -0,0 +1,83 @@ +/** + * 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; + +type PendingUpgrade = { + isClaimed: boolean; +}; + +const upgradesInFlight = new Map(); + +const upgradeCounts = new Map(); + +const listeners = new Set<() => void>(); + +function notify() { + for (const listener of listeners) { + listener(); + } +} + +function start(durableName: string) { + if (upgradesInFlight.has(durableName)) { + return; + } + + upgradesInFlight.set(durableName, {isClaimed: false}); + notify(); +} + +function finish(durableName: string) { + const upgrade = upgradesInFlight.get(durableName); + if (!upgrade) { + return; + } + + upgradesInFlight.delete(durableName); + notify(); +} + +function recordUpgrade(durableName: string) { + upgradeCounts.set(durableName, (upgradeCounts.get(durableName) ?? 0) + 1); + + while (upgradeCounts.size > MAX_TRACKED_UPGRADE_COUNTS) { + const oldest = upgradeCounts.keys().next().value; + if (oldest === undefined) { + break; + } + upgradeCounts.delete(oldest); + } + + notify(); +} + +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; +} + +function isClaimedForRead(durableName: string): boolean { + return upgradesInFlight.get(durableName)?.isClaimed ?? false; +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export {start, finish, recordUpgrade, claimForRead, isClaimedForRead, getUpgradeCount, subscribe}; diff --git a/src/libs/ReceiptStorage/types.ts b/src/libs/ReceiptStorage/types.ts index affc64c59a53..b2e27a910951 100644 --- a/src/libs/ReceiptStorage/types.ts +++ b/src/libs/ReceiptStorage/types.ts @@ -5,6 +5,24 @@ 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; @@ -16,6 +34,9 @@ type ReceiptStorage = { /** 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; }; export default ReceiptStorage; diff --git a/src/libs/actions/IOU/Receipt.ts b/src/libs/actions/IOU/Receipt.ts index 7cde573f9bd2..4fc0390f436a 100644 --- a/src/libs/actions/IOU/Receipt.ts +++ b/src/libs/actions/IOU/Receipt.ts @@ -458,6 +458,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, @@ -493,7 +509,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( @@ -508,7 +524,7 @@ function checkIfLocalFileIsAccessible( return Promise.resolve(); } - return readFileAsync(ReceiptStorage.resolve(receiptPath) ?? receiptPath.toString(), receiptFilename, onSuccess, onFailure, receiptType); + return readReceiptFile(receiptFilename, receiptPath, receiptType, onSuccess, onFailure); } function clearReceiptUploadError({ 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/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/libs/prepareRequestPayload/index.native.ts b/src/libs/prepareRequestPayload/index.native.ts index eb613f45289b..4334d3794187 100644 --- a/src/libs/prepareRequestPayload/index.native.ts +++ b/src/libs/prepareRequestPayload/index.native.ts @@ -46,6 +46,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; @@ -55,13 +61,12 @@ const prepareRequestPayload: PrepareRequestPayload = (command, data, initiatedOf return Promise.resolve(); } - const localUri = ReceiptStorage.resolve(source) ?? source; - - return checkFileExistsWithReason(localUri).then(({exists, error}) => { - if (!exists) { + return ReceiptStorage.locate(source).then((localUri) => { + if (!localUri) { const transactionID = typeof data.transactionID === 'string' ? data.transactionID : undefined; - return getReceiptsFolderState().then((receiptsFolder) => { - logReceiptDropped({receiptTraceId, transactionID, command, source, localUri, fileName: name, statError: error, receiptsFolder}); + const resolvedUri = ReceiptStorage.resolve(source) ?? source; + return Promise.all([checkFileExistsWithReason(resolvedUri), getReceiptsFolderState()]).then(([{error}, receiptsFolder]) => { + logReceiptDropped({receiptTraceId, transactionID, command, source, localUri: resolvedUri, fileName: name, statError: error, receiptsFolder}); }); } const receiptFormData = { @@ -73,6 +78,10 @@ const prepareRequestPayload: PrepareRequestPayload = (command, data, initiatedOf formData.append(key, receiptFormData as File); }); } + + if (name) { + return ReceiptStorage.settle(name).then(appendValueAsIs); + } } if (key === 'file' && initiatedOffline) { @@ -95,8 +104,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 01e29aeea8ff..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 @@ -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 usePhotoUpgrade from '@pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade'; 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(); @@ -83,16 +84,16 @@ 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 - // 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. 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}}, @@ -112,6 +113,7 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou }; const {handleCameraInitialized} = useCameraInitTelemetry({cameraPermissionStatus, device, format}); + const {hasPendingPhotoCapture, startPhotoCapture, upgradeReceiptWithPhoto, discardPendingPhoto} = usePhotoUpgrade(); const maybeCancelShutterSpan = () => { if (isMultiScanEnabled) { @@ -171,7 +173,13 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou const path = getReceiptsUploadFolderPath(); - captureReceipt(camera.current, {flash, hasFlash, isPlatformMuted, path, 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}); + + receiptCapture .then((photo: PhotoFile) => { endSpanWithAttributes(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE, { [CONST.TELEMETRY.ATTRIBUTE_PHOTO_WIDTH]: photo.width, @@ -196,6 +204,10 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou type: 'image/jpeg', }; + if (shouldUpgradeToPhoto) { + upgradeReceiptWithPhoto(durableName); + } + onCapture(cameraFile, source); }) .catch((error: string) => { @@ -203,7 +215,12 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou maybeCancelShutterSpan(); showCameraAlert(); Log.warn('Error taking photo', error); + discardPendingPhoto(); }); + + if (shouldUpgradeToPhoto) { + startPhotoCapture(camera.current); + } }; // Wait for camera permission status to render @@ -246,12 +263,17 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou blinkStyle={blinkStyle} isAttachmentPickerActive={isAttachmentPickerActive} didCapturePhoto={didCapturePhoto} + hasPendingPhotoCapture={hasPendingPhotoCapture} onInitialized={handleCameraInitialized} shouldShowFlashButton={canUseMultiScan} cameraPermissionStatus={cameraPermissionStatus} flash={flash} hasFlash={hasFlash} setFlash={setFlash} + // "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/components/Camera/types.ts b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/types.ts index 4bfae6924dfc..44285d9f4d56 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,9 @@ 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; }; 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 fe860d7e9317..101e8a6a6f74 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'; @@ -63,6 +63,9 @@ 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 */ onInitialized?: () => void; @@ -85,6 +88,9 @@ type CameraViewportProps = { setFlash: (updater: (prev: boolean) => boolean) => void; + /** 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; }; @@ -102,6 +108,7 @@ function CameraViewport({ blinkStyle, isAttachmentPickerActive, didCapturePhoto = false, + hasPendingPhotoCapture = false, onInitialized, onLayout, shouldShowFlashButton, @@ -110,6 +117,7 @@ function CameraViewport({ flash, hasFlash, setFlash, + outputOrientation = 'device', children, }: CameraViewportProps) { const theme = useTheme(); @@ -130,8 +138,10 @@ function CameraViewport({ style={styles.flex1} zoom={device.neutralZoom} photo + outputOrientation={outputOrientation} cameraTabIndex={1} - forceInactive={isAttachmentPickerActive || didCapturePhoto} + forceInactive={isAttachmentPickerActive || (didCapturePhoto && !hasPendingPhotoCapture)} + shouldStayActiveWhenBlurred={hasPendingPhotoCapture} onInitialized={onInitialized} onLayout={onLayout} // Use TextureView on Android to fix partially blank images for takeSnapshot() 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/ScanEditReceipt.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/ScanEditReceipt.tsx index 0812375fac80..00c256817ced 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/ScanEditReceipt.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/ScanEditReceipt.tsx @@ -123,6 +123,7 @@ function ScanEditReceipt({report, transactionID, backTo, isEditing}: ScanEditRec onPicked={validateFiles} onAttachmentPickerStatusChange={setIsLoaderVisible} isReplacingReceipt + 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 2e688317087a..da62ad57d6a2 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx @@ -406,6 +406,7 @@ function ScanSkipConfirmation({report, action, iouType, reportID, transactionID, onAttachmentPickerStatusChange={setIsLoaderVisible} onMultiScanSubmit={submitMultiScan} shouldAcceptMultipleFiles + canUpgradeReceiptQuality={false} /> ); diff --git a/src/pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade.ts b/src/pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade.ts new file mode 100644 index 000000000000..a3a389751940 --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade.ts @@ -0,0 +1,316 @@ +/** + * 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'; +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'; +import RNFS from 'react-native-fs'; + +const PHOTO_CAPTURE_TIMEOUT_MS = 3000; + +const ROTATE_TIMEOUT_MS = 5000; + +function discardPhoto(path: string) { + ReceiptStorage.discard(path).catch((error: unknown) => { + Log.warn('[PhotoUpgrade] could not delete the abandoned photo', {error: error instanceof Error ? error.message : String(error)}); + }); +} + +function discardWhenItLands(pending: PendingPhoto) { + pending.promise + .then((photo) => { + if (!photo) { + return; + } + discardPhoto(photo.path); + }) + .catch(() => {}); +} + +class UpgradeStepError extends Error { + step: string; + + hasTimedOut: boolean; + + constructor(step: string, message: string, hasTimedOut: boolean) { + super(`[PhotoUpgrade] ${step} ${message}`); + this.step = step; + this.hasTimedOut = hasTimedOut; + } +} + +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 UpgradeStepError(step, `did not finish within ${capMs}ms`, true)); + }, capMs); + }); + + if (discardLateResult) { + promise + .then((value) => { + if (!hasTimedOut) { + return; + } + discardLateResult(value); + }) + .catch(() => {}); + } + + return Promise.race([promise, deadline]).finally(() => clearTimeout(cap)); +} + +type PendingPhoto = { + promise: Promise; + + discard: () => void; + + startedAt: number; + + getFailureOutcome: () => string; +}; + +function toUpgradeSpanId(durableName: string) { + return `${CONST.TELEMETRY.SPAN_RECEIPT_UPGRADE}_${durableName}`; +} + +function abandonPending(pending: PendingPhoto) { + pending.discard(); + discardWhenItLands(pending); +} + +function usePhotoUpgrade() { + const [hasPendingPhotoCapture, setHasPendingPhotoCapture] = useState(false); + const pendingPhotoRef = useRef(undefined); + + // `hasPendingPhotoCapture` holds the vision-camera session open: closing it mid-capture cancels the + // photo with "Camera is closed." + const captureCountRef = useRef(0); + + const photosInFlightRef = useRef(0); + + useEffect( + () => () => { + const pending = pendingPhotoRef.current; + if (!pending) { + return; + } + pendingPhotoRef.current = undefined; + abandonPending(pending); + }, + [], + ); + + const releaseSession = () => { + captureCountRef.current -= 1; + if (captureCountRef.current > 0) { + return; + } + setHasPendingPhotoCapture(false); + }; + + const startPhotoCapture = (camera: Camera) => { + 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'); + 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; + } + + 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; + + const nativeCapture = camera.takePhoto({flash: 'off', enableShutterSound: false, path: RNFS.TemporaryDirectoryPath}); + + photosInFlightRef.current += 1; + const releasePhotoSlot = () => { + photosInFlightRef.current -= 1; + }; + nativeCapture.then(releasePhotoSlot, releasePhotoSlot); + + const capture = nativeCapture.then( + (photo) => { + if (!isDiscarded) { + return photo; + } + discardPhoto(photo.path); + return undefined; + }, + (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; + }, + ); + + let deadlineTimeout: ReturnType; + const deadline = new Promise((resolve) => { + deadlineTimeout = setTimeout(() => { + isDiscarded = true; + failureOutcome = CONST.TELEMETRY.UPGRADE_OUTCOME.CAPTURE_TIMED_OUT; + resolve(undefined); + }, PHOTO_CAPTURE_TIMEOUT_MS); + }); + + const promise = Promise.race([capture, deadline]).then((photo) => { + clearTimeout(deadlineTimeout); + releaseSession(); + + if (!photo) { + Log.info('[PhotoUpgrade] no photo to upgrade with, keeping the snapshot'); + return undefined; + } + + return photo; + }); + + pendingPhotoRef.current = { + promise, + discard: () => { + isDiscarded = true; + }, + startedAt, + getFailureOutcome: () => failureOutcome, + }; + captureCountRef.current += 1; + setHasPendingPhotoCapture(true); + }; + + const upgradeReceiptWithPhoto = (durableName: string) => { + const pending = pendingPhotoRef.current; + if (!pending) { + Log.info('[PhotoUpgrade] no pending photo for this receipt, so it keeps its snapshot', false, {durableName}); + return; + } + pendingPhotoRef.current = undefined; + + const spanId = toUpgradeSpanId(durableName); + let hasReportedOutcome = false; + const reportOutcome = (outcome: string, attributes: Record = {}) => { + if (hasReportedOutcome) { + return; + } + hasReportedOutcome = true; + endSpanWithAttributes(spanId, {[CONST.TELEMETRY.ATTRIBUTE_UPGRADE_OUTCOME]: outcome, ...attributes}); + }; + 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, + }, + }); + + startUpgrade(durableName); + + pending.promise + .then((photo) => { + if (!photo) { + reportOutcome(pending.getFailureOutcome()); + return undefined; + } + + 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; + } + discardPhoto(latePath); + }).then((uprightPath) => { + if (wasReceiptClaimed(durableName)) { + 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); + } + return undefined; + } + + 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, + }); + reportOutcome(CONST.TELEMETRY.UPGRADE_OUTCOME.UPGRADED, { + [CONST.TELEMETRY.ATTRIBUTE_PHOTO_WIDTH]: photo.width, + [CONST.TELEMETRY.ATTRIBUTE_PHOTO_HEIGHT]: photo.height, + }); + recordUpgrade(durableName); + + if (!uprightPath) { + return; + } + discardPhoto(photo.path); + }) + .catch((error: unknown) => { + if (uprightPath) { + discardPhoto(uprightPath); + } + throw error; + }); + }); + }) + .catch((error: unknown) => { + 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}); + let outcome: string = CONST.TELEMETRY.UPGRADE_OUTCOME.SWAP_FAILED; + if (wasClaimed) { + outcome = CONST.TELEMETRY.UPGRADE_OUTCOME.CLAIMED_FOR_UPLOAD; + } 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(); + discardWhenItLands(pending); + }) + .finally(() => finishUpgrade(durableName)); + }; + + const discardPendingPhoto = () => { + const pending = pendingPhotoRef.current; + if (!pending) { + return; + } + pendingPhotoRef.current = undefined; + abandonPending(pending); + }; + + return {hasPendingPhotoCapture, startPhotoCapture, upgradeReceiptWithPhoto, discardPendingPhoto}; +} + +export default usePhotoUpgrade; diff --git a/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts b/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts new file mode 100644 index 000000000000..6af92dbb4aa8 --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts @@ -0,0 +1,71 @@ +/** + * 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'; + +import type {Orientation} from 'react-native-vision-camera'; + +import {ImageManipulator, SaveFormat} from 'expo-image-manipulator'; +import ImageSize from 'react-native-image-size'; + +type PhotoSize = { + width: number; + height: number; + rotation?: number; +}; + +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; + + // 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; + 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(); + + return ImageSize.getSize(sourceUri).then((imageSize) => { + const angle = getUprightRotation(imageSize, orientation); + if (angle === undefined) { + return undefined; + } + + const context = ImageManipulator.manipulate(sourceUri).rotate(angle); + return context + .renderAsync() + .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, + angle, + exifRotation: imageSize.rotation, + source: `${imageSize.width}x${imageSize.height}`, + }); + return result.uri; + }) + .finally(() => releaseQuietly(context)); + }); +} + +export default rotatePhotoToUpright; +export {getUprightRotation}; diff --git a/src/setup/index.ts b/src/setup/index.ts index 24d54ed5d84d..9aa0407bac80 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'; @@ -110,6 +111,8 @@ export default function () { cleanupPreMountedDraftReports(); + ReceiptStorage.sweepLeftovers(); + // The carousel's sibling list belongs to the screen that seeded it, and that ownership lives in module state // which dies with the JS runtime. A list that survives in storage is therefore orphaned the moment the app // reloads: no mounted screen can refresh or release it, and an expense opened straight from a deeplink would 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(); 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/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/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 new file mode 100644 index 000000000000..0e0335bc08b6 --- /dev/null +++ b/tests/unit/hooks/usePhotoUpgrade.test.ts @@ -0,0 +1,625 @@ +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]>(); +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, shouldAbort?: () => boolean) => mockReplace(durableName, path, shouldAbort), + discard: (path: string) => mockDiscard(path), + }, +})); + +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', () => ({ + __esModule: true, + default: (path: string, orientation?: Orientation) => mockRotate(path, orientation), +})); + +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'; +const PHOTO_CAPTURE_TIMEOUT_MS = 3000; +const ROTATE_TIMEOUT_MS = 5000; + +function buildPhoto(path = PHOTO_PATH): PhotoFile { + // 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. */ +function buildCamera() { + const started: Array<{resolve: (photo: PhotoFile) => void; reject: (error: Error) => void}> = []; + const takePhoto = jest.fn( + () => + new Promise((resolve, reject) => { + started.push({resolve, 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, + landPhoto: async (photo = buildPhoto(), index = 0) => { + await act(async () => { + started.at(index)?.resolve(photo); + }); + }, + failCapture: async (index = 0) => { + await act(async () => { + started.at(index)?.reject(new Error('Camera is closed.')); + }); + }, + }; +} + +describe('usePhotoUpgrade', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + mockReplace.mockResolvedValue(DURABLE_NAME); + mockDiscard.mockResolvedValue(undefined); + mockRotate.mockResolvedValue(UPRIGHT_PATH); + mockWasClaimed.mockReturnValue(false); + mockGetSpan.mockReturnValue({setAttributes: mockSetAttributes}); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('holds the camera session open from the shutter until the photo lands', async () => { + const {camera, landPhoto} = buildCamera(); + const {result} = renderHook(() => usePhotoUpgrade()); + + expect(result.current.hasPendingPhotoCapture).toBe(false); + + act(() => { + result.current.startPhotoCapture(camera); + }); + expect(result.current.hasPendingPhotoCapture).toBe(true); + + await landPhoto(); + expect(result.current.hasPendingPhotoCapture).toBe(false); + }); + + 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()); + + act(() => { + result.current.startPhotoCapture(camera); + }); + act(() => { + result.current.upgradeReceiptWithPhoto(DURABLE_NAME); + }); + await landPhoto(); + + // 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 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()); + + act(() => { + result.current.startPhotoCapture(camera); + }); + act(() => { + result.current.upgradeReceiptWithPhoto(DURABLE_NAME); + }); + await landPhoto(); + + expect(mockReplace).toHaveBeenCalledWith(DURABLE_NAME, PHOTO_PATH, expect.any(Function)); + expect(mockDiscard).not.toHaveBeenCalled(); + }); + + it('keeps the snapshot and releases the session when the photo misses the deadline', async () => { + const {camera, landPhoto} = 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(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 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, the receipt would count as upgrading 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()); + + act(() => { + result.current.startPhotoCapture(camera); + }); + act(() => { + result.current.upgradeReceiptWithPhoto(DURABLE_NAME); + }); + await failCapture(); + + 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. + expect(mockFinishUpgrade).toHaveBeenCalledWith(DURABLE_NAME); + }); + + it('deletes the photo when the receipt it was meant for never arrived', async () => { + const {camera, landPhoto} = buildCamera(); + const {result} = renderHook(() => usePhotoUpgrade()); + + act(() => { + result.current.startPhotoCapture(camera); + }); + act(() => { + result.current.discardPendingPhoto(); + }); + await landPhoto(); + + expect(mockReplace).not.toHaveBeenCalled(); + expect(mockDiscard).toHaveBeenCalledWith(PHOTO_PATH); + }); + + 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()); + + act(() => { + result.current.startPhotoCapture(camera); + }); + act(() => { + result.current.upgradeReceiptWithPhoto(DURABLE_NAME); + }); + + // 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(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); + }); + + 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); + }); + 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('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()); + + 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/hooks/useReceiptUpgradeCount.test.ts b/tests/unit/hooks/useReceiptUpgradeCount.test.ts new file mode 100644 index 000000000000..8067e8867cb3 --- /dev/null +++ b/tests/unit/hooks/useReceiptUpgradeCount.test.ts @@ -0,0 +1,79 @@ +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('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)); + + 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 9e5853d42ff6..c9fab238f83e 100644 --- a/tests/unit/libs/prepareRequestPayloadNativeTest.ts +++ b/tests/unit/libs/prepareRequestPayloadNativeTest.ts @@ -24,13 +24,22 @@ jest.mock('@libs/telemetry/ReceiptObservability', () => ({ })); const RECEIPTS_FOLDER = '/Containers/Data/Application/CURRENT/Documents/Receipts-Upload'; +const mockSettle = jest.fn, [string]>(() => Promise.resolve()); + +const mockResolveReceiptUri = (source?: string) => { + const name = source?.includes('/Receipts-Upload/') ? source.split('/').pop() : undefined; + return name ? `file://${RECEIPTS_FOLDER}/${name}` : source; +}; + jest.mock('@libs/ReceiptStorage', () => ({ __esModule: true, default: { - resolve: (source?: string) => { - const name = source?.includes('/Receipts-Upload/') ? source.split('/').pop() : undefined; - return name ? `file://${RECEIPTS_FOLDER}/${name}` : source; + locate: (source?: string) => { + const uri = mockResolveReceiptUri(source); + return mockCheckFileExists(uri).then(({exists}) => (exists ? uri : undefined)); }, + resolve: (source?: string) => mockResolveReceiptUri(source), + settle: (durableName: string) => mockSettle(durableName), }, })); @@ -149,4 +158,70 @@ describe('prepareRequestPayload (native)', () => { expect(formData.get('amount')).toBe('100'); expect(formData.has('undefinedField')).toBe(false); }); + + 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()); + }); + + /** 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 () => { + // Given a swap that has already committed to renaming the receipt file + let releaseCommittedSwap: () => void = () => {}; + mockSettle.mockReturnValue( + new Promise((resolve) => { + releaseCommittedSwap = resolve; + }), + ); + + // When an old queued ReplaceReceipt request is prepared with only the file object + 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(); + + // 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. + 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 () => { + // Given an old queued ReplaceReceipt request whose receipt has a uri but no source + // When the payload is prepared + await prepareRequestPayload( + 'ReplaceReceipt', + { + transactionID: '1', + receipt: {uri: `file://${RECEIPTS_FOLDER}/CAM-1.jpg`, name: 'CAM-1.jpg', type: 'image/jpeg'}, + }, + 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(); + }); + }); }); diff --git a/tests/unit/libs/receiptReadDuringSwapTest.ts b/tests/unit/libs/receiptReadDuringSwapTest.ts new file mode 100644 index 000000000000..4d3bd2db6207 --- /dev/null +++ b/tests/unit/libs/receiptReadDuringSwapTest.ts @@ -0,0 +1,275 @@ +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([]), + writeFile: () => 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}.receipt-swap-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}.receipt-swap-staged`) { + return hold('staging', stagingStarted); + } + if (from === `${RECEIPT_PATH}.receipt-swap-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 17bd5b431300..9da2fa8fed71 100644 --- a/tests/unit/libs/receiptStorageTest.ts +++ b/tests/unit/libs/receiptStorageTest.ts @@ -1,17 +1,32 @@ +import fileURIToPath from '@libs/fileURIToPath'; 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]>(); +const mockReadDir = jest.fn>, [string]>(); +const mockWriteFile = jest.fn, [string, string, 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), + writeFile: (path: string, contents: string, encoding: string) => mockWriteFile(path, contents, encoding), })); 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), + checkFileExistsWithReason: (path: string | undefined) => mockCheckFileExists(path).then((exists) => ({exists})), +})); + const FOLDER = '/var/mobile/Containers/Data/Application/AAAA-1111/Documents/Receipts-Upload'; jest.mock('@libs/getReceiptsUploadFolderPath', () => ({ __esModule: true, @@ -20,6 +35,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'); const {default: WebReceiptStorage}: {default: ReceiptStorageType} = jest.requireActual('@libs/ReceiptStorage/index.ts'); describe('ReceiptStorage', () => { @@ -28,6 +48,10 @@ describe('ReceiptStorage', () => { mockExists.mockResolvedValue(true); mockMv.mockResolvedValue(undefined); mockMkdir.mockResolvedValue(undefined); + mockUnlink.mockResolvedValue(undefined); + mockCheckFileExists.mockResolvedValue(true); + mockReadDir.mockResolvedValue([]); + mockWriteFile.mockResolvedValue(undefined); }); describe('adopt', () => { @@ -74,6 +98,814 @@ describe('ReceiptStorage', () => { }); }); + describe('overwrite', () => { + 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.overwrite(RECEIPT, `file://${STILL}`); + + expect(name).toBe(RECEIPT); + expect(mockMv.mock.calls).toEqual([ + [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}.receipt-swap-backup`)); + + await ReceiptStorage.overwrite(RECEIPT, STILL); + + 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}.receipt-swap-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.overwrite(RECEIPT, STILL)).rejects.toThrow('no space left on device'); + + 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 () => { + 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.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}.receipt-swap-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.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 () => { + 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); + existing.add(to); + return Promise.resolve(); + }); + + 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}.receipt-swap-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); + + 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}.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); + }); + + 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}.receipt-swap-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}.receipt-swap-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'; + 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 { + jest.resetModules(); + const {default: storage}: {default: ReceiptStorageType} = jest.requireActual('@libs/ReceiptStorage/index.native.ts'); + return storage; + } + + 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}]); + + // 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); + expect(mockReadDir).toHaveBeenCalledWith(PENDING_SWAPS); + if (stall) { + clearTimeout(stall); + } + }); + + 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 = 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'}]); + + // 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}`); + 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); + + 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(() => { + hasListed = true; + return Promise.resolve([]); + }); + mockCheckFileExists.mockResolvedValue(true); + + // When `locate` runs inside the request the sequential queue is processing + await expect(storage.locate(`file://${FOLDER}/${RECEIPT}`)).resolves.toBe(`file://${FOLDER}/${RECEIPT}`); + + // 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 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); + + // When the startup sweep runs + await runStartupSweep(storage); + + // Then it lists nothing, since `readDir` on a missing folder rejects and logs on every launch + expect(mockReadDir).not.toHaveBeenCalled(); + }); + + 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(); + + // When the startup sweep runs + await runStartupSweep(storage); + + // 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('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 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(); + }); + + // 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); + + // 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('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(); + 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(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); + }); + }); + + 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(); + }); + + 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}.receipt-swap-backup`)); + + await expect(ReceiptStorage.locate(RECEIPT_URI)).resolves.toBe(RECEIPT_URI); + 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 = () => {}; + const stagingStarted = new Promise((resolve) => { + mockMv.mockImplementation((from: string, to: string) => { + if (to === `${RECEIPT_PATH}.receipt-swap-staged`) { + resolve(); + return new Promise((settle) => { + finishStaging = settle; + }); + } + existing.delete(from); + existing.add(to); + 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}.receipt-swap-backup`) { + resolve(); + return new Promise((settle) => { + finishRename = settle; + }); + } + existing.delete(from); + existing.add(to); + 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 renameStarted; + + let hasLocated = false; + const located = ReceiptStorage.locate(RECEIPT_URI).then((uri) => { + hasLocated = true; + return uri; + }); + await flushPendingWork(); + + // 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); + + 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 = () => {}; + const swapReachedItsGap = new Promise((resolve) => { + mockMv.mockImplementation((from: string, to: string) => { + existing.delete(from); + existing.add(to); + 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}.receipt-swap-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.overwrite('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}.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}.receipt-swap-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); + + 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('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}.receipt-swap-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}.receipt-swap-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}.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}.receipt-swap-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'); + + 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/libs/receiptUpgradesTest.ts b/tests/unit/libs/receiptUpgradesTest.ts new file mode 100644 index 000000000000..2321d2585c06 --- /dev/null +++ b/tests/unit/libs/receiptUpgradesTest.ts @@ -0,0 +1,110 @@ +import {claimForRead, finish, getUpgradeCount, isClaimedForRead, recordUpgrade, start, subscribe} from '@libs/ReceiptStorage/receiptUpgrades'; + +const RECEIPT = 'receipt_1234.jpg'; + +describe('receiptUpgrades', () => { + afterEach(() => { + finish(RECEIPT); + jest.useRealTimers(); + }); + + it('reports nothing in flight for a receipt that is not being upgraded', () => { + expect(isClaimedForRead(RECEIPT)).toBe(false); + }); + + it('claims a receipt without waiting, so an upload never queues behind a capture', () => { + start(RECEIPT); + expect(isClaimedForRead(RECEIPT)).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); + + expect(isClaimedForRead(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('keeps a claim for the rest of the upgrade, so a second reader cannot hand the file back', () => { + start(RECEIPT); + claimForRead(RECEIPT); + claimForRead(RECEIPT); + + expect(isClaimedForRead(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', () => { + 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', () => { + start(RECEIPT); + claimForRead(RECEIPT); + // A second start must not reset the claim the first upgrade already handed out. + start(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); + }); + + 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 new file mode 100644 index 000000000000..6b36f4beadce --- /dev/null +++ b/tests/unit/rotatePhotoToUprightTest.ts @@ -0,0 +1,50 @@ +import {getUprightRotation} from '@pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright'; + +describe('getUprightRotation', () => { + 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', () => { + expect(getUprightRotation({width: 1920, height: 1440, rotation: 0})).toBe(90); + expect(getUprightRotation({width: 1920, height: 1440, rotation: 180})).toBe(90); + }); + + 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('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); + }); + + it('leaves a square photo with no metadata alone', () => { + expect(getUprightRotation({width: 1440, height: 1440, rotation: 0})).toBeUndefined(); + }); +});