Move chat attachments to durable storage before offline queue#90908
Move chat attachments to durable storage before offline queue#90908MelvinBot wants to merge 3 commits into
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.
| 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. |
|
🧪🧪 Use the links below to test this adhoc build on Android, iOS, and Web. Happy testing! 🧪🧪
|
Reviewer Checklist
Screenshots/VideosAndroid: HybridAppAndroid: mWeb ChromeiOS: HybridAppiOS: mWeb SafariMacOS: Chrome / Safari |
|
@MelvinBot
|
On web, the file object is a native File/Blob whose name, type, and size
properties are prototype getters (not own enumerable properties). Spreading
it with {...file} produces a plain object that loses those properties,
causing 'undefined' to appear in the optimistic attachment HTML.
Skip creating a new object when moveReceiptToDurableStorage returns the
same URI (the web no-op case), preserving the original File object intact.
Co-authored-by: Yauheni Horbach <ZhenjaHorbach@users.noreply.github.com>
|
Fixed in 0bcad5e. Root cause: On web, Fix: Skip creating a new object when |

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)