Skip to content

[$250] [Due for payment 2026-04-28] Reports - Web - Downloaded video has corrupted format #82894

Closed

Description

@jponikarchuk

If you haven’t already, check out our contributing guidelines for onboarding and email contributors@expensify.com to request to join our Slack channel!


Version Number: 9.3.22-1-staging
Reproducible in staging?: Yes
Reproducible in production?: Yes
If this was caught during regression testing, add the test name, ID and link from BrowserStack: https://test-management.browserstack.com/projects/2219752/folder/13176922/test-cases/41237524
Email or phone of affected tester (no customers): N/A
Issue reported by: Applause Internal Team
Bug source: Regression TC Execution
Device used: Windows 11, Chrome
App Component: Search

Action Performed:

Precondition: User has a workspace.

  1. Go to https://staging.new.expensify.com
  2. Sign in. Navigate to the workspace chat.
  3. Click on +, Add attachment, attach a video in .mp4 format.
  4. In LHN, navigate to Reports > Chats.
  5. Click the attached video, click expand, then click Download from the top right corner.

Expected Result:

Downloaded video attachments should retain their original format and be playable.

Actual Result:

Downloaded video attachments have a corrupted format.

Workaround:

Unknown

Platforms:

  • Android: App
  • Android: mWeb Chrome
  • iOS: App
  • iOS: mWeb Safari
  • iOS: mWeb Chrome
  • Windows: Chrome
  • MacOS: Chrome / Safari

Screenshots/Videos

1.mp4

View all open jobs on GitHub

Issue OwnerCurrent Issue Owner: @mallenexpensify
Upwork Automation - Do Not Edit
  • Upwork Job URL: https://www.upwork.com/jobs/~022049246091609971560
  • Upwork Job ID: 2049246091609971560
  • Last Price Increase: 2026-04-28

Activity

  1. added
    BugSomething is broken. Auto assigns a BugZero manager.
    on Feb 19, 2026
  2. melvin-bot commented on Feb 19, 2026

    @melvin-bot

    Triggered auto assignment to @garrettmknight (Bug), see https://stackoverflow.com/c/expensify/questions/14418 for more details. Please add this bug to a GH project, as outlined in the SO.

  3. twilight2294 commented on Feb 19, 2026

    @twilight2294
    Contributor

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    When downloading a video file (e.g., MP4) from a report on web, the downloaded file has a corrupted format that cannot be played. This is because the downloaded blob loses its MIME type during the download process.

    What is the root cause of that problem?

    The root cause is in src/libs/fileDownload/DownloadUtils.ts around line 75. When a file is fetched, response.blob() correctly returns a Blob with the proper MIME type from the server's Content-Type header (e.g., video/mp4). However, this blob is then unnecessarily re-wrapped with new Blob([blob]) without passing the {type: blob.type} option.

    According to the MDN Blob API documentation, the type property is not inherited from source content — it must be explicitly passed to the Blob constructor. As a result, the re-wrapped blob has an empty MIME type (""), and when URL.createObjectURL() is called on it, the resulting object URL lacks the format metadata needed for proper file recognition.

    While this affects all downloaded file types, it is most apparent with video files because image formats (JPEG, PNG) rely on magic bytes for format self-detection, while video containers like MP4 are more dependent on MIME type metadata for proper recognition by media players and OS file handlers.

    What changes do you think we should make in order to solve the problem?

    The simplest and cleanest fix is to remove the unnecessary new Blob([blob]) wrapper entirely in src/libs/fileDownload/DownloadUtils.ts and pass the original blob directly to URL.createObjectURL():

    Current (buggy):

    const href = URL.createObjectURL(new Blob([blob]));

    Fixed:

    const href = URL.createObjectURL(blob);

    Since response.blob() already returns a Blob object that is directly usable with URL.createObjectURL(), the re-wrapping step serves no purpose and only introduces the MIME type stripping bug.

    What alternative solutions did you explore?

    An alternative fix (instead of removing the wrapper) would be to preserve the MIME type when re-wrapping: new Blob([blob], {type: blob.type}). This also resolves the bug, but is less clean since the re-wrap is unnecessary in the first place. The simpler approach of using blob directly is preferred.

  4. added
    ExternalAdded to denote the issue can be worked on by a contributor
    on Feb 19, 2026
  5. added
    Help WantedApply this label when an issue is open to proposals by contributors
    on Feb 19, 2026
  6. melvin-bot commented on Feb 19, 2026

    @melvin-bot

    Triggered auto assignment to Contributor-plus team member for initial proposal review - @sobitneupane (External)

  7. melvin-bot commented on Feb 19, 2026

    @melvin-bot

    Unable to auto-create job on Upwork. The BZ team member should create it manually for this issue.

  8. MelvinBot commented on Feb 19, 2026

    @MelvinBot
    Contributor

    🔍 Issue Analysis

    Summary: Downloaded video attachments on web are corrupted because the download utility strips the MIME type when re-wrapping the fetched blob, making the file unrecognizable to video players.

    Root Cause: In src/libs/fileDownload/DownloadUtils.ts:75, the response blob is unnecessarily re-wrapped with new Blob([blob]) without preserving the content type. Per the MDN Blob API, the type property is not inherited from source content — it must be explicitly passed. The resulting blob has an empty MIME type (""), causing video files to lose format metadata needed for playback.

    Recommended Fix: Remove the unnecessary new Blob([blob]) wrapper on line 75 of src/libs/fileDownload/DownloadUtils.ts and pass the original blob directly to URL.createObjectURL():

    // Current (buggy):
    const href = URL.createObjectURL(new Blob([blob]));
    
    // Fixed:
    const href = URL.createObjectURL(blob);
    

    Since response.blob() already returns a Blob object directly usable with URL.createObjectURL(), the re-wrapping serves no purpose and only strips the MIME type.

    Detailed Investigation

    Root Cause Analysis

    The download flow on web works as follows:

    1. User clicks Download in the expanded attachment modal header (AttachmentModalBaseContent calls onDownloadAttachment)
    2. useDownloadAttachment hook adds the encrypted auth token to the URL and calls fileDownload()
    3. fileDownload() delegates to fetchFileDownload() in DownloadUtils.ts
    4. fetch(url) retrieves the file → response.blob() returns a Blob with the correct MIME type (e.g., video/mp4) from the server's Content-Type header
    5. BUG: new Blob([blob]) creates a new Blob that defaults type to "" — the original MIME type is lost
    6. URL.createObjectURL() creates a blob URL for the now-typeless blob
    7. Browser saves the file without proper MIME type metadata

    Images (JPEG, PNG) survive this bug because they rely on magic bytes for format self-detection. Video containers like MP4, while they do have magic bytes (ftyp box), are more dependent on MIME type metadata for proper recognition by media players and OS file handlers — especially on Windows Chrome.

    Not a Recent Regression

    The new Blob([blob]) pattern has been present since at least September 2023 (the TypeScript migration, commit 0989000c). It was carried forward when DownloadUtils.ts was extracted as a separate file (commit b073f0e7, July 2024). This bug was likely never noticed because:

    • Video attachment downloads may not have been covered by automated tests
    • Image downloads, which are more common, are not visibly affected

    Both Download Paths Are Affected

    There are two entry points for downloading attachments on web, and both converge at the same buggy function:

    1. Attachment modal Download button: useDownloadAttachment → fileDownload → fetchFileDownload
    2. Video popover context menu: VideoPopoverMenuContext.downloadAttachment → fileDownload → fetchFileDownload

    Evidence Chain

    • response.blob() at line 71 returns a Blob with correct type from server Content-Type header ✓
    • new Blob([blob]) at line 75 creates new Blob without {type: blob.type} option → type defaults to "" ✓
    • URL.createObjectURL(typelessBlob) creates blob URL without MIME metadata ✓
    • Browser saves file with correct filename (.mp4) but empty MIME type → video player cannot play it ✓

    Relevant Code

    Implementation Notes

    • The fix is a single-line change — remove the new Blob([]) wrapper
    • No special handling for video vs image is needed; preserving the MIME type fixes all file types
    • A contributor (twilight2294) has already independently identified the same root cause and proposed the same fix in this issue

    Next Steps for Contributor+ team: Reply with @MelvinBot implement this to create a draft PR, or @MelvinBot <your feedback> to refine this analysis.

  9. Eskalifer1 commented on Feb 19, 2026

    @Eskalifer1
    Contributor

    🚨 Edited by proposal-police: This proposal was edited at 2026-02-23 13:31:39 UTC.

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    Reports - Web - Downloaded video has corrupted format

    What is the root cause of that problem?

    We are missing file name for search, so we get very strange name for file, here is this part:

    const fileName = type === CONST.ATTACHMENT_TYPE.SEARCH ? getFileName(`${sourceURL}`) : file?.name;

    But we don't pass type here:

    const onDownloadAttachment = useDownloadAttachment({
    isAuthTokenRequired,
    });

    So on Search page we get fileName === '', which leads to unexpected behaviour

    There is also another problem. When we open an attachment on the Search page, another component is rendered:

    return !isEmptyObject(report) && shouldShowCarousel && type !== CONST.ATTACHMENT_TYPE.SEARCH ? (
    <AttachmentCarousel
    accountID={accountID}
    type={type}
    attachmentID={attachmentID}
    report={report}
    onNavigate={onNavigate}
    source={sourceProp}
    setDownloadButtonVisibility={setDownloadButtonVisibility}
    attachmentLink={currentAttachmentLink}
    onAttachmentError={setAttachmentError}
    />
    ) : (
    !!sourceForAttachmentView && (
    <AttachmentCarouselPagerStateContext.Provider value={stateValue}>
    <AttachmentCarouselPagerActionsContext.Provider value={actionsValue}>
    <AttachmentView
    containerStyles={[styles.mh5]}
    source={sourceForAttachmentView}
    isAuthTokenRequired={isAuthTokenRequiredState}
    file={fileToDisplay}
    onToggleKeyboard={setIsConfirmButtonDisabled}
    isWorkspaceAvatar={isWorkspaceAvatar}
    maybeIcon={maybeIcon}
    fallbackSource={fallbackSource}
    isUsedInAttachmentModal
    transactionID={transaction?.transactionID}
    transaction={transaction}
    isUploaded={!isEmptyObject(report)}
    reportID={reportID ?? (!isEmptyObject(report) ? report.reportID : undefined)}
    />
    </AttachmentCarouselPagerActionsContext.Provider>
    </AttachmentCarouselPagerStateContext.Provider>
    )
    );

    This leads to the use of this source:

    const source = useMemo(() => getValidatedImageSource(sourceParam), [sourceParam]);

    App/src/libs/AvatarUtils.ts

    Lines 113 to 125 in db4fb46

    function getValidatedImageSource(source: AvatarSource | undefined) {
    const numberSource = Number(source);
    if (!Number.isNaN(numberSource) && numberSource !== 0) {
    return numberSource;
    }
    if (typeof source === 'string') {
    return tryResolveUrlFromApiRoot(decodeURIComponent(source));
    }
    return undefined;
    }

    /**
    * When possible this function resolve URLs relative to API ROOT
    * - Absolute URLs like `/{path}`, become: `https://{API_ROOT}/{path}`
    * - Similarly for prod or staging URLs we replace the `https://www.expensify`
    * or `https://staging.expensify` part, with `https://{API_ROOT}`
    * - Unmatched URLs (non expensify) are returned with no modifications
    */
    function tryResolveUrlFromApiRoot(url: string): string;
    function tryResolveUrlFromApiRoot(url: ImageSourcePropType): number;
    function tryResolveUrlFromApiRoot(url: ReceiptSource): ReceiptSource;
    function tryResolveUrlFromApiRoot(url: string | ImageSourcePropType | ReceiptSource): string | ImageSourcePropType | ReceiptSource;
    function tryResolveUrlFromApiRoot(url: string | ImageSourcePropType | ReceiptSource): string | ImageSourcePropType | ReceiptSource {
    // in native, when we import an image asset, it will have a number representation which can be used in `source` of Image
    // in this case we can skip the url resolving
    if (typeof url !== 'string') {
    return url;
    }
    const apiRoot = getApiRoot({shouldUseSecure: false});
    return url.replace(ORIGIN_PATTERN, apiRoot);
    }

    This can lead to a situation where, when we have source = '/staging/chat-attachments/...' through this function (getValidatedImageSource, specifically in the function tryResolveUrlFromApiRoot) is added to the beginning of /staging, which leads to us having an incorrect source (it is equal to ‘/staging/staging/chat-attachments/...’)

    But in the chat (report), and not in the search, this does not happen, because another component (Carousel) is rendered, which leads to this function being executed on the first render (or when switching between attachments):

    /**
    * Keeps the attachment source in sync with the attachment displayed currently in the carousel.
    */
    const onNavigate = useCallback(
    (attachment: Attachment) => {
    setSource(attachment.source);
    setFile(attachment.file);
    setIsAuthTokenRequiredState(attachment.isAuthTokenRequired ?? false);
    onCarouselAttachmentChange(attachment);
    setCurrentAttachmentLink(attachment?.attachmentLink ?? '');
    },
    [onCarouselAttachmentChange, setFile],
    );

    This changes the source to the correct one:

    Therefore, this bug does not occur, although if you add console.log, this bug will appear on the first render, but on the next one, the source changes to the correct one, which is why it is not noticeable in the report.

    PS: Issue is reproducible only with Use Staging Server

    What changes do you think we should make in order to solve the problem?

    We need to pass here type:

    const onDownloadAttachment = useDownloadAttachment({
    isAuthTokenRequired,
    });

        const onDownloadAttachment = useDownloadAttachment({
            isAuthTokenRequired,
            type
        });

    And we need to pass the correct source in this case, but in order not to break too much, I suggest changing this function so that it checks whether apiRoot and url both starts with the same value, and if so, then don't add the prefix. This should fix the problem:

    /**
    * When possible this function resolve URLs relative to API ROOT
    * - Absolute URLs like `/{path}`, become: `https://{API_ROOT}/{path}`
    * - Similarly for prod or staging URLs we replace the `https://www.expensify`
    * or `https://staging.expensify` part, with `https://{API_ROOT}`
    * - Unmatched URLs (non expensify) are returned with no modifications
    */
    function tryResolveUrlFromApiRoot(url: string): string;
    function tryResolveUrlFromApiRoot(url: ImageSourcePropType): number;
    function tryResolveUrlFromApiRoot(url: ReceiptSource): ReceiptSource;
    function tryResolveUrlFromApiRoot(url: string | ImageSourcePropType | ReceiptSource): string | ImageSourcePropType | ReceiptSource;
    function tryResolveUrlFromApiRoot(url: string | ImageSourcePropType | ReceiptSource): string | ImageSourcePropType | ReceiptSource {
    // in native, when we import an image asset, it will have a number representation which can be used in `source` of Image
    // in this case we can skip the url resolving
    if (typeof url !== 'string') {
    return url;
    }
    const apiRoot = getApiRoot({shouldUseSecure: false});
    return url.replace(ORIGIN_PATTERN, apiRoot);
    }

    // Avoid "/staging/staging/..." when apiRoot already includes "/staging"
    function dedupeProxyPrefix(url: string, apiRoot: string) {
        // Iterate only over proxy path values like "/staging/", "/staging-secure/"
        for (const prefix of Object.values(proxyConfig)) {
            // normalize to ensure trailing slash consistency
            const normalizedPrefix = prefix.endsWith('/') ? prefix : `${prefix}/`;
    
            const apiHasPrefix = apiRoot.endsWith(normalizedPrefix);
            const urlHasPrefix = url.startsWith(normalizedPrefix);
    
            if (apiHasPrefix && urlHasPrefix) {
                // remove only the leading prefix from url
                return url.slice(normalizedPrefix.length - 1);
                // -1 because prefix starts with "/" and we want to keep leading slash
            }
        }
    
        return url;
    }
    /**
     * When possible this function resolve URLs relative to API ROOT
     * - Absolute URLs like `/{path}`, become: `https://{API_ROOT}/{path}`
     * - Similarly for prod or staging URLs we replace the `https://www.expensify`
     * or `https://staging.expensify` part, with `https://{API_ROOT}`
     * - Unmatched URLs (non expensify) are returned with no modifications
     */
    function tryResolveUrlFromApiRoot(url: string): string;
    function tryResolveUrlFromApiRoot(url: ImageSourcePropType): number;
    function tryResolveUrlFromApiRoot(url: ReceiptSource): ReceiptSource;
    function tryResolveUrlFromApiRoot(url: string | ImageSourcePropType | ReceiptSource): string | ImageSourcePropType | ReceiptSource;
    function tryResolveUrlFromApiRoot(url: string | ImageSourcePropType | ReceiptSource): string | ImageSourcePropType | ReceiptSource {
        // in native, when we import an image asset, it will have a number representation which can be used in `source` of Image
        // in this case we can skip the url resolving
        if (typeof url !== 'string') {
            return url;
        }
    
        const apiRoot = getApiRoot({shouldUseSecure: false});
        const normalizedUrl = dedupeProxyPrefix(url, apiRoot);
    
        return normalizedUrl.replace(ORIGIN_PATTERN, apiRoot);
    }

    PS: This is the main idea, we can improve it during the PR stage if necessary.

    What alternative solutions did you explore? (Optional)

    App/src/libs/AvatarUtils.ts

    Lines 120 to 122 in 67b752a

    if (typeof source === 'string') {
    return tryResolveUrlFromApiRoot(decodeURIComponent(source));
    }

    For second bug we can do that:
    Remove tryResolveUrlFromApiRoot, and it will look like this:

    return decodeURIComponent(source);

    But I think this parameter should be made dynamic so as not to break existing logic elsewhere.
    Reminder: Please use plain English, be brief and avoid jargon. Feel free to use images, charts or pseudo-code if necessary. Do not post large multi-line diffs or write walls of text. Do not create PRs unless you have been hired for this job.

  10. HiepPP commented on Feb 19, 2026

    @HiepPP
    Contributor

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    Video attachments downloaded from reports on web have corrupted format and cannot be played, even though the file downloads successfully.

    What is the root cause of that problem?

    The fetchFileDownload function wraps the blob in new Blob([blob]) before passing to URL.createObjectURL(). This creates a new Blob object that loses the MIME type information from the original response.

    When response.blob() is called, it returns a Blob with the correct type property (e.g., video/mp4) from the Content-Type header. However, new Blob([blob]) creates a generic Blob with an empty type string. Video players rely on this MIME type to recognize and decode the file format.

    The buggy code is at line 75:

    const href = URL.createObjectURL(new Blob([blob]));

    What changes do you think we should make in order to solve the problem?

    Pass the blob directly to URL.createObjectURL() without wrapping:

    src/libs/fileDownload/DownloadUtils.ts

    // Change line 75 from:
    const href = URL.createObjectURL(new Blob([blob]));
    
    // To:
    const href = URL.createObjectURL(blob);

    This follows the existing pattern in src/libs/Sound/index.ts:62 which correctly uses URL.createObjectURL(soundBlob) without unnecessary wrapping:

    response.blob().then((soundBlob) => {
    const soundUrl = URL.createObjectURL(soundBlob);
    initializeAndPlaySound(soundUrl);

    What alternative solutions did you explore? (Optional)

    Alternative 1: Preserve the MIME type when wrapping:

    const href = URL.createObjectURL(new Blob([blob], { type: blob.type }));

    This also works but is unnecessarily verbose. Direct use of the blob is cleaner and matches the existing pattern in Sound/index.ts.

    Why images work but videos don't: Image formats like JPEG and PNG have "magic bytes" at the start of the file that allow format self-detection. Video containers like MP4 are more dependent on MIME type metadata for proper recognition. This is why the bug manifests primarily with video files.


    Files Modified:

    • src/libs/fileDownload/DownloadUtils.ts - Remove unnecessary Blob wrapper to preserve MIME type

    Testing Notes:

    1. Reproduce by downloading a video attachment from a report on web
    2. Verify downloaded file plays correctly after the fix
    3. Test with different file types (MP4, MOV, PDF) to ensure no regression
    4. The fix aligns with the working pattern in Sound/index.ts
  11. annaweber830 commented on Feb 19, 2026

    @annaweber830
    Contributor

    🚫 Duplicated proposal withdrawn by 🤖 ProposalPolice.

  12. 72 remaining items

  13. melvin-bot commented on Apr 28, 2026

    @melvin-bot

    Current assignees @sobitneupane and @Eskalifer1 are eligible for the External assigner, not assigning anyone new.

  14. melvin-bot commented on Apr 28, 2026

    @melvin-bot
  15. changed the title [-][Due for payment 2026-04-28] Reports - Web - Downloaded video has corrupted format[/-] [+][$250] [Due for payment 2026-04-28] Reports - Web - Downloaded video has corrupted format[/+] on Apr 28, 2026
  16. removed
    Help WantedApply this label when an issue is open to proposals by contributors
    on Apr 28, 2026
  17. mallenexpensify commented on Apr 28, 2026

    @mallenexpensify
    Contributor

    @sobitneupane 👀 above plz.
    If you think the work warrants an increase, plz follow the steps in the C+ doc.
    If not, please provide reasoning.

    @Eskalifer1 can you please accept the job below? Please reply here and tag me once you have.
    https://www.upwork.com/jobs/~022049246091609971560

  18. Eskalifer1 commented on Apr 29, 2026

    @Eskalifer1
    Contributor

    Hi @mallenexpensify, i have accepted offer, thank you!

    Let's wait for a response from @sobitneupane.

    I’m not insisting on raising the price here, since I’m not sure, so I want to hear his opinion. Since we discussed this in part when reviewing the proposal. Fixing just the first bug would have been enough to resolve this issue, but we also fixed another bug as part of this task, even though it only occurs during local development (which is why QA couldn’t catch it).

  19. sobitneupane commented on Apr 30, 2026

    @sobitneupane
    Contributor

    @Eskalifer1 I don't think it should be treated as a separate issue. It seems like the same issue with two different causes. So, bounty increase does't seem necessary.

    Passing the type alone would not have resolved the issue on the staging server, which would have made the solution incomplete. Thanks for testing and covering that as well.

  20. Eskalifer1 commented on Apr 30, 2026

    @Eskalifer1
    Contributor

    Sure! It works for me

  21. Eskalifer1 commented on Apr 30, 2026

    @Eskalifer1
    Contributor

    @mallenexpensify we can procees with payment here :)

  22. mallenexpensify commented on Apr 30, 2026

    @mallenexpensify
    Contributor

    Payment Summary

    Contributor: @Eskalifer1 paid $250 via Upwork
    Contributor+: @sobitneupane due $250 via NewDot

    ^ Test case created

  23. JmillsExpensify commented on May 24, 2026

    @JmillsExpensify
    Contributor

    $250 approved for @sobitneupane

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

Metadata

Metadata

Labels

Awaiting PaymentAuto-added when associated PR is deployed to productionBugSomething is broken. Auto assigns a BugZero manager.DailyKSv2ExternalAdded to denote the issue can be worked on by a contributor

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions