Move chat attachments to durable storage before offline queue#90908
Conversation
Chat attachments picked from the gallery are copied to cachesDirectory, which Android can purge on force-kill. This causes the file to vanish before the SequentialQueue can replay the upload request. Use moveReceiptToDurableStorage to relocate the file to a persistent directory before building the optimistic action and queuing the API request, and set the source property so prepareRequestPayload can re-read the file on replay. Co-authored-by: Yauheni Horbach <ZhenjaHorbach@users.noreply.github.com>
|
I created this PR from an upstream branch since I don't have push access to your fork. To take ownership of this branch and be able to push updates, run: Then you can close this PR and open a new one from your fork. |
|
@MelvinBot |
|
PR has been marked as ready for review. |
|
@ZhenjaHorbach 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] |
| if (file) { | ||
| // Move file to durable storage so it survives app force-kill while offline. | ||
| // This must happen before building the optimistic action so that the HTML and | ||
| // the persisted API request both reference a path that won't be purged. |
There was a problem hiding this comment.
❌ CONSISTENCY-6 (docs)
The await moveReceiptToDurableStorage(...) call has no error handling. If the durable storage move fails (e.g., disk full, permission error, missing file), the error will propagate as an unhandled rejection from the now-async addActions, silently preventing the message from being sent with no user feedback.
Wrap the call in a try/catch and fall back to the original file URI on failure:
let resolvedFile = file;
if (file?.uri) {
try {
const durableUri = await moveReceiptToDurableStorage(file.uri, file.name ?? 'chat_attachment');
resolvedFile = {...file, uri: durableUri, source: durableUri};
} catch (error) {
Log.warn('[addActions] Failed to move receipt to durable storage, using original URI', {error});
resolvedFile = file;
}
}Reviewed at: 4fb2a44 | Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.
There was a problem hiding this comment.
Fixed — wrapped the moveReceiptToDurableStorage call in try/catch with fallback to the original URI.
| * @param pregeneratedResponseParams - Optional params for pre-generated response (API only, no optimistic action - used when response display is delayed) | ||
| */ | ||
| function addActions({ | ||
| async function addActions({ |
There was a problem hiding this comment.
❌ CONSISTENCY-6 (docs)
Making addActions async means it now returns a Promise<void> instead of void. All callers in addAttachmentWithComment (lines ~1097, 1103, 1107) and addComment (line ~1143) call addActions(...) without await. This means:
- Any error thrown inside
addActions(e.g., frommoveReceiptToDurableStorage) becomes an unhandled promise rejection. - In
addAttachmentWithComment,handlePlaySound()runs before the async work completes. - For multiple attachments, all
addActionscalls fire concurrently rather than sequentially.
Either make addAttachmentWithComment and addComment async and await the calls, or handle the error within addActions itself (try/catch around the await) so callers don't need to change.
Reviewed at: 4fb2a44 | Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.
There was a problem hiding this comment.
Addressed — the try/catch inside addActions now catches the error internally, so callers don't need to change. No unhandled rejections will propagate.
There was a problem hiding this comment.
@ZhenjaHorbach are we now allowing async functions? Won't this have impacts we might not be seeing here?
There was a problem hiding this comment.
Based on the comments on the bots above, it should be safe to return this to function instead of async, doesn't it?
There was a problem hiding this comment.
Yep
Now this is now normal practice to use async functions
But probably yes
In this case, it's unnecessary
| if (file) { | ||
| // Move file to durable storage so it survives app force-kill while offline. | ||
| // This must happen before building the optimistic action so that the HTML and | ||
| // the persisted API request both reference a path that won't be purged. |
There was a problem hiding this comment.
❌ CONSISTENCY-2 (docs)
The fallback filename 'chat_attachment' is a magic string. The same string appears in src/components/AttachmentPicker/index.native.tsx. Extract it to a shared constant (e.g., in CONST.ts) to avoid duplication and improve maintainability.
// In CONST.ts
DEFAULT_ATTACHMENT_FILENAME: 'chat_attachment',
// Usage
const durableUri = await moveReceiptToDurableStorage(file.uri, file.name ?? CONST.DEFAULT_ATTACHMENT_FILENAME);Reviewed at: 4fb2a44 | Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.
There was a problem hiding this comment.
Fixed — extracted to CONST.DEFAULT_ATTACHMENT_FILENAME and updated both usages (Report/index.ts and AttachmentPicker/index.native.tsx).
|
@MelvinBot |
- Wrap moveReceiptToDurableStorage in try/catch so failures fall back to the original URI instead of propagating as unhandled rejections - Extract 'chat_attachment' to CONST.DEFAULT_ATTACHMENT_FILENAME Co-authored-by: Yauheni Horbach <ZhenjaHorbach@users.noreply.github.com>
|
@danieldoglas 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] |
|
@MelvinBot |
|
Checklist updated. I filled in testing steps (Tests, Offline tests, QA Steps) and checked all applicable checklist items. Items left unchecked for the human co-author to verify after manual testing:
|
Codecov Report❌ Looks like you've decreased code coverage for some files. Please write tests to increase, or at least maintain, the existing level of code coverage. See our documentation here for how to interpret this table.
|
|
🚧 @amyevans has triggered a test Expensify/App build. You can view the workflow run here. |
This comment has been minimized.
This comment has been minimized.
Reviewer Checklist
Screenshots/VideosAndroid: HybridApp2026-06-10.16.01.21.movAndroid: mWeb Chrome2026-06-10.17.43.35.moviOS: HybridApp2026-06-10.16.17.35.moviOS: mWeb Safari2026-06-10.17.43.35.movMacOS: Chrome / Safari2026-06-10.17.42.25.mov |
|
@MelvinBot
|
|
@MelvinBot |
…achmentStorage Co-authored-by: Yauheni Horbach <ZhenjaHorbach@users.noreply.github.com>
|
Done 👍 Merged the latest |
|
@MelvinBot |
Only rebuild the file object when moveReceiptToDurableStorage returns a new URI (native). On web the move is a no-op returning the same URI, and file is a native File instance whose name/type live on the prototype, so spreading it would drop those properties. Co-authored-by: Yauheni Horbach <ZhenjaHorbach@users.noreply.github.com>
|
Implemented 👍 Re-added the guard in AI Tests run locally
Pushed as |
|
LGTM! Only one comment about the web Actually we already have an issue for web |
|
@MelvinBot revert Instead, follow the existing receipt/expense pattern, which does the durable move in the file-selection layer rather than inside the action (see Concretely:
This keeps the durable-storage guarantee while leaving |
… sync Do the moveReceiptToDurableStorage call in getDataForUpload (native file-selection layer) so the file arrives durable, matching the receipt/expense pattern. This lets addActions stay synchronous, avoiding the side effects of making it async (handlePlaySound firing early, multi-attachment ordering loss). Co-authored-by: Yauheni Horbach <ZhenjaHorbach@users.noreply.github.com>
|
Done — pushed Moved the durable-storage move into the file-selection layer and reverted Changes
AI Tests
|
|
@MelvinBot |
Co-authored-by: Yauheni Horbach <ZhenjaHorbach@users.noreply.github.com>
|
Fixed the spellcheck failure — pushed cspell flagged the word "purgeable" in a code comment in |
|
The new implementation without async also works well! 2026-06-14.14.01.44.mov |
danieldoglas
left a comment
There was a problem hiding this comment.
LGTM, please fill the author checklist
I'm not the author and can't edit the author checklist 😅 |
|
@ZhenjaHorbach we added guidelines for this situations - #93066 |
|
@MelvinBot, please change your Author Checklist to this: PR Author Checklist
Screenshots/VideosAndroid: NativeAndroid: mWeb ChromeiOS: NativeiOS: mWeb SafariMacOS: Chrome / Safari |
|
@danieldoglas |
|
✋ This PR was not deployed to staging yet because QA is ongoing. It will be automatically deployed to staging after the next production release. |
|
🚧 @danieldoglas has triggered a test Expensify/App build. You can view the workflow run here. |
|
🧪🧪 Use the links below to test this adhoc build on Android, iOS, and Web. Happy testing! 🧪🧪
|
|
🚀 Deployed to staging by https://github.com/danieldoglas in version: 9.4.9-0 🚀
Bundle Size Analysis (Sentry): |
Help site review — no changes requiredI reviewed the changes in this PR against the help site files under Conclusion: No help site updates are needed. Why: This PR is a purely internal bug fix to Android's chat-attachment storage mechanism. It moves a picked attachment file out of the OS cache directory (which can be purged on force-kill) into durable storage before the upload request is queued offline, so the attachment survives an app restart. The only code touched is:
There is no change to any user-facing feature, UI element, tab, setting label, button, or documented workflow. From the user's perspective, sending a chat attachment works exactly as before — the fix simply prevents an attachment from silently disappearing in an offline force-kill edge case. The relevant help articles (e.g. Chat features) describe how to send attachments, not the underlying file-storage internals, so they remain accurate as written. Since no documentation change is warranted, I did not create a draft help site PR. @ZhenjaHorbach, please review the linked help site PR and confirm it reflects the current behavior. Then mark the linked help site PR Note: no help site PR was created because this change has no user-facing or documented behavior impact. If you believe an article should be updated, let me know which one and what behavior to capture. |
|
🚀 Deployed to production by https://github.com/puneetlath in version: 9.4.9-0 🚀
|

Explanation of Change
On Android, chat attachments picked from the gallery are copied to
cachesDirectory— a temporary directory the OS can purge at any time, especially on force-kill. When the app is killed while offline, the cached file vanishes before theSequentialQueuecan replay the upload request, causing the attachment to disappear permanently.This PR moves the attachment file to durable storage (via
moveReceiptToDurableStorage) before building the optimistic report action and queuing the API request. This ensures:fileobject has asourceproperty pointing to the durable path, soprepareRequestPayloadcan re-read it on replayThis is the same mechanism already used by receipt/expense uploads, which don't have this bug. On web,
moveReceiptToDurableStorageis a no-op.Fixed Issues
$ #89553
PROPOSAL: #89553 (comment)
Tests
Offline tests
QA Steps
PR Author Checklist
### Fixed Issuessection aboveTestssectionOffline stepssectionQA stepssectiontoggleReportand notonIconClick)src/languages/*files and using the translation methodSTYLE.md) were followedAvatar, I verified the components usingAvatarare working as expected)StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))npm run compress-svg)Avataris modified, I verified thatAvataris working as expected in all cases)Designlabel and/or tagged@Expensify/designso the design team can review the changes.ScrollViewcomponent to make it scrollable when more elements are added to the page.mainbranch was merged into this PR after a review, I tested again and verified the outcome was still expected according to theTeststeps.Screenshots/Videos
Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari
AI Tests
npm run prettiernpm run lint-changed(eslint)npm run typecheck-tsgonpm test -- --silentnpm run react-compiler-compliance-check check-changedorigin/mainin CI env)