From 9564ca379350b9a70e26d087dbd01671ca3e081c Mon Sep 17 00:00:00 2001 From: Nicolas Bonet Date: Mon, 20 Jul 2026 17:05:09 -0500 Subject: [PATCH 01/11] Stop exposing app data to the iOS Files app Disable UIFileSharingEnabled and LSSupportsOpeningDocumentsInPlace and move internal files out of the user-visible Documents directory: - OnyxDB moves to Library/Application Support via a react-native-nitro-sqlite patch that also migrates existing database files - The attachment cache moves to Library/Caches - Queued receipt uploads move to Library/Application Support - Non-media downloads now go through the share sheet ("Save to Files") instead of being written to Documents, with a startup migration cleaning up files left behind by older versions Co-Authored-By: Claude Fable 5 --- ios/NewExpensify/Info.plist | 4 -- patches/react-native-nitro-sqlite+9.6.0.patch | 59 ++++++++++++++++ src/libs/actions/Attachment/index.native.ts | 4 +- src/libs/fileDownload/index.ios.ts | 46 ++++++++---- .../getReceiptsUploadFolderPath/index.ios.ts | 5 +- src/libs/migrateOnyx.ts | 3 +- .../MoveFilesOutOfDocuments/index.ios.ts | 70 +++++++++++++++++++ .../MoveFilesOutOfDocuments/index.ts | 5 ++ 8 files changed, 176 insertions(+), 20 deletions(-) create mode 100644 patches/react-native-nitro-sqlite+9.6.0.patch create mode 100644 src/libs/migrations/MoveFilesOutOfDocuments/index.ios.ts create mode 100644 src/libs/migrations/MoveFilesOutOfDocuments/index.ts diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 558c7ce01774..75e2956387f0 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -60,8 +60,6 @@ LSRequiresIPhoneOS - LSSupportsOpeningDocumentsInPlace - NSAppTransportSecurity NSAllowsArbitraryLoads @@ -116,8 +114,6 @@ fetch processing - UIFileSharingEnabled - UILaunchStoryboardName BootSplash UIRequiredDeviceCapabilities diff --git a/patches/react-native-nitro-sqlite+9.6.0.patch b/patches/react-native-nitro-sqlite+9.6.0.patch new file mode 100644 index 000000000000..ec70f626a216 --- /dev/null +++ b/patches/react-native-nitro-sqlite+9.6.0.patch @@ -0,0 +1,59 @@ +diff --git a/node_modules/react-native-nitro-sqlite/ios/OnLoad.mm b/node_modules/react-native-nitro-sqlite/ios/OnLoad.mm +index 6ce7258..38ea210 100644 +--- a/node_modules/react-native-nitro-sqlite/ios/OnLoad.mm ++++ b/node_modules/react-native-nitro-sqlite/ios/OnLoad.mm +@@ -10,6 +10,33 @@ @implementation OnLoad + using namespace margelo::nitro; + using namespace margelo::nitro::rnnitrosqlite; + ++// The Documents directory can be exposed to the user (Files app) when file sharing ++// is enabled, so databases are stored in Library/Application Support instead. ++// Databases created by older app versions are moved out of Documents on first launch. ++static void migrateDatabaseFiles(NSString *fromDirectory, NSString *toDirectory) { ++ NSFileManager *fileManager = [NSFileManager defaultManager]; ++ NSArray *files = [fileManager contentsOfDirectoryAtPath:fromDirectory error:nil]; ++ ++ for (NSString *file in files) { ++ // Covers the database itself plus its -wal/-shm journal files ++ if (![file hasPrefix:@"OnyxDB"]) { ++ continue; ++ } ++ ++ NSString *sourcePath = [fromDirectory stringByAppendingPathComponent:file]; ++ NSString *destinationPath = [toDirectory stringByAppendingPathComponent:file]; ++ ++ if ([fileManager fileExistsAtPath:destinationPath]) { ++ continue; ++ } ++ ++ NSError *error = nil; ++ if (![fileManager moveItemAtPath:sourcePath toPath:destinationPath error:&error]) { ++ NSLog(@"Failed to migrate database file %@: %@", file, error.localizedDescription); ++ } ++ } ++} ++ + + (void)load { + // Get appGroupID value from Info.plist using key "AppGroup" + NSString *appGroupID = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"RNNitroSQLite_AppGroup"]; +@@ -30,9 +57,18 @@ + (void)load { + + documentPath = [storeUrl path]; + } else { +- // Get iOS app's document directory (to safely store database .sqlite3 file) +- NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, true); ++ // Store databases in Library/Application Support, which is persistent, backed up, ++ // and never exposed to the user via the Files app (unlike the Documents directory) ++ NSArray *paths = NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, true); + documentPath = [paths objectAtIndex:0]; ++ ++ NSFileManager *fileManager = [NSFileManager defaultManager]; ++ if (![fileManager fileExistsAtPath:documentPath]) { ++ [fileManager createDirectoryAtPath:documentPath withIntermediateDirectories:YES attributes:nil error:nil]; ++ } ++ ++ NSArray *documentPaths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, true); ++ migrateDatabaseFiles([documentPaths objectAtIndex:0], documentPath); + } + + HybridNitroSQLite::docPath = [documentPath UTF8String]; diff --git a/src/libs/actions/Attachment/index.native.ts b/src/libs/actions/Attachment/index.native.ts index dd6cb92c7559..8702893368c4 100644 --- a/src/libs/actions/Attachment/index.native.ts +++ b/src/libs/actions/Attachment/index.native.ts @@ -10,7 +10,9 @@ import Onyx from 'react-native-onyx'; import type {CacheAttachmentProps, GetCachedAttachmentProps, RemoveCachedAttachmentProps} from './types'; -const ATTACHMENT_DIR = `${RNFS.DocumentDirectoryPath}/attachments`; +// Cached attachments are re-downloadable, so they live in Caches, which the OS may purge +// and which is never exposed to the user via the iOS Files app (unlike Documents) +const ATTACHMENT_DIR = `${RNFS.CachesDirectoryPath}/attachments`; async function cacheAttachment({attachmentID, uri, mimeType}: CacheAttachmentProps) { const isLocalFile = uri.startsWith('file://'); diff --git a/src/libs/fileDownload/index.ios.ts b/src/libs/fileDownload/index.ios.ts index 45577c54ba31..3c5a689bd862 100644 --- a/src/libs/fileDownload/index.ios.ts +++ b/src/libs/fileDownload/index.ios.ts @@ -30,26 +30,30 @@ const isUserCancelled = (err: unknown) => { }; /** - * Downloads the file to Documents section in iOS + * Downloads the file to the app's cache directory. The cache directory is not exposed + * to the user, so files meant for the user must be handed off via the share sheet afterwards. */ function downloadFile(fileUrl: string, fileName: string) { const dirs = RNFetchBlob.fs.dirs; - // The iOS files will download to documents directory - const path = dirs.DocumentDir; + const path = dirs.CacheDir; // Fetching the attachment return RNFetchBlob.config({ fileCache: true, path: `${path}/${fileName}`, - addAndroidDownloads: { - useDownloadManager: true, - notification: true, - path: `${path}/Expensify/${fileName}`, - }, }).fetch('GET', fileUrl); } +/** + * Presents the iOS share sheet so the user can save the file to the Files app, + * then removes the local copy. The app sandbox is not browsable by the user, + * so this hand-off is the only way a downloaded file reaches them. + */ +function shareFileToFilesApp(localPath: string) { + return Share.open({url: localPath, failOnCancel: false, saveToFiles: true}).then(() => RNFS.unlink(localPath)); +} + const postDownloadFile = (translate: LocalizedTranslate, url: string, fileName?: string, formData?: FormData, onDownloadFailed?: () => void, appendTimestamp = true) => { const fetchOptions: RequestInit = { method: 'POST', @@ -70,12 +74,10 @@ const postDownloadFile = (translate: LocalizedTranslate, url: string, fileName?: .then((fileData) => { const resolvedFileName = fileName ?? 'Expensify'; const finalFileName = appendTimestamp ? appendTimeToFileName(resolvedFileName) : resolvedFileName; - const expensifyDir = `${RNFS.DocumentDirectoryPath}/Expensify`; + const expensifyDir = `${RNFS.CachesDirectoryPath}/Expensify`; const localPath = `${expensifyDir}/${finalFileName}`; return RNFS.mkdir(expensifyDir).then(() => { - return RNFS.writeFile(localPath, fileData, 'utf8') - .then(() => Share.open({url: localPath, failOnCancel: false, saveToFiles: true})) - .then(() => RNFS.unlink(localPath)); + return RNFS.writeFile(localPath, fileData, 'utf8').then(() => shareFileToFilesApp(localPath)); }); }) .catch((error) => { @@ -152,7 +154,25 @@ const fileDownload: FileDownload = (translate, fileUrl, fileName, successMessage break; } - fileDownloadPromise = downloadFile(fileUrl, attachmentName); + // The downloaded file lives in the app cache, which the user cannot browse, + // so hand it off through the share sheet ("Save to Files"). The share sheet + // provides its own confirmation, so we resolve without a success alert. + fileDownloadPromise = downloadFile(fileUrl, attachmentName) + .then((attachment) => { + const localPath = attachment.path(); + if (!localPath) { + throw new Error('Error downloading file'); + } + return shareFileToFilesApp(localPath); + }) + .then(() => undefined) + .catch((err: unknown) => { + // If the user cancels the iOS share/save dialog, we exit silently without showing an error + if (isUserCancelled(err)) { + return undefined; + } + throw err; + }); break; } diff --git a/src/libs/getReceiptsUploadFolderPath/index.ios.ts b/src/libs/getReceiptsUploadFolderPath/index.ios.ts index 7eeb484d5a02..ea0adcfd9bbc 100644 --- a/src/libs/getReceiptsUploadFolderPath/index.ios.ts +++ b/src/libs/getReceiptsUploadFolderPath/index.ios.ts @@ -4,6 +4,9 @@ import RNFetchBlob from 'react-native-blob-util'; import type GetReceiptsUploadFolderPath from './types'; -const getReceiptsUploadFolderPath: GetReceiptsUploadFolderPath = () => `${RNFetchBlob.fs.dirs.DocumentDir}${CONST.RECEIPTS_UPLOAD_PATH}`; +// Queued receipts must survive until upload, so they live in Library/Application Support: +// it is persistent (unlike Caches, which the OS may purge) and never exposed to the user +// via the iOS Files app (unlike Documents) +const getReceiptsUploadFolderPath: GetReceiptsUploadFolderPath = () => `${RNFetchBlob.fs.dirs.LibraryDir}/Application Support${CONST.RECEIPTS_UPLOAD_PATH}`; export default getReceiptsUploadFolderPath; diff --git a/src/libs/migrateOnyx.ts b/src/libs/migrateOnyx.ts index 21c828349228..c19112f0c855 100644 --- a/src/libs/migrateOnyx.ts +++ b/src/libs/migrateOnyx.ts @@ -2,6 +2,7 @@ import CONST from '@src/CONST'; import Log from './Log'; import ConvertGpsPointsTo2DArray from './migrations/ConvertGpsPointsTo2DArray'; +import MoveFilesOutOfDocuments from './migrations/MoveFilesOutOfDocuments'; import {endSpan, getSpan, startSpan} from './telemetry/activeSpans'; export default function () { @@ -16,7 +17,7 @@ export default function () { }); // Add all migrations to an array so they are executed in order - const migrationPromises: Array<() => Promise> = [ConvertGpsPointsTo2DArray]; + const migrationPromises: Array<() => Promise> = [ConvertGpsPointsTo2DArray, MoveFilesOutOfDocuments]; // Reduce all promises down to a single promise. All promises run in a linear fashion, waiting for the // previous promise to finish before moving onto the next one. diff --git a/src/libs/migrations/MoveFilesOutOfDocuments/index.ios.ts b/src/libs/migrations/MoveFilesOutOfDocuments/index.ios.ts new file mode 100644 index 000000000000..380cc9b39cac --- /dev/null +++ b/src/libs/migrations/MoveFilesOutOfDocuments/index.ios.ts @@ -0,0 +1,70 @@ +import getReceiptsUploadFolderPath from '@libs/getReceiptsUploadFolderPath'; +import Log from '@libs/Log'; + +import ONYXKEYS from '@src/ONYXKEYS'; + +import RNFS from 'react-native-fs'; +import Onyx from 'react-native-onyx'; + +const OLD_ATTACHMENT_DIR = `${RNFS.DocumentDirectoryPath}/attachments`; +const OLD_RECEIPTS_UPLOAD_DIR = `${RNFS.DocumentDirectoryPath}/Receipts-Upload`; + +/** + * The attachment cache now lives in Library/Caches. The old copies in Documents are + * deleted rather than moved because cached attachments re-download on demand, and the + * Onyx attachment collection is cleared since its sources point at the old directory. + */ +function migrateAttachmentCache(): Promise { + return RNFS.exists(OLD_ATTACHMENT_DIR).then((exists) => { + if (!exists) { + return; + } + return RNFS.unlink(OLD_ATTACHMENT_DIR) + .then(() => Onyx.setCollection(ONYXKEYS.COLLECTION.ATTACHMENT, {})) + .then(() => { + Log.info('[Migrate Onyx] MoveFilesOutOfDocuments removed the old attachment cache'); + }); + }); +} + +/** + * Receipts queued for upload now live in Library/Application Support. Existing queued + * receipts are moved so pending uploads keep working after the app updates. + */ +function migrateQueuedReceipts(): Promise { + return RNFS.exists(OLD_RECEIPTS_UPLOAD_DIR).then((exists) => { + if (!exists) { + return; + } + const uploadFolder = getReceiptsUploadFolderPath(); + return RNFS.mkdir(uploadFolder) + .then(() => RNFS.readDir(OLD_RECEIPTS_UPLOAD_DIR)) + .then((files) => + Promise.all( + files.map((file) => + RNFS.moveFile(file.path, `${uploadFolder}/${file.name}`).catch((error) => { + Log.warn('[Migrate Onyx] MoveFilesOutOfDocuments failed to move a queued receipt', {error: error instanceof Error ? error.message : String(error)}); + }), + ), + ), + ) + .then(() => RNFS.unlink(OLD_RECEIPTS_UPLOAD_DIR)) + .then(() => { + Log.info('[Migrate Onyx] MoveFilesOutOfDocuments moved queued receipts'); + }); + }); +} + +/** + * Internal app files used to live in the Documents directory, which iOS exposes to the + * user (and other apps) through the Files app when file sharing is enabled. This moves + * them into locations that are never user-visible. + */ +export default function (): Promise { + return Promise.all([migrateAttachmentCache(), migrateQueuedReceipts()]) + .then(() => undefined) + .catch((error) => { + // A failed cleanup must never block app startup; new files already go to the new locations + Log.warn('[Migrate Onyx] MoveFilesOutOfDocuments failed', {error: error instanceof Error ? error.message : String(error)}); + }); +} diff --git a/src/libs/migrations/MoveFilesOutOfDocuments/index.ts b/src/libs/migrations/MoveFilesOutOfDocuments/index.ts new file mode 100644 index 000000000000..79cd559856e7 --- /dev/null +++ b/src/libs/migrations/MoveFilesOutOfDocuments/index.ts @@ -0,0 +1,5 @@ +// This migration only applies to iOS, where internal files previously lived in the +// user-visible Documents directory. On other platforms it is a no-op. +export default function (): Promise { + return Promise.resolve(); +} From c30cf1ccdbfc6f2e53a5a60764f0787addd84664 Mon Sep 17 00:00:00 2001 From: Nicolas Bonet Date: Mon, 20 Jul 2026 17:19:07 -0500 Subject: [PATCH 02/11] Fix Jest mocks for new file storage locations The global react-native-fs mock lacked the functions and directory constants the MoveFilesOutOfDocuments migration uses, which crashed app-boot test suites, and AttachmentTest still expected the attachment cache under the documents directory. The migration also guards against synchronous throws so a file-system error can never block startup. Co-Authored-By: Claude Fable 5 --- jest/setup.ts | 9 ++++++++- .../MoveFilesOutOfDocuments/index.ios.ts | 13 ++++++++----- tests/actions/AttachmentTest.ts | 17 +++++++++-------- 3 files changed, 25 insertions(+), 14 deletions(-) diff --git a/jest/setup.ts b/jest/setup.ts index 72256c7efa57..a544725ff1c5 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -121,7 +121,14 @@ jest.mock('react-native-fs', () => ({ res([]); }), ), - CachesDirectoryPath: jest.fn(), + exists: jest.fn(() => Promise.resolve(false)), + mkdir: jest.fn(() => Promise.resolve()), + moveFile: jest.fn(() => Promise.resolve()), + copyFile: jest.fn(() => Promise.resolve()), + writeFile: jest.fn(() => Promise.resolve()), + DocumentDirectoryPath: '/mock/documents', + CachesDirectoryPath: '/mock/caches', + LibraryDirectoryPath: '/mock/library', })); jest.mock('react-native-share', () => ({ diff --git a/src/libs/migrations/MoveFilesOutOfDocuments/index.ios.ts b/src/libs/migrations/MoveFilesOutOfDocuments/index.ios.ts index 380cc9b39cac..1eb8eab0825c 100644 --- a/src/libs/migrations/MoveFilesOutOfDocuments/index.ios.ts +++ b/src/libs/migrations/MoveFilesOutOfDocuments/index.ios.ts @@ -61,10 +61,13 @@ function migrateQueuedReceipts(): Promise { * them into locations that are never user-visible. */ export default function (): Promise { - return Promise.all([migrateAttachmentCache(), migrateQueuedReceipts()]) - .then(() => undefined) - .catch((error) => { + return ( + Promise.resolve() + .then(() => Promise.all([migrateAttachmentCache(), migrateQueuedReceipts()])) + .then(() => undefined) // A failed cleanup must never block app startup; new files already go to the new locations - Log.warn('[Migrate Onyx] MoveFilesOutOfDocuments failed', {error: error instanceof Error ? error.message : String(error)}); - }); + .catch((error) => { + Log.warn('[Migrate Onyx] MoveFilesOutOfDocuments failed', {error: error instanceof Error ? error.message : String(error)}); + }) + ); } diff --git a/tests/actions/AttachmentTest.ts b/tests/actions/AttachmentTest.ts index 39445c7c51f3..2c0dc33b72d0 100644 --- a/tests/actions/AttachmentTest.ts +++ b/tests/actions/AttachmentTest.ts @@ -16,6 +16,7 @@ import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; jest.mock('react-native-fs', () => ({ DocumentDirectoryPath: '/mock/documents', + CachesDirectoryPath: '/mock/caches', copyFile: jest.fn(() => Promise.resolve()), exists: jest.fn(() => Promise.resolve(true)), unlink: jest.fn(() => Promise.resolve()), @@ -104,7 +105,7 @@ describe('AttachmentStorage', () => { expect(attachmentID).toBeDefined(); expect(attachment).toEqual({ attachmentID, - source: `/mock/documents/attachments/${attachmentID}.jpg`, + source: `/mock/caches/attachments/${attachmentID}.jpg`, }); }); it('should cache markdown attachment', async () => { @@ -144,7 +145,7 @@ describe('AttachmentStorage', () => { expect(attachmentID).toBeDefined(); expect(attachment).toEqual({ attachmentID, - source: `/mock/documents/attachments/${attachmentID}.jpg`, + source: `/mock/caches/attachments/${attachmentID}.jpg`, remoteSource: sourceURL, }); }); @@ -188,7 +189,7 @@ describe('AttachmentStorage', () => { expect(attachmentID).toBeDefined(); expect(attachment).toEqual({ attachmentID, - source: `/mock/documents/attachments/${attachmentID}.jpg`, + source: `/mock/caches/attachments/${attachmentID}.jpg`, remoteSource: sourceURL, }); @@ -207,7 +208,7 @@ describe('AttachmentStorage', () => { // Then the attachment should be updated with new attachment link expect(newAttachment).toEqual({ attachmentID, - source: `/mock/documents/attachments/${attachmentID}.jpg`, + source: `/mock/caches/attachments/${attachmentID}.jpg`, remoteSource: newSourceURL, }); }); @@ -260,7 +261,7 @@ describe('AttachmentStorage', () => { expect(attachmentID).toBeDefined(); expect(newAttachment).toEqual({ attachmentID, - source: `/mock/documents/attachments/${attachmentID}.jpg`, + source: `/mock/caches/attachments/${attachmentID}.jpg`, remoteSource: fileData.uri, }); @@ -323,7 +324,7 @@ describe('AttachmentStorage', () => { expect(attachmentID).toBeDefined(); expect(newAttachment).toEqual({ attachmentID, - source: `/mock/documents/attachments/${attachmentID}.jpg`, + source: `/mock/caches/attachments/${attachmentID}.jpg`, remoteSource: sourceURL, }); @@ -409,14 +410,14 @@ describe('AttachmentStorage', () => { expect(remoteSourceIndex).toBeDefined(); expect(attachment).toEqual({ attachmentID, - source: `/mock/documents/attachments/${attachmentID}.jpg`, + source: `/mock/caches/attachments/${attachmentID}.jpg`, remoteSource: markdownAttachments.at(remoteSourceIndex), }); continue; } expect(attachment).toEqual({ attachmentID, - source: `/mock/documents/attachments/${attachmentID}.jpg`, + source: `/mock/caches/attachments/${attachmentID}.jpg`, }); } From bdbf8f3b541e670cefa6bea19c3d74b81bfb64cb Mon Sep 17 00:00:00 2001 From: Nicolas Bonet Date: Tue, 21 Jul 2026 09:51:10 -0500 Subject: [PATCH 03/11] Move nitro-sqlite patch to subdirectory and document it Co-Authored-By: Claude Fable 5 --- patches/react-native-nitro-sqlite/details.md | 16 ++++++++++++++++ ...0+001+store-database-outside-documents.patch} | 0 2 files changed, 16 insertions(+) create mode 100644 patches/react-native-nitro-sqlite/details.md rename patches/{react-native-nitro-sqlite+9.6.0.patch => react-native-nitro-sqlite/react-native-nitro-sqlite+9.6.0+001+store-database-outside-documents.patch} (100%) diff --git a/patches/react-native-nitro-sqlite/details.md b/patches/react-native-nitro-sqlite/details.md new file mode 100644 index 000000000000..20edcc53cb3e --- /dev/null +++ b/patches/react-native-nitro-sqlite/details.md @@ -0,0 +1,16 @@ +# `react-native-nitro-sqlite` patches + +### [react-native-nitro-sqlite+9.6.0+001+store-database-outside-documents.patch](react-native-nitro-sqlite+9.6.0+001+store-database-outside-documents.patch) + +- Reason: + + ``` + The library stores SQLite databases in the iOS Documents directory, which is exposed to users + via the Files app when file sharing is enabled. This patch stores databases in + Library/Application Support instead (persistent, backed up, never user-visible) and migrates + databases created by older app versions out of Documents on first launch. + ``` + +- Upstream PR/issue: 🛑 TODO +- E/App issue: 🛑 TODO +- PR introducing patch: https://github.com/Expensify/App/pull/96531 diff --git a/patches/react-native-nitro-sqlite+9.6.0.patch b/patches/react-native-nitro-sqlite/react-native-nitro-sqlite+9.6.0+001+store-database-outside-documents.patch similarity index 100% rename from patches/react-native-nitro-sqlite+9.6.0.patch rename to patches/react-native-nitro-sqlite/react-native-nitro-sqlite+9.6.0+001+store-database-outside-documents.patch From 065abaf07dfe389b9073cc795a4bcc92411e892f Mon Sep 17 00:00:00 2001 From: Nicolas Bonet Date: Tue, 21 Jul 2026 09:59:42 -0500 Subject: [PATCH 04/11] Link upstream and tracking issues in nitro-sqlite patch details Co-Authored-By: Claude Fable 5 --- patches/react-native-nitro-sqlite/details.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/patches/react-native-nitro-sqlite/details.md b/patches/react-native-nitro-sqlite/details.md index 20edcc53cb3e..95fa8458365e 100644 --- a/patches/react-native-nitro-sqlite/details.md +++ b/patches/react-native-nitro-sqlite/details.md @@ -11,6 +11,6 @@ databases created by older app versions out of Documents on first launch. ``` -- Upstream PR/issue: 🛑 TODO -- E/App issue: 🛑 TODO +- Upstream PR/issue: https://github.com/margelo/react-native-nitro-sqlite/issues/289 +- E/App issue: https://github.com/Expensify/App/issues/96649 - PR introducing patch: https://github.com/Expensify/App/pull/96531 From 9e663963346b350c4de33f9c24c2ad3061d59c2f Mon Sep 17 00:00:00 2001 From: Nicolas Bonet Date: Tue, 21 Jul 2026 14:54:22 -0500 Subject: [PATCH 05/11] Bump Mobile-Expensify to restore documentsDirectory declaration Co-Authored-By: Claude Fable 5 --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index beddf6913284..402304001400 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit beddf6913284a6878588e5c6b1386194eac69b1a +Subproject commit 4023040014001a7648cc480e94cda712476ca909 From 68ac36d78491ba6a7d8e890852a69739a7c7d07c Mon Sep 17 00:00:00 2001 From: Nicolas Bonet Date: Tue, 21 Jul 2026 16:23:15 -0500 Subject: [PATCH 06/11] Preserve queued receipts and rewrite their persisted paths in the iOS migration The migration now copies queued receipts instead of moving them, rewrites the receipt paths persisted in queued requests and transactions to the new upload folder, and only deletes the originals once the rewrite has landed. Receipts that fail to copy keep their only copy in the old directory, which is then preserved, and their persisted paths are refreshed to the current container path so the queued upload can still recover. Co-Authored-By: Claude Fable 5 --- .../MoveFilesOutOfDocuments/index.ios.ts | 189 +++++++++++++++++- tests/unit/MoveFilesOutOfDocumentsTest.ts | 171 ++++++++++++++++ 2 files changed, 351 insertions(+), 9 deletions(-) create mode 100644 tests/unit/MoveFilesOutOfDocumentsTest.ts diff --git a/src/libs/migrations/MoveFilesOutOfDocuments/index.ios.ts b/src/libs/migrations/MoveFilesOutOfDocuments/index.ios.ts index 1eb8eab0825c..803e7199f661 100644 --- a/src/libs/migrations/MoveFilesOutOfDocuments/index.ios.ts +++ b/src/libs/migrations/MoveFilesOutOfDocuments/index.ios.ts @@ -1,13 +1,25 @@ import getReceiptsUploadFolderPath from '@libs/getReceiptsUploadFolderPath'; import Log from '@libs/Log'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {Transaction} from '@src/types/onyx'; + +import type {OnyxCollection} from 'react-native-onyx'; import RNFS from 'react-native-fs'; import Onyx from 'react-native-onyx'; const OLD_ATTACHMENT_DIR = `${RNFS.DocumentDirectoryPath}/attachments`; -const OLD_RECEIPTS_UPLOAD_DIR = `${RNFS.DocumentDirectoryPath}/Receipts-Upload`; +const OLD_RECEIPTS_UPLOAD_DIR = `${RNFS.DocumentDirectoryPath}${CONST.RECEIPTS_UPLOAD_PATH}`; + +// Persisted receipt paths are matched by this suffix instead of the full old directory path +// because iOS moves the app container (changing its absolute path) on every app update. +const OLD_RECEIPTS_PATH_MARKER = `/Documents${CONST.RECEIPTS_UPLOAD_PATH}/`; + +type RewriteReceiptPath = (value: string) => string | null; + +type DeepRewriteResult = {result: T; changed: boolean}; /** * The attachment cache now lives in Library/Caches. The old copies in Documents are @@ -27,9 +39,148 @@ function migrateAttachmentCache(): Promise { }); } +/** + * Builds the rewriter used to fix persisted receipt references. Values pointing at a copied + * receipt are rewritten to the new upload folder. Values pointing at a receipt that could + * not be copied are rewritten to the old directory under the current container path, since + * the container path persisted before the update no longer exists. Returns null when the + * value does not reference a known receipt or is already correct. + */ +function buildReceiptPathRewriter(uploadFolder: string, copiedFileNames: Set, failedFileNames: Set): RewriteReceiptPath { + return (value) => { + const markerIndex = value.indexOf(OLD_RECEIPTS_PATH_MARKER); + if (markerIndex === -1) { + return null; + } + const fileName = value.slice(markerIndex + OLD_RECEIPTS_PATH_MARKER.length); + if (!fileName || fileName.includes('/')) { + return null; + } + let newFolder: string; + if (copiedFileNames.has(fileName)) { + newFolder = uploadFolder; + } else if (failedFileNames.has(fileName)) { + newFolder = OLD_RECEIPTS_UPLOAD_DIR; + } else { + return null; + } + const rewritten = `${value.startsWith('file://') ? 'file://' : ''}${newFolder}/${fileName}`; + return rewritten === value ? null : rewritten; + }; +} + +/** + * Recursively rewrites every string in a value, returning the original object untouched + * when nothing changed so callers can skip unnecessary Onyx writes. + */ +function deepRewriteStrings(value: T, rewrite: RewriteReceiptPath): DeepRewriteResult { + if (typeof value === 'string') { + const rewritten = rewrite(value); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- T is a string here, so the rewritten string is a valid T + return rewritten === null ? {result: value, changed: false} : {result: rewritten as T, changed: true}; + } + if (Array.isArray(value)) { + let changed = false; + const result = value.map((item: unknown) => { + const child = deepRewriteStrings(item, rewrite); + changed = changed || child.changed; + return child.result; + }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the rewritten array has the same shape as the original T + return changed ? {result: result as T, changed: true} : {result: value, changed: false}; + } + if (value !== null && typeof value === 'object') { + let changed = false; + const result: Record = {}; + for (const [key, item] of Object.entries(value)) { + const child = deepRewriteStrings(item, rewrite); + changed = changed || child.changed; + result[key] = child.result; + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the rewritten object has the same shape as the original T + return changed ? {result: result as T, changed: true} : {result: value, changed: false}; + } + return {result: value, changed: false}; +} + +/** + * Queued requests persist the receipt's absolute path in their data (and in their optimistic + * Onyx updates), and the native payload preparation drops the receipt when that path no + * longer exists. Every persisted reference is rewritten to where the file actually is now. + */ +function rewritePersistedRequests(key: typeof ONYXKEYS.PERSISTED_REQUESTS | typeof ONYXKEYS.PERSISTED_ONGOING_REQUESTS, rewrite: RewriteReceiptPath): Promise { + return new Promise((resolve) => { + const connection = Onyx.connectWithoutView({ + key, + callback: (requests) => { + Onyx.disconnect(connection); + if (!requests) { + return resolve(); + } + const {result, changed} = deepRewriteStrings(requests, rewrite); + if (!changed) { + return resolve(); + } + // No need to add a new action just for this migration + // eslint-disable-next-line rulesdir/prefer-actions-set-data + Onyx.set(key, result).then(() => resolve()); + }, + }); + }); +} + +/** + * Offline-created transactions keep the receipt's local path in receipt.source until the + * upload completes, so those references are rewritten to where the file actually is now. + */ +function rewriteTransactionReceipts(collectionKey: typeof ONYXKEYS.COLLECTION.TRANSACTION | typeof ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, rewrite: RewriteReceiptPath): Promise { + return new Promise((resolve) => { + const connection = Onyx.connectWithoutView({ + key: collectionKey, + waitForCollectionCallback: true, + callback: (transactions: OnyxCollection) => { + Onyx.disconnect(connection); + const updatePromises: Array> = []; + for (const [transactionKey, transaction] of Object.entries(transactions ?? {})) { + const source = transaction?.receipt?.source; + if (typeof source !== 'string') { + continue; + } + const rewritten = rewrite(source); + if (rewritten === null) { + continue; + } + // No need to add a new action just for this migration + // eslint-disable-next-line rulesdir/prefer-actions-set-data, @typescript-eslint/no-unsafe-type-assertion -- keys from a collection callback always carry the collection prefix + updatePromises.push(Onyx.merge(transactionKey as `${typeof collectionKey}${string}`, {receipt: {source: rewritten}}).then(() => undefined)); + } + Promise.all(updatePromises).then(() => resolve()); + }, + }); + }); +} + +function updatePersistedReceiptPaths(uploadFolder: string, copiedFileNames: Set, failedFileNames: Set): Promise { + if (copiedFileNames.size === 0 && failedFileNames.size === 0) { + return Promise.resolve(); + } + const rewrite = buildReceiptPathRewriter(uploadFolder, copiedFileNames, failedFileNames); + return Promise.all([ + rewritePersistedRequests(ONYXKEYS.PERSISTED_REQUESTS, rewrite), + rewritePersistedRequests(ONYXKEYS.PERSISTED_ONGOING_REQUESTS, rewrite), + rewriteTransactionReceipts(ONYXKEYS.COLLECTION.TRANSACTION, rewrite), + rewriteTransactionReceipts(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, rewrite), + ]).then(() => undefined); +} + /** * Receipts queued for upload now live in Library/Application Support. Existing queued - * receipts are moved so pending uploads keep working after the app updates. + * receipts are moved so pending uploads keep working after the app updates, and the + * paths persisted in queued requests and transactions are rewritten to match. + * + * The files are copied first and the originals deleted only after the persisted paths are + * rewritten, because the request queue can start replaying while this migration runs: the + * old path must stay valid until the rewrite lands, or the replay drops the receipt. */ function migrateQueuedReceipts(): Promise { return RNFS.exists(OLD_RECEIPTS_UPLOAD_DIR).then((exists) => { @@ -37,20 +188,40 @@ function migrateQueuedReceipts(): Promise { return; } const uploadFolder = getReceiptsUploadFolderPath(); + const copiedFileNames = new Set(); + const failedFileNames = new Set(); return RNFS.mkdir(uploadFolder) .then(() => RNFS.readDir(OLD_RECEIPTS_UPLOAD_DIR)) .then((files) => Promise.all( - files.map((file) => - RNFS.moveFile(file.path, `${uploadFolder}/${file.name}`).catch((error) => { - Log.warn('[Migrate Onyx] MoveFilesOutOfDocuments failed to move a queued receipt', {error: error instanceof Error ? error.message : String(error)}); - }), - ), + files.map((file) => { + const destination = `${uploadFolder}/${file.name}`; + // Remove any partial copy left by an interrupted earlier run; copyFile fails when the destination exists + return RNFS.unlink(destination) + .catch(() => {}) + .then(() => RNFS.copyFile(file.path, destination)) + .then(() => { + copiedFileNames.add(file.name); + }) + .catch((error) => { + failedFileNames.add(file.name); + Log.warn('[Migrate Onyx] MoveFilesOutOfDocuments failed to copy a queued receipt', {error: error instanceof Error ? error.message : String(error)}); + }); + }), ), ) - .then(() => RNFS.unlink(OLD_RECEIPTS_UPLOAD_DIR)) + .then(() => updatePersistedReceiptPaths(uploadFolder, copiedFileNames, failedFileNames)) + .then(() => { + if (failedFileNames.size === 0) { + return RNFS.unlink(OLD_RECEIPTS_UPLOAD_DIR).then(() => undefined); + } + // A receipt that failed to copy has its only copy in the old directory, so only + // the successfully copied originals are removed and the directory is kept. + Log.warn('[Migrate Onyx] MoveFilesOutOfDocuments kept the old receipts folder because some receipts could not be copied', {failedCopyCount: failedFileNames.size}); + return Promise.all([...copiedFileNames].map((fileName) => RNFS.unlink(`${OLD_RECEIPTS_UPLOAD_DIR}/${fileName}`).catch(() => {}))).then(() => undefined); + }) .then(() => { - Log.info('[Migrate Onyx] MoveFilesOutOfDocuments moved queued receipts'); + Log.info('[Migrate Onyx] MoveFilesOutOfDocuments moved queued receipts', false, {movedFileCount: copiedFileNames.size}); }); }); } diff --git a/tests/unit/MoveFilesOutOfDocumentsTest.ts b/tests/unit/MoveFilesOutOfDocumentsTest.ts new file mode 100644 index 000000000000..527e3f2d490d --- /dev/null +++ b/tests/unit/MoveFilesOutOfDocumentsTest.ts @@ -0,0 +1,171 @@ +import MoveFilesOutOfDocuments from '@libs/migrations/MoveFilesOutOfDocuments/index.ios'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import type {AnyRequest} from '@src/types/onyx'; + +import Onyx from 'react-native-onyx'; + +import getOnyxValue from '../utils/getOnyxValue'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +// Importing the real Log module pulls in the network stack, whose persisted-request +// bookkeeping reacts to the Onyx writes made in these tests. +jest.mock('@libs/Log', () => ({ + __esModule: true, + default: {info: jest.fn(), warn: jest.fn(), alert: jest.fn()}, +})); + +jest.mock('react-native-fs', () => ({ + DocumentDirectoryPath: '/mock/documents', + exists: jest.fn(() => Promise.resolve(false)), + mkdir: jest.fn(() => Promise.resolve()), + readDir: jest.fn(() => Promise.resolve([])), + copyFile: jest.fn(() => Promise.resolve()), + unlink: jest.fn(() => Promise.resolve()), +})); + +const NEW_UPLOAD_FOLDER = '/mock/library/Application Support/Receipts-Upload'; + +jest.mock('@libs/getReceiptsUploadFolderPath', () => ({ + __esModule: true, + default: jest.fn(() => NEW_UPLOAD_FOLDER), +})); + +const mockRNFS: { + exists: jest.Mock; + mkdir: jest.Mock; + readDir: jest.Mock; + copyFile: jest.Mock; + unlink: jest.Mock; +} = jest.requireMock('react-native-fs'); + +const OLD_RECEIPTS_DIR = '/mock/documents/Receipts-Upload'; +const OLD_ATTACHMENT_DIR = '/mock/documents/attachments'; + +// The container path persisted before the app update differs from the current one because +// iOS moves the app container on every update. +const STALE_CONTAINER_RECEIPTS_DIR = '/mock/old-container/Documents/Receipts-Upload'; + +const RECEIPT_A = 'receipt_a.jpg'; +const RECEIPT_B = 'receipt_b.jpg'; +const STALE_URI_A = `file://${STALE_CONTAINER_RECEIPTS_DIR}/${RECEIPT_A}`; +const STALE_URI_B = `file://${STALE_CONTAINER_RECEIPTS_DIR}/${RECEIPT_B}`; +const NEW_URI_A = `file://${NEW_UPLOAD_FOLDER}/${RECEIPT_A}`; +const NEW_URI_B = `file://${NEW_UPLOAD_FOLDER}/${RECEIPT_B}`; +const SERVER_RECEIPT_URL = 'https://www.expensify.com/receipts/w_abc.jpg'; + +function buildQueuedRequest(fileName: string, uri: string): AnyRequest { + return { + command: 'RequestMoney', + data: { + transactionID: '123', + receipt: {source: uri, uri, name: fileName, type: 'image/jpeg'}, + }, + optimisticData: [ + { + onyxMethod: 'merge', + key: `${ONYXKEYS.COLLECTION.TRANSACTION}123`, + value: {receipt: {source: uri}}, + }, + ], + }; +} + +describe('MoveFilesOutOfDocuments migration (iOS)', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + mockRNFS.exists.mockImplementation((path: string) => Promise.resolve(path === OLD_RECEIPTS_DIR)); + mockRNFS.mkdir.mockImplementation(() => Promise.resolve()); + mockRNFS.readDir.mockImplementation(() => + Promise.resolve([ + {name: RECEIPT_A, path: `${OLD_RECEIPTS_DIR}/${RECEIPT_A}`}, + {name: RECEIPT_B, path: `${OLD_RECEIPTS_DIR}/${RECEIPT_B}`}, + ]), + ); + mockRNFS.copyFile.mockImplementation(() => Promise.resolve()); + mockRNFS.unlink.mockImplementation(() => Promise.resolve()); + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + it('does nothing when the old directories do not exist', async () => { + mockRNFS.exists.mockImplementation(() => Promise.resolve(false)); + + await MoveFilesOutOfDocuments(); + + expect(mockRNFS.copyFile).not.toHaveBeenCalled(); + expect(mockRNFS.unlink).not.toHaveBeenCalled(); + }); + + it('removes the old attachment cache and clears the attachment collection', async () => { + mockRNFS.exists.mockImplementation((path: string) => Promise.resolve(path === OLD_ATTACHMENT_DIR)); + await Onyx.set(`${ONYXKEYS.COLLECTION.ATTACHMENT}source1`, {source: `${OLD_ATTACHMENT_DIR}/file.pdf`}); + await waitForBatchedUpdates(); + + await MoveFilesOutOfDocuments(); + await waitForBatchedUpdates(); + + expect(mockRNFS.unlink).toHaveBeenCalledWith(OLD_ATTACHMENT_DIR); + const attachment = await getOnyxValue(`${ONYXKEYS.COLLECTION.ATTACHMENT}source1`); + expect(attachment).toBeUndefined(); + }); + + it('copies queued receipts and rewrites persisted paths before removing the old directory', async () => { + await Onyx.set(ONYXKEYS.PERSISTED_REQUESTS, [buildQueuedRequest(RECEIPT_A, STALE_URI_A)]); + await Onyx.set(ONYXKEYS.PERSISTED_ONGOING_REQUESTS, buildQueuedRequest(RECEIPT_B, STALE_URI_B)); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}123`, {transactionID: '123', receipt: {source: STALE_URI_A}}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}999`, {transactionID: '999', receipt: {source: SERVER_RECEIPT_URL}}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}456`, {transactionID: '456', receipt: {source: STALE_URI_B}}); + await waitForBatchedUpdates(); + + await MoveFilesOutOfDocuments(); + await waitForBatchedUpdates(); + + expect(mockRNFS.copyFile).toHaveBeenCalledWith(`${OLD_RECEIPTS_DIR}/${RECEIPT_A}`, `${NEW_UPLOAD_FOLDER}/${RECEIPT_A}`); + expect(mockRNFS.copyFile).toHaveBeenCalledWith(`${OLD_RECEIPTS_DIR}/${RECEIPT_B}`, `${NEW_UPLOAD_FOLDER}/${RECEIPT_B}`); + expect(mockRNFS.unlink).toHaveBeenCalledWith(OLD_RECEIPTS_DIR); + + // The persisted request's receipt source/uri and its optimistic transaction data + // are all rewritten to the new upload folder. + const persistedRequests = await getOnyxValue(ONYXKEYS.PERSISTED_REQUESTS); + expect(persistedRequests).toEqual([buildQueuedRequest(RECEIPT_A, NEW_URI_A)]); + + const ongoingRequest = await getOnyxValue(ONYXKEYS.PERSISTED_ONGOING_REQUESTS); + expect(ongoingRequest).toEqual(buildQueuedRequest(RECEIPT_B, NEW_URI_B)); + + const transaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}123`); + expect(transaction?.receipt?.source).toBe(NEW_URI_A); + + // A receipt that was already uploaded points at the server and is left untouched + const uploadedTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}999`); + expect(uploadedTransaction?.receipt?.source).toBe(SERVER_RECEIPT_URL); + + const draftTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}456`); + expect(draftTransaction?.receipt?.source).toBe(NEW_URI_B); + }); + + it('keeps the old directory and points persisted paths at it when a copy fails', async () => { + mockRNFS.copyFile.mockImplementation((source: string) => (source.endsWith(RECEIPT_B) ? Promise.reject(new Error('copy failed')) : Promise.resolve())); + await Onyx.set(ONYXKEYS.PERSISTED_REQUESTS, [buildQueuedRequest(RECEIPT_A, STALE_URI_A), buildQueuedRequest(RECEIPT_B, STALE_URI_B)]); + await waitForBatchedUpdates(); + + await MoveFilesOutOfDocuments(); + await waitForBatchedUpdates(); + + // The directory keeps the only remaining copy of the failed receipt; only the + // successfully copied original is removed. + expect(mockRNFS.unlink).not.toHaveBeenCalledWith(OLD_RECEIPTS_DIR); + expect(mockRNFS.unlink).toHaveBeenCalledWith(`${OLD_RECEIPTS_DIR}/${RECEIPT_A}`); + expect(mockRNFS.unlink).not.toHaveBeenCalledWith(`${OLD_RECEIPTS_DIR}/${RECEIPT_B}`); + + // The copied receipt points at the new folder, and the failed one is refreshed to the + // old directory under the current container path, since the stale container path no + // longer exists. + const persistedRequests = await getOnyxValue(ONYXKEYS.PERSISTED_REQUESTS); + expect(persistedRequests).toEqual([buildQueuedRequest(RECEIPT_A, NEW_URI_A), buildQueuedRequest(RECEIPT_B, `file://${OLD_RECEIPTS_DIR}/${RECEIPT_B}`)]); + }); +}); From bb1ce6b9b3889b452c1a7d25d5be88e9261d6fdd Mon Sep 17 00:00:00 2001 From: Nicolas Bonet Date: Wed, 22 Jul 2026 15:19:59 -0500 Subject: [PATCH 07/11] Rewrite odometer image paths in the Documents migration The receipts migration only rewrote transaction receipt.source, but odometer images live in the same Receipts-Upload folder and are referenced from transaction comments (odometerStartImage/odometerEndImage), mergeTransaction entries, and the standalone odometer draft. Rewrite those references too so in-progress odometer flows survive the move out of Documents. Co-Authored-By: Claude Fable 5 --- .../MoveFilesOutOfDocuments/index.ios.ts | 133 ++++++++++++++++-- tests/unit/MoveFilesOutOfDocumentsTest.ts | 41 ++++++ 2 files changed, 164 insertions(+), 10 deletions(-) diff --git a/src/libs/migrations/MoveFilesOutOfDocuments/index.ios.ts b/src/libs/migrations/MoveFilesOutOfDocuments/index.ios.ts index 803e7199f661..299ae7adaad3 100644 --- a/src/libs/migrations/MoveFilesOutOfDocuments/index.ios.ts +++ b/src/libs/migrations/MoveFilesOutOfDocuments/index.ios.ts @@ -3,7 +3,7 @@ import Log from '@libs/Log'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Transaction} from '@src/types/onyx'; +import type {MergeTransaction, OdometerDraft, Transaction} from '@src/types/onyx'; import type {OnyxCollection} from 'react-native-onyx'; @@ -21,6 +21,12 @@ type RewriteReceiptPath = (value: string) => string | null; type DeepRewriteResult = {result: T; changed: boolean}; +type OdometerImageKey = 'odometerStartImage' | 'odometerEndImage'; + +type OdometerImagesUpdate = Partial>; + +const ODOMETER_IMAGE_KEYS: OdometerImageKey[] = ['odometerStartImage', 'odometerEndImage']; + /** * The attachment cache now lives in Library/Caches. The old copies in Documents are * deleted rather than moved because cached attachments re-download on demand, and the @@ -130,10 +136,45 @@ function rewritePersistedRequests(key: typeof ONYXKEYS.PERSISTED_REQUESTS | type } /** - * Offline-created transactions keep the receipt's local path in receipt.source until the - * upload completes, so those references are rewritten to where the file actually is now. + * Rewrites a persisted file reference, which is either a plain path/URI string or a file + * object carrying the path in its uri field. Returns a merge-ready replacement (the new + * string, or a partial object updating only the uri), or null when the value does not + * reference a moved receipt. + */ +function rewriteFileReference(value: unknown, rewrite: RewriteReceiptPath): string | {uri: string} | null { + if (typeof value === 'string') { + return rewrite(value); + } + if (value !== null && typeof value === 'object' && 'uri' in value && typeof value.uri === 'string') { + const rewritten = rewrite(value.uri); + return rewritten === null ? null : {uri: rewritten}; + } + return null; +} + +/** + * Builds the merge update fixing the odometer image references held by a container + * (a transaction comment, a merge transaction, or the odometer draft). Returns null + * when neither image references a moved receipt. */ -function rewriteTransactionReceipts(collectionKey: typeof ONYXKEYS.COLLECTION.TRANSACTION | typeof ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, rewrite: RewriteReceiptPath): Promise { +function buildOdometerImagesUpdate(container: Partial> | undefined, rewrite: RewriteReceiptPath): OdometerImagesUpdate | null { + let update: OdometerImagesUpdate | null = null; + for (const imageKey of ODOMETER_IMAGE_KEYS) { + const rewritten = rewriteFileReference(container?.[imageKey], rewrite); + if (rewritten === null) { + continue; + } + update = {...(update ?? {}), [imageKey]: rewritten}; + } + return update; +} + +/** + * Offline-created transactions keep local file paths until the upload completes: the + * receipt in receipt.source and the odometer images on the comment. Those references + * are rewritten to where the files actually are now. + */ +function rewriteTransactionPaths(collectionKey: typeof ONYXKEYS.COLLECTION.TRANSACTION | typeof ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, rewrite: RewriteReceiptPath): Promise { return new Promise((resolve) => { const connection = Onyx.connectWithoutView({ key: collectionKey, @@ -143,16 +184,52 @@ function rewriteTransactionReceipts(collectionKey: typeof ONYXKEYS.COLLECTION.TR const updatePromises: Array> = []; for (const [transactionKey, transaction] of Object.entries(transactions ?? {})) { const source = transaction?.receipt?.source; - if (typeof source !== 'string') { + const rewrittenSource = typeof source === 'string' ? rewrite(source) : null; + const commentUpdate = buildOdometerImagesUpdate(transaction?.comment, rewrite); + if (rewrittenSource === null && commentUpdate === null) { continue; } - const rewritten = rewrite(source); - if (rewritten === null) { + const update = { + ...(rewrittenSource === null ? {} : {receipt: {source: rewrittenSource}}), + ...(commentUpdate === null ? {} : {comment: commentUpdate}), + }; + // No need to add a new action just for this migration + // eslint-disable-next-line rulesdir/prefer-actions-set-data, @typescript-eslint/no-unsafe-type-assertion -- keys from a collection callback always carry the collection prefix + updatePromises.push(Onyx.merge(transactionKey as `${typeof collectionKey}${string}`, update).then(() => undefined)); + } + Promise.all(updatePromises).then(() => resolve()); + }, + }); + }); +} + +/** + * Merge-expense drafts persist the receipt and the odometer images at the top level of + * each mergeTransaction entry, so those references are rewritten to where the files + * actually are now. + */ +function rewriteMergeTransactions(rewrite: RewriteReceiptPath): Promise { + return new Promise((resolve) => { + const connection = Onyx.connectWithoutView({ + key: ONYXKEYS.COLLECTION.MERGE_TRANSACTION, + waitForCollectionCallback: true, + callback: (mergeTransactions: OnyxCollection) => { + Onyx.disconnect(connection); + const updatePromises: Array> = []; + for (const [mergeTransactionKey, mergeTransaction] of Object.entries(mergeTransactions ?? {})) { + const source = mergeTransaction?.receipt?.source; + const rewrittenSource = typeof source === 'string' ? rewrite(source) : null; + const imagesUpdate = buildOdometerImagesUpdate(mergeTransaction ?? undefined, rewrite); + if (rewrittenSource === null && imagesUpdate === null) { continue; } + const update = { + ...(rewrittenSource === null ? {} : {receipt: {source: rewrittenSource}}), + ...imagesUpdate, + }; // No need to add a new action just for this migration // eslint-disable-next-line rulesdir/prefer-actions-set-data, @typescript-eslint/no-unsafe-type-assertion -- keys from a collection callback always carry the collection prefix - updatePromises.push(Onyx.merge(transactionKey as `${typeof collectionKey}${string}`, {receipt: {source: rewritten}}).then(() => undefined)); + updatePromises.push(Onyx.merge(mergeTransactionKey as `${typeof ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${string}`, update).then(() => undefined)); } Promise.all(updatePromises).then(() => resolve()); }, @@ -160,6 +237,40 @@ function rewriteTransactionReceipts(collectionKey: typeof ONYXKEYS.COLLECTION.TR }); } +/** + * The standalone odometer draft (the "save for later" flow) stores each image as a plain + * file URI string on native, so those references are rewritten to where the files + * actually are now. + */ +function rewriteOdometerDraft(rewrite: RewriteReceiptPath): Promise { + return new Promise((resolve) => { + const connection = Onyx.connectWithoutView({ + key: ONYXKEYS.ODOMETER_DRAFT, + callback: (draft) => { + Onyx.disconnect(connection); + const update: Partial = {}; + for (const imageKey of ODOMETER_IMAGE_KEYS) { + const value = draft?.[imageKey]; + if (typeof value !== 'string') { + continue; + } + const rewritten = rewrite(value); + if (rewritten === null) { + continue; + } + update[imageKey] = rewritten; + } + if (Object.keys(update).length === 0) { + return resolve(); + } + // No need to add a new action just for this migration + // eslint-disable-next-line rulesdir/prefer-actions-set-data + Onyx.merge(ONYXKEYS.ODOMETER_DRAFT, update).then(() => resolve()); + }, + }); + }); +} + function updatePersistedReceiptPaths(uploadFolder: string, copiedFileNames: Set, failedFileNames: Set): Promise { if (copiedFileNames.size === 0 && failedFileNames.size === 0) { return Promise.resolve(); @@ -168,8 +279,10 @@ function updatePersistedReceiptPaths(uploadFolder: string, copiedFileNames: Set< return Promise.all([ rewritePersistedRequests(ONYXKEYS.PERSISTED_REQUESTS, rewrite), rewritePersistedRequests(ONYXKEYS.PERSISTED_ONGOING_REQUESTS, rewrite), - rewriteTransactionReceipts(ONYXKEYS.COLLECTION.TRANSACTION, rewrite), - rewriteTransactionReceipts(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, rewrite), + rewriteTransactionPaths(ONYXKEYS.COLLECTION.TRANSACTION, rewrite), + rewriteTransactionPaths(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, rewrite), + rewriteMergeTransactions(rewrite), + rewriteOdometerDraft(rewrite), ]).then(() => undefined); } diff --git a/tests/unit/MoveFilesOutOfDocumentsTest.ts b/tests/unit/MoveFilesOutOfDocumentsTest.ts index 527e3f2d490d..f27874b0de6e 100644 --- a/tests/unit/MoveFilesOutOfDocumentsTest.ts +++ b/tests/unit/MoveFilesOutOfDocumentsTest.ts @@ -148,6 +148,47 @@ describe('MoveFilesOutOfDocuments migration (iOS)', () => { expect(draftTransaction?.receipt?.source).toBe(NEW_URI_B); }); + it('rewrites odometer image references on transactions, merge transactions, and the odometer draft', async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}123`, { + transactionID: '123', + comment: { + odometerStartImage: {uri: STALE_URI_A, name: RECEIPT_A, type: 'image/jpeg'}, + odometerEndImage: STALE_URI_B, + }, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}456`, { + transactionID: '456', + comment: {odometerStartImage: {uri: STALE_URI_A, name: RECEIPT_A, type: 'image/jpeg'}}, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}789`, { + receipt: {source: STALE_URI_A}, + odometerEndImage: {uri: STALE_URI_B, name: RECEIPT_B, type: 'image/jpeg'}, + }); + await Onyx.set(ONYXKEYS.ODOMETER_DRAFT, {odometerStartImage: STALE_URI_A, odometerEndImage: SERVER_RECEIPT_URL}); + await waitForBatchedUpdates(); + + await MoveFilesOutOfDocuments(); + await waitForBatchedUpdates(); + + // Odometer images on the transaction comment are rewritten whether they are stored + // as a file object (only the uri changes) or as a plain URI string. + const transaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}123`); + expect(transaction?.comment?.odometerStartImage).toEqual({uri: NEW_URI_A, name: RECEIPT_A, type: 'image/jpeg'}); + expect(transaction?.comment?.odometerEndImage).toBe(NEW_URI_B); + + const draftTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}456`); + expect(draftTransaction?.comment?.odometerStartImage).toEqual({uri: NEW_URI_A, name: RECEIPT_A, type: 'image/jpeg'}); + + const mergeTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}789`); + expect(mergeTransaction?.receipt?.source).toBe(NEW_URI_A); + expect(mergeTransaction?.odometerEndImage).toEqual({uri: NEW_URI_B, name: RECEIPT_B, type: 'image/jpeg'}); + + // The rewritten draft image points at the new folder; a non-receipt value is untouched + const odometerDraft = await getOnyxValue(ONYXKEYS.ODOMETER_DRAFT); + expect(odometerDraft?.odometerStartImage).toBe(NEW_URI_A); + expect(odometerDraft?.odometerEndImage).toBe(SERVER_RECEIPT_URL); + }); + it('keeps the old directory and points persisted paths at it when a copy fails', async () => { mockRNFS.copyFile.mockImplementation((source: string) => (source.endsWith(RECEIPT_B) ? Promise.reject(new Error('copy failed')) : Promise.resolve())); await Onyx.set(ONYXKEYS.PERSISTED_REQUESTS, [buildQueuedRequest(RECEIPT_A, STALE_URI_A), buildQueuedRequest(RECEIPT_B, STALE_URI_B)]); From aa78425d7156d4abc91b827ef290cc0be5594bb2 Mon Sep 17 00:00:00 2001 From: Nicolas Bonet Date: Thu, 23 Jul 2026 15:53:21 -0500 Subject: [PATCH 08/11] Keep Documents visible in the Files app for user downloads Hiding the Documents directory entirely would make files users previously downloaded through the app unreachable. Instead, the Info.plist file-sharing keys stay and downloads keep saving to Documents, while everything internal moves to directories the Files app never shows: temporary share-sheet and video files go to Caches, the Onyx state dump goes to Caches and a stale copy in Documents is removed by the startup migration. Co-Authored-By: Claude Fable 5 --- ios/NewExpensify/Info.plist | 4 ++ src/libs/ExportOnyxState/index.native.ts | 5 +- src/libs/fileDownload/index.ios.ts | 63 +++++++++---------- src/libs/localFileCreate/index.native.ts | 5 +- .../MoveFilesOutOfDocuments/index.ios.ts | 23 ++++++- 5 files changed, 61 insertions(+), 39 deletions(-) diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 75e2956387f0..558c7ce01774 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -60,6 +60,8 @@ LSRequiresIPhoneOS + LSSupportsOpeningDocumentsInPlace + NSAppTransportSecurity NSAllowsArbitraryLoads @@ -114,6 +116,8 @@ fetch processing + UIFileSharingEnabled + UILaunchStoryboardName BootSplash UIRequiredDeviceCapabilities diff --git a/src/libs/ExportOnyxState/index.native.ts b/src/libs/ExportOnyxState/index.native.ts index 74a6eef80571..967b6c05b956 100644 --- a/src/libs/ExportOnyxState/index.native.ts +++ b/src/libs/ExportOnyxState/index.native.ts @@ -40,7 +40,10 @@ const shareAsFile: ShareAsFile = (fileContent) => { try { // Define new filename and path for the app info file const infoFileName = CONST.DEFAULT_ONYX_DUMP_FILE_NAME; - const infoFilePath = `${RNFS.DocumentDirectoryPath}/${infoFileName}`; + // The dump only needs to live long enough to be shared, so it goes in Caches, which + // is never exposed to the user (unlike Documents, which the iOS Files app shows when + // file sharing is enabled) and which the OS can reclaim afterwards + const infoFilePath = `${RNFS.CachesDirectoryPath}/${infoFileName}`; const actualInfoFile = `file://${infoFilePath}`; RNFS.writeFile(infoFilePath, fileContent, 'utf8').then(() => { diff --git a/src/libs/fileDownload/index.ios.ts b/src/libs/fileDownload/index.ios.ts index 3c5a689bd862..0a0f38155461 100644 --- a/src/libs/fileDownload/index.ios.ts +++ b/src/libs/fileDownload/index.ios.ts @@ -30,25 +30,36 @@ const isUserCancelled = (err: unknown) => { }; /** - * Downloads the file to the app's cache directory. The cache directory is not exposed - * to the user, so files meant for the user must be handed off via the share sheet afterwards. + * Downloads the file to the Documents directory, which the iOS Files app shows to the user + * as the app's folder because file sharing is enabled. Only files the user asked to download + * belong there; internal files must go to a directory the Files app does not expose. */ function downloadFile(fileUrl: string, fileName: string) { const dirs = RNFetchBlob.fs.dirs; - const path = dirs.CacheDir; + return RNFetchBlob.config({ + fileCache: true, + path: `${dirs.DocumentDir}/${fileName}`, + }).fetch('GET', fileUrl); +} + +/** + * Downloads the file to the cache directory, for flows that only need a temporary local + * copy (e.g. saving to Photos or handing off to the share sheet). Unlike Documents, the + * cache directory is never shown to the user in the iOS Files app. + */ +function downloadFileToCache(fileUrl: string, fileName: string) { + const dirs = RNFetchBlob.fs.dirs; - // Fetching the attachment return RNFetchBlob.config({ fileCache: true, - path: `${path}/${fileName}`, + path: `${dirs.CacheDir}/${fileName}`, }).fetch('GET', fileUrl); } /** * Presents the iOS share sheet so the user can save the file to the Files app, - * then removes the local copy. The app sandbox is not browsable by the user, - * so this hand-off is the only way a downloaded file reaches them. + * then removes the local copy. */ function shareFileToFilesApp(localPath: string) { return Share.open({url: localPath, failOnCancel: false, saveToFiles: true}).then(() => RNFS.unlink(localPath)); @@ -74,6 +85,8 @@ const postDownloadFile = (translate: LocalizedTranslate, url: string, fileName?: .then((fileData) => { const resolvedFileName = fileName ?? 'Expensify'; const finalFileName = appendTimestamp ? appendTimeToFileName(resolvedFileName) : resolvedFileName; + // The file only exists to be handed to the share sheet, so it is written to the + // cache directory, which the iOS Files app never shows to the user const expensifyDir = `${RNFS.CachesDirectoryPath}/Expensify`; const localPath = `${expensifyDir}/${finalFileName}`; return RNFS.mkdir(expensifyDir).then(() => { @@ -104,24 +117,24 @@ function downloadImage(fileUrl: string) { */ function downloadVideo(fileUrl: string, fileName: string): Promise { return new Promise((resolve, reject) => { - let documentPathUri: string | null = null; + let tempPathUri: string | null = null; let cameraRollAsset: PhotoIdentifier; - // Because CameraRoll doesn't allow direct downloads of video with remote URIs, we first download as documents, then copy to photo lib and unlink the original file. - downloadFile(fileUrl, fileName) + // Because CameraRoll doesn't allow direct downloads of video with remote URIs, we first download to the cache, then copy to photo lib and unlink the temporary file. + downloadFileToCache(fileUrl, fileName) .then((attachment) => { - documentPathUri = attachment.data as string | null; - if (!documentPathUri) { + tempPathUri = attachment.data as string | null; + if (!tempPathUri) { throw new Error('Error downloading video'); } - return CameraRoll.saveAsset(documentPathUri); + return CameraRoll.saveAsset(tempPathUri); }) .then((attachment) => { cameraRollAsset = attachment; - if (!documentPathUri) { + if (!tempPathUri) { throw new Error('Error downloading video'); } - return RNFetchBlob.fs.unlink(documentPathUri); + return RNFetchBlob.fs.unlink(tempPathUri); }) .then(() => { resolve(cameraRollAsset); @@ -154,25 +167,7 @@ const fileDownload: FileDownload = (translate, fileUrl, fileName, successMessage break; } - // The downloaded file lives in the app cache, which the user cannot browse, - // so hand it off through the share sheet ("Save to Files"). The share sheet - // provides its own confirmation, so we resolve without a success alert. - fileDownloadPromise = downloadFile(fileUrl, attachmentName) - .then((attachment) => { - const localPath = attachment.path(); - if (!localPath) { - throw new Error('Error downloading file'); - } - return shareFileToFilesApp(localPath); - }) - .then(() => undefined) - .catch((err: unknown) => { - // If the user cancels the iOS share/save dialog, we exit silently without showing an error - if (isUserCancelled(err)) { - return undefined; - } - throw err; - }); + fileDownloadPromise = downloadFile(fileUrl, attachmentName); break; } diff --git a/src/libs/localFileCreate/index.native.ts b/src/libs/localFileCreate/index.native.ts index b1a8c893e500..a568874b3c2f 100644 --- a/src/libs/localFileCreate/index.native.ts +++ b/src/libs/localFileCreate/index.native.ts @@ -14,7 +14,10 @@ const localFileCreate: LocalFileCreate = (fileName, textContent, appendTimestamp const {fileExtension} = splitExtensionFromFileName(fileName); const fileNameWithExtension = fileExtension ? fileName : `${fileName}.txt`; const newFileName = appendTimestamp ? appendTimeToFileName(fileNameWithExtension) : fileNameWithExtension; - const dir = RNFetchBlob.fs.dirs.DocumentDir; + // These files are temporary hand-offs to a share/copy flow that deletes them afterwards, + // so they belong in the cache directory, which is never exposed to the user (unlike + // Documents, which the iOS Files app shows when file sharing is enabled) + const dir = RNFetchBlob.fs.dirs.CacheDir; const path = `${dir}/${newFileName}`; return RNFetchBlob.fs.writeFile(path, textContent, 'utf8').then(() => RNFetchBlob.fs.stat(path).then(({size}) => ({path, newFileName, size}))); diff --git a/src/libs/migrations/MoveFilesOutOfDocuments/index.ios.ts b/src/libs/migrations/MoveFilesOutOfDocuments/index.ios.ts index 299ae7adaad3..62c3ff5efa09 100644 --- a/src/libs/migrations/MoveFilesOutOfDocuments/index.ios.ts +++ b/src/libs/migrations/MoveFilesOutOfDocuments/index.ios.ts @@ -339,15 +339,32 @@ function migrateQueuedReceipts(): Promise { }); } +/** + * Onyx state dumps were previously written to Documents and never deleted after sharing, + * so a stale dump may still sit there. It is an internal debug file, so it is removed. + */ +function removeStaleOnyxDump(): Promise { + const dumpPath = `${RNFS.DocumentDirectoryPath}/${CONST.DEFAULT_ONYX_DUMP_FILE_NAME}`; + return RNFS.exists(dumpPath).then((exists) => { + if (!exists) { + return; + } + return RNFS.unlink(dumpPath).then(() => { + Log.info('[Migrate Onyx] MoveFilesOutOfDocuments removed a stale Onyx state dump'); + }); + }); +} + /** * Internal app files used to live in the Documents directory, which iOS exposes to the - * user (and other apps) through the Files app when file sharing is enabled. This moves - * them into locations that are never user-visible. + * user (and other apps) through the Files app because file sharing is enabled. This moves + * them into locations that are not user-visible, leaving Documents to hold only the files + * the user downloaded on purpose. */ export default function (): Promise { return ( Promise.resolve() - .then(() => Promise.all([migrateAttachmentCache(), migrateQueuedReceipts()])) + .then(() => Promise.all([migrateAttachmentCache(), migrateQueuedReceipts(), removeStaleOnyxDump()])) .then(() => undefined) // A failed cleanup must never block app startup; new files already go to the new locations .catch((error) => { From 9961ef4eb85e1338403126c985105572b77db28d Mon Sep 17 00:00:00 2001 From: Nicolas Bonet Date: Thu, 23 Jul 2026 17:09:53 -0500 Subject: [PATCH 09/11] Keep queued receipts in Documents so users can see them Queued receipt uploads are files the user expects to find in the app's Files-app folder, so they stay in Documents alongside downloads. This removes the receipt relocation and the persisted-path rewriting from the startup migration, which now only cleans up internal leftovers: the old attachment cache and a stale Onyx state dump. Co-Authored-By: Claude Fable 5 --- .../getReceiptsUploadFolderPath/index.ios.ts | 5 +- .../MoveFilesOutOfDocuments/index.ios.ts | 323 +----------------- tests/unit/MoveFilesOutOfDocumentsTest.ts | 157 +-------- 3 files changed, 18 insertions(+), 467 deletions(-) diff --git a/src/libs/getReceiptsUploadFolderPath/index.ios.ts b/src/libs/getReceiptsUploadFolderPath/index.ios.ts index ea0adcfd9bbc..7eeb484d5a02 100644 --- a/src/libs/getReceiptsUploadFolderPath/index.ios.ts +++ b/src/libs/getReceiptsUploadFolderPath/index.ios.ts @@ -4,9 +4,6 @@ import RNFetchBlob from 'react-native-blob-util'; import type GetReceiptsUploadFolderPath from './types'; -// Queued receipts must survive until upload, so they live in Library/Application Support: -// it is persistent (unlike Caches, which the OS may purge) and never exposed to the user -// via the iOS Files app (unlike Documents) -const getReceiptsUploadFolderPath: GetReceiptsUploadFolderPath = () => `${RNFetchBlob.fs.dirs.LibraryDir}/Application Support${CONST.RECEIPTS_UPLOAD_PATH}`; +const getReceiptsUploadFolderPath: GetReceiptsUploadFolderPath = () => `${RNFetchBlob.fs.dirs.DocumentDir}${CONST.RECEIPTS_UPLOAD_PATH}`; export default getReceiptsUploadFolderPath; diff --git a/src/libs/migrations/MoveFilesOutOfDocuments/index.ios.ts b/src/libs/migrations/MoveFilesOutOfDocuments/index.ios.ts index 62c3ff5efa09..597701f98168 100644 --- a/src/libs/migrations/MoveFilesOutOfDocuments/index.ios.ts +++ b/src/libs/migrations/MoveFilesOutOfDocuments/index.ios.ts @@ -1,31 +1,12 @@ -import getReceiptsUploadFolderPath from '@libs/getReceiptsUploadFolderPath'; import Log from '@libs/Log'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {MergeTransaction, OdometerDraft, Transaction} from '@src/types/onyx'; - -import type {OnyxCollection} from 'react-native-onyx'; import RNFS from 'react-native-fs'; import Onyx from 'react-native-onyx'; const OLD_ATTACHMENT_DIR = `${RNFS.DocumentDirectoryPath}/attachments`; -const OLD_RECEIPTS_UPLOAD_DIR = `${RNFS.DocumentDirectoryPath}${CONST.RECEIPTS_UPLOAD_PATH}`; - -// Persisted receipt paths are matched by this suffix instead of the full old directory path -// because iOS moves the app container (changing its absolute path) on every app update. -const OLD_RECEIPTS_PATH_MARKER = `/Documents${CONST.RECEIPTS_UPLOAD_PATH}/`; - -type RewriteReceiptPath = (value: string) => string | null; - -type DeepRewriteResult = {result: T; changed: boolean}; - -type OdometerImageKey = 'odometerStartImage' | 'odometerEndImage'; - -type OdometerImagesUpdate = Partial>; - -const ODOMETER_IMAGE_KEYS: OdometerImageKey[] = ['odometerStartImage', 'odometerEndImage']; /** * The attachment cache now lives in Library/Caches. The old copies in Documents are @@ -45,300 +26,6 @@ function migrateAttachmentCache(): Promise { }); } -/** - * Builds the rewriter used to fix persisted receipt references. Values pointing at a copied - * receipt are rewritten to the new upload folder. Values pointing at a receipt that could - * not be copied are rewritten to the old directory under the current container path, since - * the container path persisted before the update no longer exists. Returns null when the - * value does not reference a known receipt or is already correct. - */ -function buildReceiptPathRewriter(uploadFolder: string, copiedFileNames: Set, failedFileNames: Set): RewriteReceiptPath { - return (value) => { - const markerIndex = value.indexOf(OLD_RECEIPTS_PATH_MARKER); - if (markerIndex === -1) { - return null; - } - const fileName = value.slice(markerIndex + OLD_RECEIPTS_PATH_MARKER.length); - if (!fileName || fileName.includes('/')) { - return null; - } - let newFolder: string; - if (copiedFileNames.has(fileName)) { - newFolder = uploadFolder; - } else if (failedFileNames.has(fileName)) { - newFolder = OLD_RECEIPTS_UPLOAD_DIR; - } else { - return null; - } - const rewritten = `${value.startsWith('file://') ? 'file://' : ''}${newFolder}/${fileName}`; - return rewritten === value ? null : rewritten; - }; -} - -/** - * Recursively rewrites every string in a value, returning the original object untouched - * when nothing changed so callers can skip unnecessary Onyx writes. - */ -function deepRewriteStrings(value: T, rewrite: RewriteReceiptPath): DeepRewriteResult { - if (typeof value === 'string') { - const rewritten = rewrite(value); - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- T is a string here, so the rewritten string is a valid T - return rewritten === null ? {result: value, changed: false} : {result: rewritten as T, changed: true}; - } - if (Array.isArray(value)) { - let changed = false; - const result = value.map((item: unknown) => { - const child = deepRewriteStrings(item, rewrite); - changed = changed || child.changed; - return child.result; - }); - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the rewritten array has the same shape as the original T - return changed ? {result: result as T, changed: true} : {result: value, changed: false}; - } - if (value !== null && typeof value === 'object') { - let changed = false; - const result: Record = {}; - for (const [key, item] of Object.entries(value)) { - const child = deepRewriteStrings(item, rewrite); - changed = changed || child.changed; - result[key] = child.result; - } - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the rewritten object has the same shape as the original T - return changed ? {result: result as T, changed: true} : {result: value, changed: false}; - } - return {result: value, changed: false}; -} - -/** - * Queued requests persist the receipt's absolute path in their data (and in their optimistic - * Onyx updates), and the native payload preparation drops the receipt when that path no - * longer exists. Every persisted reference is rewritten to where the file actually is now. - */ -function rewritePersistedRequests(key: typeof ONYXKEYS.PERSISTED_REQUESTS | typeof ONYXKEYS.PERSISTED_ONGOING_REQUESTS, rewrite: RewriteReceiptPath): Promise { - return new Promise((resolve) => { - const connection = Onyx.connectWithoutView({ - key, - callback: (requests) => { - Onyx.disconnect(connection); - if (!requests) { - return resolve(); - } - const {result, changed} = deepRewriteStrings(requests, rewrite); - if (!changed) { - return resolve(); - } - // No need to add a new action just for this migration - // eslint-disable-next-line rulesdir/prefer-actions-set-data - Onyx.set(key, result).then(() => resolve()); - }, - }); - }); -} - -/** - * Rewrites a persisted file reference, which is either a plain path/URI string or a file - * object carrying the path in its uri field. Returns a merge-ready replacement (the new - * string, or a partial object updating only the uri), or null when the value does not - * reference a moved receipt. - */ -function rewriteFileReference(value: unknown, rewrite: RewriteReceiptPath): string | {uri: string} | null { - if (typeof value === 'string') { - return rewrite(value); - } - if (value !== null && typeof value === 'object' && 'uri' in value && typeof value.uri === 'string') { - const rewritten = rewrite(value.uri); - return rewritten === null ? null : {uri: rewritten}; - } - return null; -} - -/** - * Builds the merge update fixing the odometer image references held by a container - * (a transaction comment, a merge transaction, or the odometer draft). Returns null - * when neither image references a moved receipt. - */ -function buildOdometerImagesUpdate(container: Partial> | undefined, rewrite: RewriteReceiptPath): OdometerImagesUpdate | null { - let update: OdometerImagesUpdate | null = null; - for (const imageKey of ODOMETER_IMAGE_KEYS) { - const rewritten = rewriteFileReference(container?.[imageKey], rewrite); - if (rewritten === null) { - continue; - } - update = {...(update ?? {}), [imageKey]: rewritten}; - } - return update; -} - -/** - * Offline-created transactions keep local file paths until the upload completes: the - * receipt in receipt.source and the odometer images on the comment. Those references - * are rewritten to where the files actually are now. - */ -function rewriteTransactionPaths(collectionKey: typeof ONYXKEYS.COLLECTION.TRANSACTION | typeof ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, rewrite: RewriteReceiptPath): Promise { - return new Promise((resolve) => { - const connection = Onyx.connectWithoutView({ - key: collectionKey, - waitForCollectionCallback: true, - callback: (transactions: OnyxCollection) => { - Onyx.disconnect(connection); - const updatePromises: Array> = []; - for (const [transactionKey, transaction] of Object.entries(transactions ?? {})) { - const source = transaction?.receipt?.source; - const rewrittenSource = typeof source === 'string' ? rewrite(source) : null; - const commentUpdate = buildOdometerImagesUpdate(transaction?.comment, rewrite); - if (rewrittenSource === null && commentUpdate === null) { - continue; - } - const update = { - ...(rewrittenSource === null ? {} : {receipt: {source: rewrittenSource}}), - ...(commentUpdate === null ? {} : {comment: commentUpdate}), - }; - // No need to add a new action just for this migration - // eslint-disable-next-line rulesdir/prefer-actions-set-data, @typescript-eslint/no-unsafe-type-assertion -- keys from a collection callback always carry the collection prefix - updatePromises.push(Onyx.merge(transactionKey as `${typeof collectionKey}${string}`, update).then(() => undefined)); - } - Promise.all(updatePromises).then(() => resolve()); - }, - }); - }); -} - -/** - * Merge-expense drafts persist the receipt and the odometer images at the top level of - * each mergeTransaction entry, so those references are rewritten to where the files - * actually are now. - */ -function rewriteMergeTransactions(rewrite: RewriteReceiptPath): Promise { - return new Promise((resolve) => { - const connection = Onyx.connectWithoutView({ - key: ONYXKEYS.COLLECTION.MERGE_TRANSACTION, - waitForCollectionCallback: true, - callback: (mergeTransactions: OnyxCollection) => { - Onyx.disconnect(connection); - const updatePromises: Array> = []; - for (const [mergeTransactionKey, mergeTransaction] of Object.entries(mergeTransactions ?? {})) { - const source = mergeTransaction?.receipt?.source; - const rewrittenSource = typeof source === 'string' ? rewrite(source) : null; - const imagesUpdate = buildOdometerImagesUpdate(mergeTransaction ?? undefined, rewrite); - if (rewrittenSource === null && imagesUpdate === null) { - continue; - } - const update = { - ...(rewrittenSource === null ? {} : {receipt: {source: rewrittenSource}}), - ...imagesUpdate, - }; - // No need to add a new action just for this migration - // eslint-disable-next-line rulesdir/prefer-actions-set-data, @typescript-eslint/no-unsafe-type-assertion -- keys from a collection callback always carry the collection prefix - updatePromises.push(Onyx.merge(mergeTransactionKey as `${typeof ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${string}`, update).then(() => undefined)); - } - Promise.all(updatePromises).then(() => resolve()); - }, - }); - }); -} - -/** - * The standalone odometer draft (the "save for later" flow) stores each image as a plain - * file URI string on native, so those references are rewritten to where the files - * actually are now. - */ -function rewriteOdometerDraft(rewrite: RewriteReceiptPath): Promise { - return new Promise((resolve) => { - const connection = Onyx.connectWithoutView({ - key: ONYXKEYS.ODOMETER_DRAFT, - callback: (draft) => { - Onyx.disconnect(connection); - const update: Partial = {}; - for (const imageKey of ODOMETER_IMAGE_KEYS) { - const value = draft?.[imageKey]; - if (typeof value !== 'string') { - continue; - } - const rewritten = rewrite(value); - if (rewritten === null) { - continue; - } - update[imageKey] = rewritten; - } - if (Object.keys(update).length === 0) { - return resolve(); - } - // No need to add a new action just for this migration - // eslint-disable-next-line rulesdir/prefer-actions-set-data - Onyx.merge(ONYXKEYS.ODOMETER_DRAFT, update).then(() => resolve()); - }, - }); - }); -} - -function updatePersistedReceiptPaths(uploadFolder: string, copiedFileNames: Set, failedFileNames: Set): Promise { - if (copiedFileNames.size === 0 && failedFileNames.size === 0) { - return Promise.resolve(); - } - const rewrite = buildReceiptPathRewriter(uploadFolder, copiedFileNames, failedFileNames); - return Promise.all([ - rewritePersistedRequests(ONYXKEYS.PERSISTED_REQUESTS, rewrite), - rewritePersistedRequests(ONYXKEYS.PERSISTED_ONGOING_REQUESTS, rewrite), - rewriteTransactionPaths(ONYXKEYS.COLLECTION.TRANSACTION, rewrite), - rewriteTransactionPaths(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, rewrite), - rewriteMergeTransactions(rewrite), - rewriteOdometerDraft(rewrite), - ]).then(() => undefined); -} - -/** - * Receipts queued for upload now live in Library/Application Support. Existing queued - * receipts are moved so pending uploads keep working after the app updates, and the - * paths persisted in queued requests and transactions are rewritten to match. - * - * The files are copied first and the originals deleted only after the persisted paths are - * rewritten, because the request queue can start replaying while this migration runs: the - * old path must stay valid until the rewrite lands, or the replay drops the receipt. - */ -function migrateQueuedReceipts(): Promise { - return RNFS.exists(OLD_RECEIPTS_UPLOAD_DIR).then((exists) => { - if (!exists) { - return; - } - const uploadFolder = getReceiptsUploadFolderPath(); - const copiedFileNames = new Set(); - const failedFileNames = new Set(); - return RNFS.mkdir(uploadFolder) - .then(() => RNFS.readDir(OLD_RECEIPTS_UPLOAD_DIR)) - .then((files) => - Promise.all( - files.map((file) => { - const destination = `${uploadFolder}/${file.name}`; - // Remove any partial copy left by an interrupted earlier run; copyFile fails when the destination exists - return RNFS.unlink(destination) - .catch(() => {}) - .then(() => RNFS.copyFile(file.path, destination)) - .then(() => { - copiedFileNames.add(file.name); - }) - .catch((error) => { - failedFileNames.add(file.name); - Log.warn('[Migrate Onyx] MoveFilesOutOfDocuments failed to copy a queued receipt', {error: error instanceof Error ? error.message : String(error)}); - }); - }), - ), - ) - .then(() => updatePersistedReceiptPaths(uploadFolder, copiedFileNames, failedFileNames)) - .then(() => { - if (failedFileNames.size === 0) { - return RNFS.unlink(OLD_RECEIPTS_UPLOAD_DIR).then(() => undefined); - } - // A receipt that failed to copy has its only copy in the old directory, so only - // the successfully copied originals are removed and the directory is kept. - Log.warn('[Migrate Onyx] MoveFilesOutOfDocuments kept the old receipts folder because some receipts could not be copied', {failedCopyCount: failedFileNames.size}); - return Promise.all([...copiedFileNames].map((fileName) => RNFS.unlink(`${OLD_RECEIPTS_UPLOAD_DIR}/${fileName}`).catch(() => {}))).then(() => undefined); - }) - .then(() => { - Log.info('[Migrate Onyx] MoveFilesOutOfDocuments moved queued receipts', false, {movedFileCount: copiedFileNames.size}); - }); - }); -} - /** * Onyx state dumps were previously written to Documents and never deleted after sharing, * so a stale dump may still sit there. It is an internal debug file, so it is removed. @@ -356,15 +43,15 @@ function removeStaleOnyxDump(): Promise { } /** - * Internal app files used to live in the Documents directory, which iOS exposes to the - * user (and other apps) through the Files app because file sharing is enabled. This moves - * them into locations that are not user-visible, leaving Documents to hold only the files - * the user downloaded on purpose. + * Internal app files used to live in the Documents directory, which iOS shows to the + * user (and other apps) through the Files app because file sharing is enabled. This + * removes the ones older app versions left behind, so the directory only holds files + * the user expects to see there: their downloads and queued receipt uploads. */ export default function (): Promise { return ( Promise.resolve() - .then(() => Promise.all([migrateAttachmentCache(), migrateQueuedReceipts(), removeStaleOnyxDump()])) + .then(() => Promise.all([migrateAttachmentCache(), removeStaleOnyxDump()])) .then(() => undefined) // A failed cleanup must never block app startup; new files already go to the new locations .catch((error) => { diff --git a/tests/unit/MoveFilesOutOfDocumentsTest.ts b/tests/unit/MoveFilesOutOfDocumentsTest.ts index f27874b0de6e..1ac4f6e91dec 100644 --- a/tests/unit/MoveFilesOutOfDocumentsTest.ts +++ b/tests/unit/MoveFilesOutOfDocumentsTest.ts @@ -1,7 +1,7 @@ import MoveFilesOutOfDocuments from '@libs/migrations/MoveFilesOutOfDocuments/index.ios'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {AnyRequest} from '@src/types/onyx'; import Onyx from 'react-native-onyx'; @@ -18,58 +18,16 @@ jest.mock('@libs/Log', () => ({ jest.mock('react-native-fs', () => ({ DocumentDirectoryPath: '/mock/documents', exists: jest.fn(() => Promise.resolve(false)), - mkdir: jest.fn(() => Promise.resolve()), - readDir: jest.fn(() => Promise.resolve([])), - copyFile: jest.fn(() => Promise.resolve()), unlink: jest.fn(() => Promise.resolve()), })); -const NEW_UPLOAD_FOLDER = '/mock/library/Application Support/Receipts-Upload'; - -jest.mock('@libs/getReceiptsUploadFolderPath', () => ({ - __esModule: true, - default: jest.fn(() => NEW_UPLOAD_FOLDER), -})); - const mockRNFS: { exists: jest.Mock; - mkdir: jest.Mock; - readDir: jest.Mock; - copyFile: jest.Mock; unlink: jest.Mock; } = jest.requireMock('react-native-fs'); -const OLD_RECEIPTS_DIR = '/mock/documents/Receipts-Upload'; const OLD_ATTACHMENT_DIR = '/mock/documents/attachments'; - -// The container path persisted before the app update differs from the current one because -// iOS moves the app container on every update. -const STALE_CONTAINER_RECEIPTS_DIR = '/mock/old-container/Documents/Receipts-Upload'; - -const RECEIPT_A = 'receipt_a.jpg'; -const RECEIPT_B = 'receipt_b.jpg'; -const STALE_URI_A = `file://${STALE_CONTAINER_RECEIPTS_DIR}/${RECEIPT_A}`; -const STALE_URI_B = `file://${STALE_CONTAINER_RECEIPTS_DIR}/${RECEIPT_B}`; -const NEW_URI_A = `file://${NEW_UPLOAD_FOLDER}/${RECEIPT_A}`; -const NEW_URI_B = `file://${NEW_UPLOAD_FOLDER}/${RECEIPT_B}`; -const SERVER_RECEIPT_URL = 'https://www.expensify.com/receipts/w_abc.jpg'; - -function buildQueuedRequest(fileName: string, uri: string): AnyRequest { - return { - command: 'RequestMoney', - data: { - transactionID: '123', - receipt: {source: uri, uri, name: fileName, type: 'image/jpeg'}, - }, - optimisticData: [ - { - onyxMethod: 'merge', - key: `${ONYXKEYS.COLLECTION.TRANSACTION}123`, - value: {receipt: {source: uri}}, - }, - ], - }; -} +const STALE_ONYX_DUMP = `/mock/documents/${CONST.DEFAULT_ONYX_DUMP_FILE_NAME}`; describe('MoveFilesOutOfDocuments migration (iOS)', () => { beforeAll(() => { @@ -78,26 +36,15 @@ describe('MoveFilesOutOfDocuments migration (iOS)', () => { beforeEach(async () => { jest.clearAllMocks(); - mockRNFS.exists.mockImplementation((path: string) => Promise.resolve(path === OLD_RECEIPTS_DIR)); - mockRNFS.mkdir.mockImplementation(() => Promise.resolve()); - mockRNFS.readDir.mockImplementation(() => - Promise.resolve([ - {name: RECEIPT_A, path: `${OLD_RECEIPTS_DIR}/${RECEIPT_A}`}, - {name: RECEIPT_B, path: `${OLD_RECEIPTS_DIR}/${RECEIPT_B}`}, - ]), - ); - mockRNFS.copyFile.mockImplementation(() => Promise.resolve()); + mockRNFS.exists.mockImplementation(() => Promise.resolve(false)); mockRNFS.unlink.mockImplementation(() => Promise.resolve()); await Onyx.clear(); await waitForBatchedUpdates(); }); - it('does nothing when the old directories do not exist', async () => { - mockRNFS.exists.mockImplementation(() => Promise.resolve(false)); - + it('does nothing when no internal files are left in Documents', async () => { await MoveFilesOutOfDocuments(); - expect(mockRNFS.copyFile).not.toHaveBeenCalled(); expect(mockRNFS.unlink).not.toHaveBeenCalled(); }); @@ -114,99 +61,19 @@ describe('MoveFilesOutOfDocuments migration (iOS)', () => { expect(attachment).toBeUndefined(); }); - it('copies queued receipts and rewrites persisted paths before removing the old directory', async () => { - await Onyx.set(ONYXKEYS.PERSISTED_REQUESTS, [buildQueuedRequest(RECEIPT_A, STALE_URI_A)]); - await Onyx.set(ONYXKEYS.PERSISTED_ONGOING_REQUESTS, buildQueuedRequest(RECEIPT_B, STALE_URI_B)); - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}123`, {transactionID: '123', receipt: {source: STALE_URI_A}}); - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}999`, {transactionID: '999', receipt: {source: SERVER_RECEIPT_URL}}); - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}456`, {transactionID: '456', receipt: {source: STALE_URI_B}}); - await waitForBatchedUpdates(); + it('removes a stale Onyx state dump left by older app versions', async () => { + mockRNFS.exists.mockImplementation((path: string) => Promise.resolve(path === STALE_ONYX_DUMP)); await MoveFilesOutOfDocuments(); - await waitForBatchedUpdates(); - - expect(mockRNFS.copyFile).toHaveBeenCalledWith(`${OLD_RECEIPTS_DIR}/${RECEIPT_A}`, `${NEW_UPLOAD_FOLDER}/${RECEIPT_A}`); - expect(mockRNFS.copyFile).toHaveBeenCalledWith(`${OLD_RECEIPTS_DIR}/${RECEIPT_B}`, `${NEW_UPLOAD_FOLDER}/${RECEIPT_B}`); - expect(mockRNFS.unlink).toHaveBeenCalledWith(OLD_RECEIPTS_DIR); - - // The persisted request's receipt source/uri and its optimistic transaction data - // are all rewritten to the new upload folder. - const persistedRequests = await getOnyxValue(ONYXKEYS.PERSISTED_REQUESTS); - expect(persistedRequests).toEqual([buildQueuedRequest(RECEIPT_A, NEW_URI_A)]); - - const ongoingRequest = await getOnyxValue(ONYXKEYS.PERSISTED_ONGOING_REQUESTS); - expect(ongoingRequest).toEqual(buildQueuedRequest(RECEIPT_B, NEW_URI_B)); - - const transaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}123`); - expect(transaction?.receipt?.source).toBe(NEW_URI_A); - - // A receipt that was already uploaded points at the server and is left untouched - const uploadedTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}999`); - expect(uploadedTransaction?.receipt?.source).toBe(SERVER_RECEIPT_URL); - const draftTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}456`); - expect(draftTransaction?.receipt?.source).toBe(NEW_URI_B); + expect(mockRNFS.unlink).toHaveBeenCalledWith(STALE_ONYX_DUMP); + expect(mockRNFS.unlink).not.toHaveBeenCalledWith(OLD_ATTACHMENT_DIR); }); - it('rewrites odometer image references on transactions, merge transactions, and the odometer draft', async () => { - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}123`, { - transactionID: '123', - comment: { - odometerStartImage: {uri: STALE_URI_A, name: RECEIPT_A, type: 'image/jpeg'}, - odometerEndImage: STALE_URI_B, - }, - }); - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}456`, { - transactionID: '456', - comment: {odometerStartImage: {uri: STALE_URI_A, name: RECEIPT_A, type: 'image/jpeg'}}, - }); - await Onyx.merge(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}789`, { - receipt: {source: STALE_URI_A}, - odometerEndImage: {uri: STALE_URI_B, name: RECEIPT_B, type: 'image/jpeg'}, - }); - await Onyx.set(ONYXKEYS.ODOMETER_DRAFT, {odometerStartImage: STALE_URI_A, odometerEndImage: SERVER_RECEIPT_URL}); - await waitForBatchedUpdates(); - - await MoveFilesOutOfDocuments(); - await waitForBatchedUpdates(); - - // Odometer images on the transaction comment are rewritten whether they are stored - // as a file object (only the uri changes) or as a plain URI string. - const transaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}123`); - expect(transaction?.comment?.odometerStartImage).toEqual({uri: NEW_URI_A, name: RECEIPT_A, type: 'image/jpeg'}); - expect(transaction?.comment?.odometerEndImage).toBe(NEW_URI_B); - - const draftTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}456`); - expect(draftTransaction?.comment?.odometerStartImage).toEqual({uri: NEW_URI_A, name: RECEIPT_A, type: 'image/jpeg'}); - - const mergeTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}789`); - expect(mergeTransaction?.receipt?.source).toBe(NEW_URI_A); - expect(mergeTransaction?.odometerEndImage).toEqual({uri: NEW_URI_B, name: RECEIPT_B, type: 'image/jpeg'}); - - // The rewritten draft image points at the new folder; a non-receipt value is untouched - const odometerDraft = await getOnyxValue(ONYXKEYS.ODOMETER_DRAFT); - expect(odometerDraft?.odometerStartImage).toBe(NEW_URI_A); - expect(odometerDraft?.odometerEndImage).toBe(SERVER_RECEIPT_URL); - }); - - it('keeps the old directory and points persisted paths at it when a copy fails', async () => { - mockRNFS.copyFile.mockImplementation((source: string) => (source.endsWith(RECEIPT_B) ? Promise.reject(new Error('copy failed')) : Promise.resolve())); - await Onyx.set(ONYXKEYS.PERSISTED_REQUESTS, [buildQueuedRequest(RECEIPT_A, STALE_URI_A), buildQueuedRequest(RECEIPT_B, STALE_URI_B)]); - await waitForBatchedUpdates(); - - await MoveFilesOutOfDocuments(); - await waitForBatchedUpdates(); + it('does not block startup when the cleanup fails', async () => { + mockRNFS.exists.mockImplementation(() => Promise.resolve(true)); + mockRNFS.unlink.mockImplementation(() => Promise.reject(new Error('unlink failed'))); - // The directory keeps the only remaining copy of the failed receipt; only the - // successfully copied original is removed. - expect(mockRNFS.unlink).not.toHaveBeenCalledWith(OLD_RECEIPTS_DIR); - expect(mockRNFS.unlink).toHaveBeenCalledWith(`${OLD_RECEIPTS_DIR}/${RECEIPT_A}`); - expect(mockRNFS.unlink).not.toHaveBeenCalledWith(`${OLD_RECEIPTS_DIR}/${RECEIPT_B}`); - - // The copied receipt points at the new folder, and the failed one is refreshed to the - // old directory under the current container path, since the stale container path no - // longer exists. - const persistedRequests = await getOnyxValue(ONYXKEYS.PERSISTED_REQUESTS); - expect(persistedRequests).toEqual([buildQueuedRequest(RECEIPT_A, NEW_URI_A), buildQueuedRequest(RECEIPT_B, `file://${OLD_RECEIPTS_DIR}/${RECEIPT_B}`)]); + await expect(MoveFilesOutOfDocuments()).resolves.toBeUndefined(); }); }); From f0135efc0f438bfa00791f51acb4e584d353adf8 Mon Sep 17 00:00:00 2001 From: Nicolas Bonet Date: Wed, 29 Jul 2026 15:11:03 -0500 Subject: [PATCH 10/11] Update Mobile-Expensify submodule to 3c66ebd Co-Authored-By: Claude Fable 5 --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 6f762e3569c9..3c66ebde6170 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 6f762e3569c9f0deb9fe4ba22e267def72d361de +Subproject commit 3c66ebde6170366bab553f9179415da73ff4437f From 8247c01b4b58e9831c1b30fd8b6a9be817013b2e Mon Sep 17 00:00:00 2001 From: Nicolas Bonet Date: Wed, 29 Jul 2026 15:18:45 -0500 Subject: [PATCH 11/11] Update Mobile-Expensify submodule to 5b97cab Co-Authored-By: Claude Fable 5 --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 3c66ebde6170..5b97cabcb9a4 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 3c66ebde6170366bab553f9179415da73ff4437f +Subproject commit 5b97cabcb9a4b2f81237207be55c229b8eddd25f