Skip to content

Stop exposing app data to the iOS Files app - #96531

Merged
NicolasBonet merged 24 commits into
mainfrom
nicolas-ios-files-app-data-exposure
Aug 24, 2026
Merged

NicolasBonet merged 24 commits into
mainfrom
nicolas-ios-files-app-data-exposure

Conversation

@NicolasBonet

@NicolasBonet NicolasBonet commented Jul 20, 2026 •

Copy link
Copy Markdown
Contributor

Explanation of Change

The iOS app enables UIFileSharingEnabled and LSSupportsOpeningDocumentsInPlace, which publishes the entire Documents/ directory to the iOS Files app. That exposed the Onyx database, attachment caches, and receipt images to the user (and any app with Files access), all of it user-deletable — deleting the OnyxDB breaks the app.

The Documents/ folder is also where downloaded PDFs/documents land, and the Files-app exposure is how users reach them (added in [Mobile-Expensify#2380728e0a]). Hiding the folder would make previously downloaded files unreachable (discussion), so this PR keeps the folder visible and instead moves every internal file out of Documents/, leaving it to hold only files the user expects to see: their downloads and queued receipt uploads.

  • Info.plist: unchanged — the file-sharing keys stay, so the "New Expensify" folder (and any previously downloaded file in it) remains visible in the Files app.
  • Download flow (src/libs/fileDownload/index.ios.ts): non-media downloads keep saving to Documents/. Temporary files move out of it: video downloads stage in Library/Caches before being saved to Photos, and the CSV-export flow writes its share-sheet hand-off file to Library/Caches instead of Documents/Expensify.
  • OnyxDB → Library/Application Support via a react-native-nitro-sqlite patch ([patches/react-native-nitro-sqlite+9.6.0.patch]) that also migrates existing OnyxDB* files out of Documents/ at startup. The DB path is hardcoded to NSDocumentDirectory in the library's native +load, so this cannot be fixed at the app level; the change should be offered upstream so the patch can eventually be dropped.
  • Attachment cache → Library/Caches (re-downloadable, so purgeable is fine).
  • Debug state export (src/libs/ExportOnyxState/index.native.ts) and temporary share files (src/libs/localFileCreate/index.native.ts) → Library/Caches.
  • A new startup migration (src/libs/migrations/MoveFilesOutOfDocuments) moves the old attachment cache into Library/Caches and repoints the Onyx attachment records at it (offline-composed attachments have no remote source to re-download from), and removes a stale Onyx state dump and the old Documents/Expensify export staging folder if older versions left them behind. The same internal files lived under the same relative paths on Android, so the migration runs there too. Queued receipt uploads stay in Documents/ on purpose — they are user content, so they remain visible in the Files app.

Companion Mobile-Expensify PR (moves OldDot app data out of Documents/ while leaving user downloads in place):

MOBILE-EXPENSIFY: https://github.com/Expensify/Mobile-Expensify/pull/14023

Fixed Issues

$ https://github.com/Expensify/Expensify/issues/643611
PROPOSAL:

Tests

All file-visibility checks below refer to the iOS Files app under On My iPhone → New Expensify.

A. Fresh install (no migration)

  1. Install this build fresh and sign in. Verify the app boots with no JS console errors.
  2. Kill and relaunch the app. Verify you are still signed in and your data is intact (OnyxDB persists in its new location).
  3. Open the Files app folder. Verify it contains no OnyxDB* files, no attachments folder, and no other internal files.

B. Downloads (must stay user-visible)

  1. In a chat, download a PDF attachment. Verify the success message appears and the file shows up in the Files app folder.
  2. Download an image attachment. Verify it is saved to Photos, not the Files app folder.
  3. Download a video attachment. Verify it is saved to Photos and no temporary copy is left behind in the Files app folder.
  4. Export expenses as CSV: go to the Spend tab, long-press an expense (or use the ⋮ menu → Select multiple) and select a couple of expenses, then tap the "x selected" dropdown → Export → Basic export. Keep the selection small so the export downloads directly instead of being queued server-side. Verify the share sheet appears, "Save to Files" works, and no Expensify folder is left behind in the Files app folder.
  5. Download an attachment whose name is only digits (e.g. 2024.pdf). Relaunch the app and verify the file is still visible in the Files app folder (the OldDot migration must not sweep numeric-named user downloads).

C. Receipt uploads (user content, must stay visible)

  1. While offline, scan a receipt and create an expense. Verify the Files app folder shows the queued image under Receipts-Upload. Kill and reopen the app, go online, and verify the receipt uploads.

D. Attachment cache

  1. Open several image attachments in a chat so they get cached. Verify no attachments folder appears in the Files app folder.
  2. Go offline and reopen the same attachments. Verify they load from the cache.

E. Internal/debug files

  1. Settings → Troubleshoot → Export Onyx state. Verify the share sheet works and no dump file appears in the Files app folder.
  2. Settings → Security → Two-factor authentication → download the recovery codes. Verify the share/save flow works and no stray copy appears in the Files app folder.

F. Upgrade migration (NewDot)

  1. Install a build from main and sign in. Open image attachments in a chat (populates the attachment cache), download a PDF attachment, and export Onyx state (the old build leaves the dump in Documents). Install this build over it and verify:
    • You are still signed in and chats/expenses are intact (OnyxDB* including -wal/-shm migrated to Application Support).
    • The previously downloaded PDF is still visible in the Files app folder.
    • The Files app folder no longer contains OnyxDB* files, the attachments folder, or the Onyx state dump.
    • Attachments open normally (the cache was moved, so they load without re-downloading — verify this works offline too).
    • Relaunch once more and verify no errors — the migration must be a no-op on second run.

G. Upgrade migration (OldDot / HybridApp)

  1. On a main HybridApp build, use Expensify Classic (OldDot): sign in and capture a receipt in Classic. Upgrade to this build, open Classic again, and verify:
    • You are still signed in and account data loads (the JSON state caches were moved to Application Support/ExpensifyData).
    • The previously captured receipt still displays/uploads (stored absolute /Documents/ paths are re-rooted onto the new data directory).
    • The Files app folder root contains no *.json files, gpsLocations.txt, logPacketsQueue, <UUID>.jpg receipts, or hash-named image caches (e.g. 93244983.jpg) — while user downloads remain in place.
  2. Sign out of Expensify Classic and sign back in. Verify NewDot's session and data are unaffected (the Classic file-cache wipe only touches ExpensifyData).

H. Resilience (the original bug)

  1. In the Files app folder, delete everything that is visible. Relaunch the app and verify you are still signed in and nothing crashes — no internal file is user-deletable anymore.

Android sanity

  1. On Android, download a PDF and an image, open attachments, and scan a receipt. Verify behavior is unchanged (all path changes are iOS-scoped).

  2. Verify that no errors appear in the JS console throughout.

Offline tests

  1. While offline, scan a receipt and create an expense. Kill and reopen the app, go online. Verify the receipt uploads.
  2. While offline, reopen image attachments you viewed before. Verify they load from the cache.

QA Steps

Same as tests

PR Author Checklist

  • I linked the correct issue in the ### Fixed Issues section above
  • I wrote clear testing steps that cover the changes made in this PR
    • I added steps for local testing in the Tests section
    • I added steps for the expected offline behavior in the Offline steps section
    • I added steps for Staging and/or Production testing in the QA steps section
    • I added steps to cover failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
    • I tested this PR with a High Traffic account against the staging or production API to ensure there are no regressions (e.g. long loading states that impact usability).
  • I included screenshots or videos for tests on all platforms
  • I ran the tests on all platforms & verified they passed on:
    • Android: Native
    • Android: mWeb Chrome
    • iOS: Native
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • I verified there are no console errors (if there's a console error not related to the PR, report it or open an issue for it to be fixed)
  • I followed proper code patterns (see Reviewing the code)
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I followed the guidelines as stated in the Review Guidelines
  • I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar are working as expected)
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))
  • If new assets were added or existing ones were modified, I verified that:
    • The assets are optimized and compressed (for SVG files, run npm run compress-svg)
    • The assets load correctly across all supported platforms.
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • I added unit tests for any new feature or bug fix in this PR to help automatically prevent regressions in this user flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.

Screenshots/Videos

Android: Native
Android: mWeb Chrome
iOS: Native
ScreenRecording_07-21-2026.15-23-31_1.MP4
iOS: mWeb Safari

@codecov

codecov Bot commented Jul 20, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ Changes either increased or maintained existing code coverage, great job!

Files with missing lines Coverage Δ
src/ONYXKEYS.ts 100.00% <ø> (ø)
src/libs/ExportOnyxState/common.ts 80.95% <ø> (ø)
src/libs/migrateOnyx.ts 100.00% <100.00%> (ø)
...migrations/MoveFilesOutOfDocuments/index.native.ts 100.00% <100.00%> (ø)
src/libs/actions/Attachment/index.native.ts 84.48% <85.71%> (+7.12%) ⬆️
src/libs/localFileCreate/index.native.ts 14.28% <0.00%> (ø)
src/libs/ExportOnyxState/index.native.ts 0.00% <0.00%> (ø)
...c/libs/migrations/MoveFilesOutOfDocuments/index.ts 0.00% <0.00%> (ø)
src/libs/fileDownload/index.ios.ts 3.44% <0.00%> (+0.07%) ⬆️
... and 274 files with indirect coverage changes

@github-actions

Copy link
Copy Markdown
Contributor

🚧 NicolasBonet has triggered a test Expensify/App build. You can view the workflow run here.

@github-actions

Copy link
Copy Markdown
Contributor

🚧 NicolasBonet has triggered a test Expensify/App build. You can view the workflow run here.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@NicolasBonet
NicolasBonet marked this pull request as ready for review July 21, 2026 20:25
@NicolasBonet
NicolasBonet requested review from a team as code owners July 21, 2026 20:25
@melvin-bot
melvin-bot Bot requested review from daledah and removed request for a team July 21, 2026 20:25
@melvin-bot

melvin-bot Bot commented Jul 21, 2026

Copy link
Copy Markdown

@daledah Please copy/paste the Reviewer Checklist from here into a new comment on this PR and complete it. If you have the K2 extension, you can simply click: [this button]

@melvin-bot
melvin-bot Bot requested review from heyjennahay and removed request for a team July 21, 2026 20:25

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 959f786962

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@github-actions

Copy link
Copy Markdown
Contributor

This PR adds a new Onyx.connectWithoutView call, so I've requested a review from the Onyx performance reviewers (@tgolen, @mountiny, @luacmartins) — a review from any one of them is enough. Please add a link in your PR description to the Slack discussion where the @frontend-performance team approved using connectWithoutView here.

@NicolasBonet
NicolasBonet marked this pull request as draft July 22, 2026 00:58
@NicolasBonet

Copy link
Copy Markdown
Contributor Author

This is WIP, will re-open when it's in a testable status

@NicolasBonet

NicolasBonet commented Jul 22, 2026 •

Copy link
Copy Markdown
Contributor Author

Heads up on a user-facing tradeoff in this PR that could use a product opinion — cc @Expensify/Product:

This PR hides the app's entire Documents folder from the iOS Files app, that includes any PDFs/documents users previously downloaded through the old flow and saved in the visible "New Expensify" folder. Those files are not migrated or surfaced anywhere — after this update they still exist on disk but become unreachable to the user.

The new download flow hands files off through the share sheet ("Save to Files"), so anything downloaded going forward is unaffected. The question is only about previously downloaded files.

Options:

  1. Accept the loss (files were re-downloadable copies of attachments, so nothing is unrecoverable) — current behavior of this PR.
  2. Have the migration surface old downloaded files once via the share sheet or move them somewhere user-accessible before hiding the folder.

Is option 1 acceptable, or should we handle existing downloads more gracefully? If 1, we should note it in the PR body/QA steps as expected behavior.

@trjExpensify

Copy link
Copy Markdown
Contributor

Woof, I don't want people to get angry thinking we've lost their curated list of downloads and force them to trawl through chats, reports, threads etc to re-download them.

What would it take to move them somewhere user accessible before hiding the folder?

@JmillsExpensify

Copy link
Copy Markdown
Contributor

I agree with Tom. I think it's dangerous to not give them someplace obvious to find them, at least for now. This is the number one thing that our users are counting on us for, keeping their digitized receipt data.

@NicolasBonet

Copy link
Copy Markdown
Contributor Author

Sounds good, let me work in a hybrid proposal and try to keep the folder while keeping onyx and similar data out of sight.

@NicolasBonet
NicolasBonet force-pushed the nicolas-ios-files-app-data-exposure branch from a71ba62 to af14bb0 Compare July 23, 2026 21:48
NicolasBonet and others added 2 commits July 24, 2026 12:31
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@NicolasBonet
NicolasBonet merged commit 5491a21 into main Aug 24, 2026
50 of 59 checks passed
@NicolasBonet
NicolasBonet deleted the nicolas-ios-files-app-data-exposure branch August 24, 2026 19:26
@github-actions

Copy link
Copy Markdown
Contributor

🚧 NicolasBonet has triggered a test Expensify/App build. You can view the workflow run here.

@OSBotify

Copy link
Copy Markdown
Contributor

✋ This PR was not deployed to staging yet because QA is ongoing. It will be automatically deployed to staging after the next production release.

@OSBotify OSBotify mentioned this pull request Aug 24, 2026
77 tasks done
@IuliiaHerets

Copy link
Copy Markdown

Hi @NicolasBonet. QA team can't check the console on the Native app. Could you please share QA steps that don't require console checking? If that's not possible, could you please check this PR internally?

cc @luacmartins @mountiny @heyjennahay @tgolen @daledah

@OSBotify

Copy link
Copy Markdown
Contributor

🚀 Deployed to staging by https://github.com/NicolasBonet in version: 9.4.60-0 🚀

platform result
🕸 web 🕸 success ✅
🤖 android 🤖 success ✅
🍎 iOS 🍎 success ✅

@MelvinBot

Copy link
Copy Markdown
Contributor

🤖 I reviewed the changes in this PR against the help site content under docs/articles, and no help site changes are required.

Why: This PR is an internal iOS storage fix. It relocates internal/implementation files (the Onyx database, attachment cache, and debug/export staging files) out of the iOS Documents/ directory so they stop appearing in the iOS Files app, and adds a startup migration to move existing internal files. The files users actually expect to see — their downloads and queued receipt uploads — stay exactly where they were in the "New Expensify" folder, so there is no change to any user-facing workflow.

Expensify's help articles document how to use features (e.g. downloading/exporting expenses and reports, attaching and editing receipts), not the internal on-device file paths or which iOS folder a file is stored in. I checked the relevant candidates — export/download articles like Search-and-Download-Expenses, How-to-Export-Reports, and Attach-and-edit-receipts-on-expenses — and none of them describe the behavior this PR touches. The user-visible outcome (downloads and receipts remain accessible in the Files app) is unchanged.

Since there is no documentable behavior change, I did not create a draft help site PR (so there is no linked PR to review). If you'd still like an article that explains where downloaded files land on iOS — and that internal app data is no longer exposed to the Files app — let me know and I'll draft one.

@NicolasBonet, if you agree there's nothing to document here, no further action is needed. If you'd like a help site PR anyway, reply and I'll create one for you to review.


view run

@NicolasBonet

Copy link
Copy Markdown
Contributor Author

Hi @NicolasBonet. QA team can't check the console on the Native app. Could you please share QA steps that don't require console checking? If that's not possible, could you please check this PR internally?

cc @luacmartins @mountiny @heyjennahay @tgolen @daledah

You can ignore the console and do the rest of tests, specially for iOS.

@IuliiaHerets

Copy link
Copy Markdown

@NicolasBonet, could you please share clear QA steps for the team?

@NicolasBonet

Copy link
Copy Markdown
Contributor Author

@NicolasBonet, could you please share clear QA steps for the team?

What is wrong with the testing section that we have in the PR description? It's indeed a lot, but I have detailed there all the possible scenarios.

@IuliiaHerets

Copy link
Copy Markdown

iOS was passed already by the team

@OSBotify

Copy link
Copy Markdown
Contributor

🚀 Deployed to production by https://github.com/AndrewGable in version: 9.4.60-2 🚀

platform result
🕸 web 🕸 success ✅
🤖 android 🤖 success ✅
🍎 iOS 🍎 success ✅

Bundle Size Analysis (Sentry):

@daledah

daledah commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

@MelvinBot, could you please create a payment issue for me? I was the reviewer of this PR.

@MelvinBot

Copy link
Copy Markdown
Contributor

🤖 Created a C+ payment issue for your review of this PR: #99524

You're assigned as the C+ reviewer. A BugZero team member will pick it up to process the payment.


view run

// exists. If it was purged, fall back to the current source and re-cache it.
const localFileExists = await RNFS.exists(localSource);
if (localFileExists) {
return localSource;

@linhvovan29546 linhvovan29546 Aug 31, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checklist from #99490: getCachedAttachment returns the cached localSource as a bare filesystem path (no file:// scheme). RN's on Android requires that scheme to load a local file, so it rendered as a broken thumbnail.

Already fixed in #99546.

* later launches skip it entirely.
*/
function updateAttachmentRecordPaths(): Promise<void> {
return OnyxUtils.get(ONYXKEYS.ATTACHMENT_RECORD_PATHS_MIGRATED).then((hasMigrated) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry I didn't catch this in the review but OnyxUtils.get() is forbidden. We're in the middle of adding an ESLint rule to prevent it, but that hasn't been merged yet. Would you please do a followup PR to replace this with Onyx.connectWithoutView()?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mmm this is quite sensitive, are there any ongoing issues with get? This change alone would mean re-running all the 19 test scenarios we described in this PR and this migration will get deleted in a month, so not sure if it's worth all the trouble unless we have something broken with its usage.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a draft open: https://github.com/Expensify/App/pull/100531/changes, but I would like to see if we can ignore this one and just get it removed when the time is due.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.