Skip to content

[Due for payment 2026-09-17] [$250] iOS - Scan - App crashes when creating an expense with 30 HEIC images #96404

Closed

Description

@applause-bot

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.4.36-0
Reproducible in staging?: Yes
Reproducible in production?: Yes
If this was caught during regression testing, add the test name, ID and link from BrowserStack: #95813
Email or phone of affected tester (no customers): applausetester+js2026yyyy@applause.expensifail.com
Issue reported by: Applause Internal Team
Bug source: Pull Request QA execution
Device used: iPhone 13/18.6.2
App Component: Other

Action Performed:

Precondition:

  • Download the HEIC image, create 30 copies of it, and save them on your device (File).
    Steps :
  1. Launch the Expensify app.
  2. Log in with a valid high-traffic account.
  3. Tap FAB > Create Expense > Scan.
  4. Tap "Choose file".
  5. Select the 30 HEIC image copies you created in the precondition.
  6. Complete the expense creation flow.

Expected Result:

The expense should be created successfully, and the app should remain stable without crashing.

Actual Result:

The app crashes while creating the expense with the 30 imported HEIC images.

Workaround:

Unknown

Platforms:

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

Screenshots/Videos

file with .heic format : https://platform.applause.com/services/links/v1/external/9bacb23a05be6fe5811915af0555ca4b015a580aef9c659413e794a6544e861b

Bug7210440_1784260496329.BUGG.mp4

Bug7210440_1784260496328!system.txt

View all open jobs on GitHub

Applause Internal Information
Exported by: Mita Rachim
Bug ID: 7210440

Upwork Automation - Do Not Edit
Issue OwnerCurrent Issue Owner: @mallenexpensify

Activity

  1. added
    BugSomething is broken. Auto assigns a BugZero manager.
    on Jul 17, 2026
  2. MelvinBot commented on Jul 17, 2026

    @MelvinBot
    Contributor

    Proposal

    What is the root cause of that problem?

    When 30 HEIC images are selected in the Scan flow, the app decodes all 30 full-resolution images into native memory simultaneously, causing an iOS out-of-memory (jetsam / std::bad_alloc) crash.

    The multi-file scan hands the selection to validateFiles in useFilesValidation.tsx. Validation itself is deliberately sequential to keep only one decoded image in memory at a time (see the explicit comment at useFilesValidation.tsx:181-184) — but the HEIC→JPEG conversion step immediately undoes that protection by fanning out every file at once:

    Evidence (file:line)

    Peak memory scales linearly with the number of selected HEIC images (≈ count × width × height × 4 bytes for the decoded RGBA bitmaps), so 30 × ~12MP images blows past the iOS per-app memory budget.

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

    Cap the concurrency of HEIC conversion instead of converting the whole batch in parallel. Replace the unbounded Promise.all at useFilesValidation.tsx:221 with either strictly sequential conversion (matching the sequential-validation rationale already documented above it) or a small concurrency pool (e.g. 2–3 at a time) so only a bounded number of full-resolution bitmaps are held in memory at once. Apply the same bound to the parallel loop in AttachmentPicker/index.native.tsx:233-285, since the gallery picker is an independent OOM vector for the same scenario.

    What alternative solutions did you explore? (Optional)

    • Downscale/resize during conversion (pass a resize op to ImageManipulator.manipulate) so each decoded bitmap is smaller — reduces per-image memory but doesn't bound total concurrency.
    • Lower MAX_FILE_LIMIT for HEIC batches — a blunt mitigation that reduces the user-facing limit rather than fixing the memory behavior.

    Related issues


    Next Steps for Contributor+ team:
    To accept: @MelvinBot implement [this](https://github.com/Expensify/App/issues/96404) to create a draft PR.
    To refine: @MelvinBot <your feedback>
    To reject: Explain why you are rejecting Melvin's proposal.

  3. added
    ExternalAdded to denote the issue can be worked on by a contributor
    on Jul 17, 2026
  4. added
    Help WantedApply this label when an issue is open to proposals by contributors
    on Jul 17, 2026
  5. abbasifaizan70 commented on Jul 17, 2026

    @abbasifaizan70
    Contributor

    Proposal

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

    On iOS, selecting 30 HEIC copies via "Choose file" when creating a Scan expense crashes the app during the expense creation flow, instead of successfully creating the expense.

    What is the root cause of that problem?

    useFilesValidation's validateAndResizeFiles is deliberately designed to avoid decoding multiple full‑resolution images at the same time — the initial per‑file validation loop explicitly runs sequentially (for...of with await inside the loop) specifically to keep peak memory bounded, because decoding a whole batch concurrently multiplies memory usage by the number of selected files and can kill the app/tab:

    // Validate sequentially so only one image is decoded and held in memory at a time. Validating the
    // whole batch with Promise.all multiplies peak memory by the number of selected files (each pass
    // decodes the full-resolution image), which can kill the WebContent process on memory-constrained
    // mobile Safari when 3+ photos are selected — the tab reloads and the attachments are lost.
    for (const [index, file] of files.entries()) {
    // eslint-disable-next-line no-await-in-loop
    const result = await validateAttachmentFile(file, items.at(index), validationState.isValidatingReceipts);
    if (result.isValid) {
    if (Str.isPDF(result.file.name ?? '')) {
    pdfsToLoad.push(result.file);
    } else {
    validNonPdfFiles.push(result.file);
    }
    continue;
    }
    if (result.error === CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE && isImageFile(file)) {
    filesToResize.push(file);
    continue;
    }
    if (result.error === CONST.FILE_VALIDATION_ERRORS.HEIC_OR_HEIF_IMAGE) {
    filesToConvert.push(file);
    continue;
    }
    const errorData = {
    error: result.error,
    isValidatingMultipleFiles: validationState.isValidatingMultipleFiles,
    fileType: result.error === CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE ? splitExtensionFromFileName(file.name ?? '').fileExtension : undefined,
    } satisfies FileValidationError;
    collectedErrors.current.push(errorData);
    }

    However, the HEIC-to-JPEG conversion step that runs right after does the opposite: it fires off a Promise.all over every file needing conversion, so all HEIC files are converted concurrently:

    if (filesToConvert.length > 0) {
    showLoader();
    const convertedFilesToResize: FileObject[] = [];
    const convertedFiles: FileObject[] = [];
    await Promise.all(
    filesToConvert.map(
    (file) =>
    new Promise<void>((resolve) => {
    convertHeicImage(file, {
    onSuccess: (convertedFile) => {
    if (validationState.isValidatingReceipts && convertedFile.size && convertedFile.size > CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE) {
    convertedFilesToResize.push(convertedFile);
    resolve();
    return;
    }
    if (!validationState.isValidatingReceipts && convertedFile.size && convertedFile.size > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) {
    collectedErrors.current.push({
    error: CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE,
    isValidatingMultipleFiles: validationState.isValidatingMultipleFiles,
    });
    resolve();
    return;
    }
    convertedFiles.push(convertedFile);
    resolve();
    },
    onError: () => {
    Log.warn('HEIC conversion failed, falling back to original file', {fileName: file.name});
    convertedFiles.push(file);
    resolve();
    },
    });
    }),
    ),
    );

    Each conversion (native path) calls ImageManipulator.manipulate(...).renderAsync() then .saveAsync(), which decodes the full-resolution HEIC image into memory, renders it, and re-encodes it as JPEG:

    const imageManipulatorContext = ImageManipulator.manipulate(sourceUri);
    imageManipulatorContext
    .renderAsync()
    .then((manipulatedImage) => manipulatedImage.saveAsync({format: SaveFormat.JPEG}))
    .then((manipulationResult) => {
    const convertedFile = {
    uri: manipulationResult.uri,
    name: file.name?.replace(originalExtension, '.jpg') ?? 'converted-image.jpg',
    type: 'image/jpeg',
    size: file.size,
    width: manipulationResult.width,
    height: manipulationResult.height,
    };
    onSuccess(convertedFile);
    })
    .catch((err) => {
    Log.warn('Error converting HEIC/HEIF to JPEG', {error: err instanceof Error ? err.message : String(err)});
    onError(err, file);
    })
    .finally(() => {
    onFinish();
    });
    };

    When 30 HEIC images are selected at once, Promise.all kicks off all 30 of these decode/render/encode operations in parallel instead of one at a time, spiking native memory far above what the sequential validation loop above it was designed to guarantee, and the iOS app is killed by the OS for exceeding its memory budget (the same class of issue the sequential-validation comment already warns about, just reintroduced in the conversion step).

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

    Convert the HEIC files sequentially (one at a time), matching the pattern already used for the initial validation loop, instead of kicking off all conversions in parallel with Promise.all.

    if (filesToConvert.length > 0) {
    showLoader();
    const convertedFilesToResize: FileObject[] = [];
    const convertedFiles: FileObject[] = [];
    await Promise.all(
    filesToConvert.map(
    (file) =>
    new Promise<void>((resolve) => {
    convertHeicImage(file, {
    onSuccess: (convertedFile) => {
    if (validationState.isValidatingReceipts && convertedFile.size && convertedFile.size > CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE) {
    convertedFilesToResize.push(convertedFile);
    resolve();
    return;
    }
    if (!validationState.isValidatingReceipts && convertedFile.size && convertedFile.size > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) {
    collectedErrors.current.push({
    error: CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE,
    isValidatingMultipleFiles: validationState.isValidatingMultipleFiles,
    });
    resolve();
    return;
    }
    convertedFiles.push(convertedFile);
    resolve();
    },
    onError: () => {
    Log.warn('HEIC conversion failed, falling back to original file', {fileName: file.name});
    convertedFiles.push(file);
    resolve();
    },
    });
    }),
    ),
    );

            if (filesToConvert.length > 0) {
                showLoader();
    
                const convertedFilesToResize: FileObject[] = [];
                const convertedFiles: FileObject[] = [];
                // Convert sequentially so only one HEIC image is decoded/rendered in memory at a time,
                // for the same reason the validation loop above avoids Promise.all.
                for (const file of filesToConvert) {
                    // eslint-disable-next-line no-await-in-loop
                    await new Promise<void>((resolve) => {
                        convertHeicImage(file, {
                            onSuccess: (convertedFile) => {
                                if (validationState.isValidatingReceipts && convertedFile.size && convertedFile.size > CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE) {
                                    convertedFilesToResize.push(convertedFile);
                                    resolve();
                                    return;
                                }
    
                                if (!validationState.isValidatingReceipts && convertedFile.size && convertedFile.size > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) {
                                    collectedErrors.current.push({
                                        error: CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE,
                                        isValidatingMultipleFiles: validationState.isValidatingMultipleFiles,
                                    });
                                    resolve();
                                    return;
                                }
    
                                convertedFiles.push(convertedFile);
                                resolve();
                            },
                            onError: () => {
                                Log.warn('HEIC conversion failed, falling back to original file', {fileName: file.name});
                                convertedFiles.push(file);
                                resolve();
                            },
                        });
                    });
                }

    This bounds peak memory during HEIC conversion to a single image at a time, regardless of how many HEIC files are selected, eliminating the OOM crash for large batches (e.g. 30 files) while keeping the rest of the conversion/resize logic unchanged.

    Test Branch

    Link

    Demo

    Screen.Recording.2026-07-17.at.8.34.59.PM.mp4
  6. MobileMage commented on Jul 17, 2026

    @MobileMage
    Contributor

    Proposal

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

    On the iOS app, picking 30 HEIC images via FAB > Create Expense > Scan > "Choose file" crashes the app. The crash happens while the selected files are being processed for the scan flow, before the expenses are created.

    What is the root cause of that problem?

    Every selected .heic file fails validation with the dedicated HEIC_OR_HEIF_IMAGE error in validateAttachmentFile (src/libs/validateAttachmentFile.ts L29-L31), so in validateAndResizeFiles in src/hooks/useFilesValidation.tsx all 30 files are routed into filesToConvert. That array is then converted with an unbounded Promise.all fan-out, which starts all 30 HEIC-to-JPEG conversions concurrently:

    await Promise.all(
    filesToConvert.map(
    (file) =>
    new Promise<void>((resolve) => {
    convertHeicImage(file, {

    On native, each conversion goes through convertImageWithManipulator in src/libs/fileDownload/heicConverter/index.native.ts, which drives expo-image-manipulator:

    const imageManipulatorContext = ImageManipulator.manipulate(sourceUri);
    imageManipulatorContext
    .renderAsync()
    .then((manipulatedImage) => manipulatedImage.saveAsync({format: SaveFormat.JPEG}))

    On the iOS side, ImageManipulator.manipulate(uri) loads the full resolution UIImage and immediately queues an ImageFixOrientationTransformer that redraws the whole bitmap (ImageManipulatorModule.swift in expo-image-manipulator@56.0.16), and renderAsync() returns an ImageRef, a SharedRef<UIImage> whose native footprint is cgImage.bytesPerRow * cgImage.height (ImageRef.swift). A standard 12 MP iPhone HEIC (4032x3024) decodes to roughly 46 MB of RGBA, so 30 concurrent conversions hold about 1.4 GB of decoded bitmaps at once, plus transient orientation-fix copies and JPEG encode buffers. Because Promise.all keeps every context and rendered ImageRef live simultaneously, Hermes GC cannot reclaim any of them mid-flight, and convertImageWithManipulator never calls release() on either shared object, so even completed conversions keep their UIImage resident until a later GC pass. The process blows past the jetsam memory limit on an iPhone 13 and iOS kills the app (native OOM, no JS error). Note this is not a regression from PR #95813 (that PR only removed retryParams from Onyx error payloads); git history shows the Promise.all conversion block ships since ec9439950e5 ("feat: convert and resize files when validating"), which matches the issue being reproducible in production. The codebase already recognizes this exact hazard: the validation loop directly above was deliberately made sequential, and PDF thumbnails are validated one at a time, but the HEIC conversion and the Promise.allSettled(filesToResize.map((file) => resizeImageIfNeeded(file))) resize step at L266 were left as unbounded fan-outs.

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

    We should bound peak memory at the layer that fans out the work, following the precedent already documented in the same function:

    // Validate sequentially so only one image is decoded and held in memory at a time. Validating the
    // whole batch with Promise.all multiplies peak memory by the number of selected files (each pass
    // decodes the full-resolution image), which can kill the WebContent process on memory-constrained
    // mobile Safari when 3+ photos are selected — the tab reloads and the attachments are lost.
    for (const [index, file] of files.entries()) {

    In validateAndResizeFiles, replace the Promise.all over filesToConvert with a sequential loop so only one image is decoded and held at a time, keeping the existing onSuccess/onError bodies unchanged:

    for (const file of filesToConvert) {
        // eslint-disable-next-line no-await-in-loop
        await new Promise<void>((resolve) => {
            convertHeicImage(file, {onSuccess: ..., onError: ...});
        });
    }

    The same treatment applies to the Promise.allSettled resize fan-out at L266, since converted JPEGs over RECEIPT_MAX_SIZE trigger a second concurrent full-resolution decode pass with the identical hazard. Additionally, in convertImageWithManipulator in heicConverter/index.native.ts we should release the native bitmaps deterministically instead of waiting for GC, since both the context and the rendered image are Expo SharedObjects that expose release() (expo-modules-core SharedObject.d.ts):

    .then((manipulatedImage) => manipulatedImage.saveAsync({format: SaveFormat.JPEG}).finally(() => manipulatedImage.release()))
    ...
    .finally(() => {
        imageManipulatorContext.release();
        onFinish();
    });

    With sequential conversion plus explicit release, peak native memory is one decoded image regardless of batch size, so 30 files convert in roughly 10 to 15 seconds behind the existing full-screen loader (showLoader() already covers this path) instead of crashing. The change is safe for every caller of useFilesValidation (attachments, share, odometer, scan) because it only changes scheduling, not results: convertedFiles/convertedFilesToResize are populated identically and sortFilesByOriginalOrder already restores original ordering, and single-file callers are unaffected since a one-element loop is equivalent to today. It also hardens mWeb Safari, which shares this hook and has the same fan-out through the web converter.

    What alternative solutions did you explore? (Optional)

    A small concurrency pool (2 or 3 conversions in flight) would be slightly faster on multi-core devices, but it adds scheduling code for marginal gain and diverges from the sequential pattern already established for validation and PDF parsing in this hook, so a follow-up could tune it if the loader duration ever matters. We also considered downscaling inside the manipulator chain before rendering, but that would change output resolution for files that are within size limits and does not fix the real problem, which is concurrency times full-resolution decode.

  7. mukhrr commented on Jul 17, 2026

    @mukhrr
    Contributor

    🚨 Edited by proposal-police: This proposal was edited at 2026-07-30 11:57:00 UTC.

    Proposal

    What is the root cause of that problem?

    Multi-scan intentionally allows selecting up to 30 files at once — ScannerControlsBar passes CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT (30) as the picker's fileLimit:

    fileLimit={shouldAcceptMultipleFiles ? CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT : 1}

    validateAndResizeFiles() in useFilesValidation validates that whole selection sequentially — an explicit, documented fix for exactly this class of native-memory issue. But HEIC post-processing doesn't follow that safeguard: it converts every HEIC file concurrently with Promise.all(), then resizes oversized results concurrently with Promise.allSettled():

    if (filesToConvert.length > 0) {
    showLoader();
    const convertedFilesToResize: FileObject[] = [];
    const convertedFiles: FileObject[] = [];
    await Promise.all(
    filesToConvert.map(
    (file) =>
    new Promise<void>((resolve) => {
    convertHeicImage(file, {
    onSuccess: (convertedFile) => {
    if (validationState.isValidatingReceipts && convertedFile.size && convertedFile.size > CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE) {
    convertedFilesToResize.push(convertedFile);
    resolve();
    return;
    }
    if (!validationState.isValidatingReceipts && convertedFile.size && convertedFile.size > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) {
    collectedErrors.current.push({
    error: CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE,
    isValidatingMultipleFiles: validationState.isValidatingMultipleFiles,
    });
    resolve();
    return;
    }
    convertedFiles.push(convertedFile);
    resolve();
    },
    onError: () => {
    Log.warn('HEIC conversion failed, falling back to original file', {fileName: file.name});
    convertedFiles.push(file);
    resolve();
    },
    });
    }),
    ),
    );
    filesToResize.push(...convertedFilesToResize);
    validNonPdfFiles.push(...convertedFiles);
    for (const [index, convertedFile] of convertedFiles.entries()) {
    updateFileOrderMapping(filesToConvert.at(index), convertedFile);
    }
    }
    if (filesToResize.length > 0) {
    showLoader();
    const toResizeResults = await Promise.allSettled(filesToResize.map((file) => resizeImageIfNeeded(file)));
    for (const [index, result] of toResizeResults.entries()) {
    if (result.status === 'fulfilled') {
    const value = result.value;
    validNonPdfFiles.push(value);
    updateFileOrderMapping(filesToResize.at(index), value);
    } else {
    const errorMessage = result.reason instanceof Error ? result.reason.message : undefined;
    if (errorMessage === CONST.FILE_VALIDATION_ERRORS.IMAGE_DIMENSIONS_TOO_LARGE) {
    collectedErrors.current.push({error: CONST.FILE_VALIDATION_ERRORS.IMAGE_DIMENSIONS_TOO_LARGE, isValidatingMultipleFiles: validationState.isValidatingMultipleFiles});
    } else {
    collectedErrors.current.push({error: CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED, isValidatingMultipleFiles: validationState.isValidatingMultipleFiles});
    }
    }
    }
    }

    On iOS, every conversion creates its own ImageManipulator context, decodes the full-resolution image, and re-encodes it as JPEG:

    ) => {
    const imageManipulatorContext = ImageManipulator.manipulate(sourceUri);
    imageManipulatorContext
    .renderAsync()
    .then((manipulatedImage) => manipulatedImage.saveAsync({format: SaveFormat.JPEG}))
    .then((manipulationResult) => {
    const convertedFile = {
    uri: manipulationResult.uri,
    name: file.name?.replace(originalExtension, '.jpg') ?? 'converted-image.jpg',
    type: 'image/jpeg',
    size: file.size,
    width: manipulationResult.width,
    height: manipulationResult.height,
    };
    onSuccess(convertedFile);

    Starting 30 of these at once spikes native image memory. I confirmed this precisely with a Jest test that mocks the native converter to hold every call open and records peak concurrency: against the current code, selecting 30 HEIC files drives concurrency to exactly 30 — every file starts converting before any of them finish.

    This concurrency was introduced by 73b5ca33fda3506f138a20787c0d69dbb3b28aef. Two later commits by the same author sequentialized other parts of this exact hook for the identical reason, but never touched HEIC conversion or resize:

    bfe2b34

    bf7ea9f

    Live verification, and why the reported symptom differs by platform. I reproduced this hands-on in iOS simulators (iPhone 17 Pro and iPhone 15) with 30 real HEIC receipt photos at genuine 12MP resolution, through the actual "Choose file" flow. On a simulator this doesn't reproduce as a hard crash — it reproduces as an indefinitely stuck loading spinner followed by a false "You appear to be offline" banner, which traces to the same root cause under a different resource ceiling:

    • The spinner is useFilesValidation's own showLoader() (
      if (filesToConvert.length > 0) {
      showLoader();
      const convertedFilesToResize: FileObject[] = [];
      const convertedFiles: FileObject[] = [];
      await Promise.all(
      ), rendered via FullScreenLoadingIndicator with no timeout or fallback UI — if Promise.all() takes a very long time to settle under contention, it just spins with no recovery path.
    • While the JS thread/RN bridge is saturated marshaling 30 concurrent native callbacks, the app's api/Ping reachability poll (10s timeout, src/libs/NetworkState.ts:339-356) and other in-flight requests can't be serviced in time. A failed Ping flips internetUnreachable immediately (src/libs/NetworkState.ts:371-373), and/or FailureTracker trips its sustained-failure hard stop after 3 failures in a 10s window (src/libs/FailureTracker.ts:54-76) — either way NetworkState.getIsOffline() returns true and OfflineIndicator shows the banner, even though the device's actual connection never dropped.

    A real iPhone 13 has a hard, OS-enforced memory ceiling (jetsam) that a 30x concurrent decode burst blows through almost immediately, so the OS kills the app outright — that's the reported "crash." A Mac simulator process shares the host Mac's much larger RAM pool and isn't subject to that same jetsam ceiling, so instead of being killed it just gets CPU/bridge-starved: same unbounded-concurrency defect, two different failure ceilings. This is also consistent with the issue being reported only on iOS: App, never web/desktop.

    I applied the fix below and verified live, before/after, in the running app: with the fix, the same 30-HEIC-file flow completes normally (files convert one at a time, loader resolves, no offline banner); without it, the stuck-spinner-then-offline pattern reproduces reliably.

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

    Make filesToConvert and filesToResize processing sequential in useFilesValidation, mirroring the pattern already established for initial validation and PDF thumbnails in this same file:

    -            await Promise.all(
    -                filesToConvert.map(
    -                    (file) =>
    -                        new Promise<void>((resolve) => {
    -                            convertHeicImage(file, {
    -                                onSuccess: (convertedFile) => { /* ... */ },
    -                                onError: () => { /* ... */ },
    -                            });
    -                        }),
    -                ),
    -            );
    +            for (const file of filesToConvert) {
    +                // eslint-disable-next-line no-await-in-loop
    +                await new Promise<void>((resolve) => {
    +                    convertHeicImage(file, {
    +                        onSuccess: (convertedFile) => { /* unchanged */ },
    +                        onError: () => { /* unchanged */ },
    +                    });
    +                });
    +            }
    -            const toResizeResults = await Promise.allSettled(filesToResize.map((file) => resizeImageIfNeeded(file)));
    +            const toResizeResults: Array<PromiseSettledResult<FileObject>> = [];
    +            for (const file of filesToResize) {
    +                // eslint-disable-next-line no-await-in-loop
    +                const result = await resizeImageIfNeeded(file)
    +                    .then((value): PromiseSettledResult<FileObject> => ({status: 'fulfilled', value}))
    +                    .catch((reason: unknown): PromiseSettledResult<FileObject> => ({status: 'rejected', reason}));
    +                toResizeResults.push(result);
    +            }

    All existing success/error handling, loader behavior, updateFileOrderMapping() calls, and the final result shape stay the same — only the scheduling changes from concurrent to sequential, bounding peak decoding memory (and JS-thread/bridge contention) to one image at a time regardless of how many of the 30 selected files are HEIC.

    I verified this with a Jest test (tests/unit/hooks/useFilesValidation.test.tsx) asserting peak concurrent conversions never exceeds 1 across 30 mocked HEIC files, and that all 30 come back in original order. It fails (max: 30) against current code and passes after this change. I also verified live in an iOS simulator, before and after, with 30 real HEIC receipt images through the actual picker flow: the stuck-loader-then-offline-banner pattern reproduces without the fix and is gone with it. QA should repeat the reported flow with 30 HEIC files on a real device, plus mixed HEIC/JPEG batches, conversion failures, and oversized receipts.

    What alternative solutions did you explore? (Optional)

    I considered lowering the 30-file limit specifically for HEIC images. That would reduce the peak memory spike but would contradict the advertised receipt limit and only mask the unbounded concurrency rather than fix it. I also considered adding shouldUseGoBackButton to the loader as a defensive fallback so users aren't stuck on a dead spinner even if some other slow path occurs — but that only treats a symptom (a stuck spinner) and doesn't address why the loader stalls, so I kept the fix scoped to the actual concurrency defect. Sequential processing fixes the root cause without rejecting valid receipts, and matches the fix pattern this codebase already uses twice in the same file for the same class of bug.

    Before:

    before.mp4

    After:

    after.mp4
  8. ahmdshrif commented on Jul 17, 2026

    @ahmdshrif
    Contributor

    🚨 Edited by proposal-police: This proposal was edited at 2026-07-17 14:54:27 UTC.

    Proposal

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

    On iOS, selecting 30 HEIC images via FAB > Create expense > Scan > "Choose file" crashes the app during the expense-creation flow. The crash is an out-of-memory kill (jetsam), not a JS error.

    What is the root cause of that problem?

    Structural cause: useFilesValidation enforces a deliberate one-image-at-a-time memory discipline for validation and PDF parsing (added in PR #93234 to stop exactly this OOM class), but that discipline was never applied to its two remaining batch stages — HEIC conversion and image resizing — which decode all selected files concurrently. Chain:

    1. The Scan picker feeds picked files into useFilesValidation's validateFiles:
      const {validateFiles, PDFValidationComponent, ErrorModal} = useFilesValidation((files: FileObject[]) => {
      processReceipts(files, getPickerCaptureSource());
      });
      return (
      <>
      {PDFValidationComponent}
      <Camera
      onCapture={(file, source) => {
      if (isMultiScanEnabled) {
      processReceipts([file], 'camera');
      return;
      }
      // Pre-warm the thumbnail cache before navigating so the confirm page
      // doesn't flash an un-thumbnail receipt.
      precacheReceiptImage(source).then(() => processReceipts([file], 'camera'));
      }}
      onPicked={validateFiles}
    2. 30 files is exactly MAX_FILE_LIMIT, so the batch passes the count gate untrimmed:

      App/src/CONST/index.ts

      Lines 358 to 360 in 90ec1a9

      ALLOWED_RECEIPT_EXTENSIONS: ['heif', 'heic', 'jpg', 'jpeg', 'gif', 'png', 'pdf', 'htm', 'html', 'text', 'rtf', 'doc', 'tif', 'tiff', 'msword', 'zip', 'xml', 'message'],
      MAX_FILE_LIMIT: 30,
    3. heic is an allowed receipt extension, so each file instead hits the HEIC gate in validateAttachmentFile and returns HEIC_OR_HEIF_IMAGE:
      if (hasHeicOrHeifExtension(file)) {
      return {isValid: false, error: CONST.FILE_VALIDATION_ERRORS.HEIC_OR_HEIF_IMAGE};
      }
      — all 30 land in filesToConvert:
      if (result.error === CONST.FILE_VALIDATION_ERRORS.HEIC_OR_HEIF_IMAGE) {
      filesToConvert.push(file);
      continue;
      }
    4. The conversion stage runs the whole batch with Promise.all — 30 conversions in flight at once:
      await Promise.all(
      filesToConvert.map(
      (file) =>
      new Promise<void>((resolve) => {
      convertHeicImage(file, {
      onSuccess: (convertedFile) => {
      if (validationState.isValidatingReceipts && convertedFile.size && convertedFile.size > CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE) {
      convertedFilesToResize.push(convertedFile);
      resolve();
      return;
      }
      if (!validationState.isValidatingReceipts && convertedFile.size && convertedFile.size > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) {
      collectedErrors.current.push({
      error: CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE,
      isValidatingMultipleFiles: validationState.isValidatingMultipleFiles,
      });
      resolve();
      return;
      }
      convertedFiles.push(convertedFile);
      resolve();
      },
      onError: () => {
      Log.warn('HEIC conversion failed, falling back to original file', {fileName: file.name});
      convertedFiles.push(file);
      resolve();
      },
      });
      }),
      ),
      );
    5. On iOS each conversion is ImageManipulator.manipulate(uri).renderAsync() → saveAsync(JPEG); renderAsync decodes the full-resolution image into a native bitmap (~48 MB for a 12 MP photo) and holds it until saveAsync finishes:
      const imageManipulatorContext = ImageManipulator.manipulate(sourceUri);
      imageManipulatorContext
      .renderAsync()
      .then((manipulatedImage) => manipulatedImage.saveAsync({format: SaveFormat.JPEG}))
      .then((manipulationResult) => {
      const convertedFile = {
      uri: manipulationResult.uri,
      name: file.name?.replace(originalExtension, '.jpg') ?? 'converted-image.jpg',
      type: 'image/jpeg',
      size: file.size,
      width: manipulationResult.width,
      height: manipulationResult.height,
      };
      onSuccess(convertedFile);
      })
      .catch((err) => {
      Log.warn('Error converting HEIC/HEIF to JPEG', {error: err instanceof Error ? err.message : String(err)});
      onError(err, file);
      })
      .finally(() => {
      onFinish();
      });
      };
    6. 30 concurrent full-res decodes ≈ 1.4 GB of transient native memory — far past the foreground jetsam limit on an iPhone 13 → iOS kills the process. That is the "crash".
    7. Second affected stage in the SAME repro: converted JPEGs over RECEIPT_MAX_SIZE (10 MB) queue into convertedFilesToResize (
      if (validationState.isValidatingReceipts && convertedFile.size && convertedFile.size > CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE) {
      convertedFilesToResize.push(convertedFile);
      resolve();
      return;
      }
      ) and are then resized with Promise.allSettled — again the whole batch at once, each doing another full native decode:
      const toResizeResults = await Promise.allSettled(filesToResize.map((file) => resizeImageIfNeeded(file)));
    8. The same file already serializes its other two decode-heavy stages for precisely this reason: sequential validation "so only one image is decoded and held in memory at a time" (
      // Validate sequentially so only one image is decoded and held in memory at a time. Validating the
      // whole batch with Promise.all multiplies peak memory by the number of selected files (each pass
      // decodes the full-resolution image), which can kill the WebContent process on memory-constrained
      // mobile Safari when 3+ photos are selected — the tab reloads and the attachments are lost.
      for (const [index, file] of files.entries()) {
      // eslint-disable-next-line no-await-in-loop
      const result = await validateAttachmentFile(file, items.at(index), validationState.isValidatingReceipts);
      ) and one-at-a-time PDF thumbnails (
      // Validate the PDFs one at a time: every mounted PDFThumbnail is a full PDF.js document parse (its own
      // worker + parsed document), so rendering the whole batch at once multiplies peak memory by the number of
      // selected PDFs — the same batch-decode problem the sequential image validation above avoids — which can
      // kill the WebContent process on memory-constrained mobile Safari and reload the tab. Each thumbnail
      // advances validatedPDFCount when it settles (success/password/error), mounting the next one.
      const pdfFileToValidate = pdfFilesToRender.at(validatedPDFCount);
      ). The conversion/resize batches are the two spots the discipline missed.

    This explains every symptom: iOS-only (HEIC is the iPhone capture format; the cost is native decode memory), exactly 30 images (peak memory scales linearly and 30 is the max the gate admits), a hard crash rather than an error dialog (jetsam), and why QA of PR #95813 still crashed — that PR fixed a different OOM (retryParams duplication in Onyx at submit time); this one happens earlier, at conversion. I verified these two lines are the only batch callers of convertHeicImage/resizeImageIfNeeded in the codebase, so the hook is the single convergence point: every consumer (Scan global-create/from-report/edit-receipt, confirmation step, odometer, report attachments, Share flow, receipt drag-and-drop) inherits the fix, and web does too (the web converter decodes via wasm/canvas under the same unbounded Promise.all — the mWeb Safari tab-reload class of #93234).

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

    In useFilesValidation.tsx, serialize the two remaining batch stages, matching the pattern the file already uses twice:

    1. Replace Promise.all(filesToConvert.map(...)) at L221 with a sequential loop (branching inside onSuccess/onError stays identical):
    for (const file of filesToConvert) {
        // eslint-disable-next-line no-await-in-loop
        await new Promise<void>((resolve) => {
            convertHeicImage(file, {
                onSuccess: (convertedFile) => { /* existing branching */ resolve(); },
                onError: () => { /* existing fallback */ resolve(); },
            });
        });
    }
    1. Replace Promise.allSettled(filesToResize.map(...)) at L266 with the same for…of + await resizeImageIfNeeded(file) in try/catch, keeping the existing error mapping.

    Peak memory drops from N decoded bitmaps to 1 regardless of batch size; wall time is nearly unchanged since the 30 decodes already contended for the same CPU/memory. Regression safety in one sentence: no signature, ordering, or state-shape changes — result order is already restored by originalFileOrder + sortFilesByOriginalOrder, so all consumer screens and both platforms are untouched.

    Why the obvious spot-fixes fail: lowering the file cap breaks an intentional product limit (MAX_FILE_LIMIT: 30) this QA test explicitly exercises; lowering JPEG save quality changes nothing because the OOM happens at decode time — renderAsync materializes the full-resolution bitmap before any save option applies; and a concurrency pool of N still spikes to N full bitmaps while diverging from the one-at-a-time pattern this file already established for this exact bug class.

    What alternative solutions did you explore? (Optional)

    1. Bounded concurrency (N = 2–3) — works, but still multiplies peak memory by N; PR #93234 chose strict serialization for the sibling stages, and consistency keeps the memory bound self-evident.
    2. Downsample during conversion (pass target dimensions to manipulate) so converted receipts land under 10 MB — trims the resize stage but leaves concurrent full-res decodes unbounded, so the crash remains.
    3. Secondary (optional, not the crash): the native converter stamps the converted JPEG with the ORIGINAL HEIC byte size (size: file.size,
      .then((manipulationResult) => {
      const convertedFile = {
      uri: manipulationResult.uri,
      name: file.name?.replace(originalExtension, '.jpg') ?? 'converted-image.jpg',
      type: 'image/jpeg',
      size: file.size,
      width: manipulationResult.width,
      height: manipulationResult.height,
      };
      ), so the > RECEIPT_MAX_SIZE check at L227 evaluates the wrong number; worth correcting while in the area.
  9. aswin-s commented on Jul 17, 2026

    @aswin-s
    Contributor

    Proposal

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

    On iOS, when a user selects 30 HEIC images in the Scan flow (FAB → Create Expense → Scan → Choose file), the app crashes while creating the expense. Expected: the expense creates successfully and the app stays stable regardless of how many HEIC images are selected (up to the configured file-limit cap).

    What is the root cause of that problem?

    In src/hooks/useFilesValidation.tsx the sequential for…of loop at line 185 was intentionally written to avoid multiplying peak memory by the batch size (see the comment on lines 181–184), but the HEIC conversion step immediately below it does the exact opposite: await Promise.all(filesToConvert.map(file => new Promise(resolve => convertHeicImage(file, …)))) at lines 221–253 fires all conversions concurrently. Each convertHeicImage in src/libs/fileDownload/heicConverter/index.native.ts (lines 32–35) calls ImageManipulator.manipulate(sourceUri).renderAsync().then(m => m.saveAsync({format: JPEG})), which fully decodes the source HEIC into an in-memory bitmap before re-encoding to JPEG. With 30 large HEIC images decoded in parallel, iOS jetsams the app before the batch finishes — reproducing the reported crash.

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

    1. src/hooks/useFilesValidation.tsx lines 221–253: replace the Promise.all(filesToConvert.map(...)) with a sequential for (const file of filesToConvert) loop that awaits each convertHeicImage (wrapped in a small promise adapter), mirroring the sequential validation loop above and its rationale in the lines 181–184 comment. This keeps peak memory to one decoded image at a time.
    2. Alternatively (or in addition), process the conversions in small concurrency-limited chunks (e.g. 2–3 at a time) if pure-sequential wall time is too slow — but sequential is safest given the same file already documents the batch-decode failure mode.
    3. Apply the same sequential/limited-concurrency treatment to the Promise.allSettled(filesToResize.map(resizeImageIfNeeded)) at line 266: resizeImageIfNeeded also decodes the full image, so a 30-file batch of images that pass the HEIC step but need resizing will hit the same crash on iOS.
    4. Verify the MAX_FILE_LIMIT guard at line 354 permits ≥30 files (the repro used 30 and passed the guard, so no change needed unless product wants a lower cap for HEIC specifically).
    5. Add a short comment above the new sequential loop pointing at the existing rationale block so future refactors don't regress it back to Promise.all.

    What alternative solutions did you explore? (Optional)

  10. samranahm commented on Jul 17, 2026

    @samranahm
    Contributor

    Proposal

    What is the root cause of that problem?

    Selecting 30 HEIC receipts via Scan -> Choose file crashes the iOS app during local preprocessing, before expenses are created. The kill is a native jetsam OOM, not a JS exception.

    Choose file goes through the document picker into useFilesValidation on the Scan create path:

    const {validateFiles, PDFValidationComponent, ErrorModal} = useFilesValidation((files: FileObject[]) => {
    processReceipts(files, getPickerCaptureSource());
    });
    return (
    <>
    {PDFValidationComponent}
    <Camera
    onCapture={(file, source) => {
    if (isMultiScanEnabled) {
    processReceipts([file], 'camera');
    return;
    }
    // Pre-warm the thumbnail cache before navigating so the confirm page
    // doesn't flash an un-thumbnail receipt.
    precacheReceiptImage(source).then(() => processReceipts([file], 'camera'));
    }}
    onPicked={validateFiles}

    30 is exactly MAX_FILE_LIMIT, so the batch is not trimmed:

    MAX_FILE_LIMIT: 30,

    Per-file validation is already sequential on purpose, to keep only one decoded image in memory at a time:

    // Validate sequentially so only one image is decoded and held in memory at a time. Validating the
    // whole batch with Promise.all multiplies peak memory by the number of selected files (each pass
    // decodes the full-resolution image), which can kill the WebContent process on memory-constrained
    // mobile Safari when 3+ photos are selected — the tab reloads and the attachments are lost.

    That protection is undone immediately afterward. Every HEIC lands in filesToConvert and is converted with unbounded Promise.all, so all 30 ImageManipulator.renderAsync() calls decode full-resolution bitmaps concurrently (~40 to 50 MB RGBA each for a 12MP photo):

    if (filesToConvert.length > 0) {
    showLoader();
    const convertedFilesToResize: FileObject[] = [];
    const convertedFiles: FileObject[] = [];
    await Promise.all(
    filesToConvert.map(
    (file) =>
    new Promise<void>((resolve) => {
    convertHeicImage(file, {
    onSuccess: (convertedFile) => {
    if (validationState.isValidatingReceipts && convertedFile.size && convertedFile.size > CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE) {
    convertedFilesToResize.push(convertedFile);
    resolve();
    return;
    }
    if (!validationState.isValidatingReceipts && convertedFile.size && convertedFile.size > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) {
    collectedErrors.current.push({
    error: CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE,
    isValidatingMultipleFiles: validationState.isValidatingMultipleFiles,
    });
    resolve();
    return;
    }
    convertedFiles.push(convertedFile);
    resolve();
    },
    onError: () => {
    Log.warn('HEIC conversion failed, falling back to original file', {fileName: file.name});
    convertedFiles.push(file);
    resolve();
    },
    });
    }),
    ),
    );

    const imageManipulatorContext = ImageManipulator.manipulate(sourceUri);
    imageManipulatorContext
    .renderAsync()
    .then((manipulatedImage) => manipulatedImage.saveAsync({format: SaveFormat.JPEG}))
    .then((manipulationResult) => {
    const convertedFile = {
    uri: manipulationResult.uri,
    name: file.name?.replace(originalExtension, '.jpg') ?? 'converted-image.jpg',
    type: 'image/jpeg',
    size: file.size,
    width: manipulationResult.width,
    height: manipulationResult.height,
    };
    onSuccess(convertedFile);

    Two compounding defects make serialization alone incomplete:

    1. The converter never calls release() on the Expo SharedObject context or the rendered ImageRef. Those hold the native UIImage until Hermes GC, so even a sequential loop can retain decoded bitmaps across the batch.

    2. The converted file is stamped with size: file.size (the HEIC byte size). ImageResult from saveAsync has no size field, so the real JPEG size is never read. HEIC is more compact than JPEG, so the later RECEIPT_MAX_SIZE check under-reports and can skip the resize safety-net:

    .then((manipulationResult) => {
    const convertedFile = {
    uri: manipulationResult.uri,
    name: file.name?.replace(originalExtension, '.jpg') ?? 'converted-image.jpg',
    type: 'image/jpeg',
    size: file.size,
    width: manipulationResult.width,
    height: manipulationResult.height,
    };

    if (validationState.isValidatingReceipts && convertedFile.size && convertedFile.size > CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE) {
    convertedFilesToResize.push(convertedFile);

    The same unbounded decode fan-out also exists for resize (Promise.allSettled) and for the gallery picker path in AttachmentPicker (not the Choose file path, but the same crash class):

    if (filesToResize.length > 0) {
    showLoader();
    const toResizeResults = await Promise.allSettled(filesToResize.map((file) => resizeImageIfNeeded(file)));

    for (const asset of assets) {
    if (!asset.uri) {
    checkAllProcessed();
    continue;
    }
    if (asset.type?.startsWith('image')) {
    verifyFileFormat({fileUri: asset.uri, formatSignatures: CONST.HEIC_SIGNATURES})
    .then((isHEIC) => {
    // react-native-image-picker incorrectly changes file extension without transcoding the HEIC file, so we are doing it manually if we detect HEIC signature
    if (isHEIC && asset.uri) {
    ImageManipulator.manipulate(asset.uri)
    .renderAsync()
    .then((manipulatedImage) => manipulatedImage.saveAsync({format: SaveFormat.JPEG}))
    .then((manipulationResult) => {
    const uri = manipulationResult.uri;
    const convertedAsset = {
    uri,
    name: uri
    .substring(uri.lastIndexOf('/') + 1)
    .split('?')
    .at(0),
    type: 'image/jpeg',
    width: manipulationResult.width,
    height: manipulationResult.height,
    };
    processedAssets.push(convertedAsset);
    checkAllProcessed();
    })
    .catch((error: Error) => {
    Log.warn('Failed to convert HEIC image, falling back to original', {error: error.message});
    const fallbackAsset = processAssetWithFallbacks(asset);
    processedAssets.push(fallbackAsset);
    checkAllProcessed();
    });
    } else {
    // Ensure the asset has proper fileName and type for non-HEIC images
    const processedAsset = processAssetWithFallbacks(asset);
    processedAssets.push(processedAsset);
    checkAllProcessed();
    }
    })
    .catch((error: Error) => {
    showGeneralAlert(error.message ?? 'An unknown error occurred');
    checkAllProcessed();
    });
    } else {
    // Ensure the asset has proper fileName and type
    const processedAsset = processAssetWithFallbacks(asset);
    processedAssets.push(processedAsset);
    checkAllProcessed();
    }

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

    1. heicConverter/index.native.ts - convertImageWithManipulator

    Release native bitmaps and stamp the real JPEG size (same fs.stat pattern as cropOrRotateImage):

    const convertImageWithManipulator = (
    file: FileObject,
    sourceUri: string,
    originalExtension: RegExp,
    {
    onSuccess = () => {},
    onError = () => {},
    onFinish = () => {},
    }: {
    onSuccess?: (convertedFile: FileObject) => void;
    onError?: (error: unknown, originalFile: FileObject) => void;
    onFinish?: () => void;
    } = {},
    ) => {
    const imageManipulatorContext = ImageManipulator.manipulate(sourceUri);
    imageManipulatorContext
    .renderAsync()
    .then((manipulatedImage) => manipulatedImage.saveAsync({format: SaveFormat.JPEG}))
    .then((manipulationResult) => {
    const convertedFile = {
    uri: manipulationResult.uri,
    name: file.name?.replace(originalExtension, '.jpg') ?? 'converted-image.jpg',
    type: 'image/jpeg',
    size: file.size,
    width: manipulationResult.width,
    height: manipulationResult.height,
    };
    onSuccess(convertedFile);
    })
    .catch((err) => {
    Log.warn('Error converting HEIC/HEIF to JPEG', {error: err instanceof Error ? err.message : String(err)});
    onError(err, file);
    })
    .finally(() => {
    onFinish();
    });
    };

    .then(({base64, ...result}) => {
    RNFetchBlob.fs
    .stat(fileURIToPath(result.uri))
    .then(({size}) => {
    resolve({
    ...result,
    size,
    type: options.type || 'image/jpeg',
    name: options.name || 'fileName.jpg',
    });
    })
    .catch(reject);

    imageManipulatorContext
        .renderAsync()
        .then(async (manipulatedImage) => {
            try {
                const manipulationResult = await manipulatedImage.saveAsync({format: SaveFormat.JPEG});
                let size = file.size;
                try {
                    const jpegStat = await RNFetchBlob.fs.stat(fileURIToPath(manipulationResult.uri));
                    size = jpegStat.size;
                } catch (statError) {
                    Log.warn('Unable to read converted JPEG size, falling back to original file size', {
                        error: statError instanceof Error ? statError.message : String(statError),
                    });
                }
                onSuccess({
                    uri: manipulationResult.uri,
                    name: file.name?.replace(originalExtension, '.jpg') ?? 'converted-image.jpg',
                    type: 'image/jpeg',
                    size,
                    width: manipulationResult.width,
                    height: manipulationResult.height,
                });
            } finally {
                manipulatedImage.release();
            }
        })
        .catch((err) => {
            Log.warn('Error converting HEIC/HEIF to JPEG', {error: err instanceof Error ? err.message : String(err)});
            onError(err, file);
        })
        .finally(() => {
            imageManipulatorContext.release();
            onFinish();
        });

    2. useFilesValidation.tsx - validateAndResizeFiles

    Replace HEIC Promise.all and resize Promise.allSettled with sequential loops. Map order inside each iteration (the post-Promise.all index mapping is wrong when some files resize and some do not):

    if (filesToConvert.length > 0) {
    showLoader();
    const convertedFilesToResize: FileObject[] = [];
    const convertedFiles: FileObject[] = [];
    await Promise.all(
    filesToConvert.map(
    (file) =>
    new Promise<void>((resolve) => {
    convertHeicImage(file, {
    onSuccess: (convertedFile) => {
    if (validationState.isValidatingReceipts && convertedFile.size && convertedFile.size > CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE) {
    convertedFilesToResize.push(convertedFile);
    resolve();
    return;
    }
    if (!validationState.isValidatingReceipts && convertedFile.size && convertedFile.size > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) {
    collectedErrors.current.push({
    error: CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE,
    isValidatingMultipleFiles: validationState.isValidatingMultipleFiles,
    });
    resolve();
    return;
    }
    convertedFiles.push(convertedFile);
    resolve();
    },
    onError: () => {
    Log.warn('HEIC conversion failed, falling back to original file', {fileName: file.name});
    convertedFiles.push(file);
    resolve();
    },
    });
    }),
    ),
    );
    filesToResize.push(...convertedFilesToResize);
    validNonPdfFiles.push(...convertedFiles);
    for (const [index, convertedFile] of convertedFiles.entries()) {
    updateFileOrderMapping(filesToConvert.at(index), convertedFile);
    }
    }
    if (filesToResize.length > 0) {
    showLoader();
    const toResizeResults = await Promise.allSettled(filesToResize.map((file) => resizeImageIfNeeded(file)));
    for (const [index, result] of toResizeResults.entries()) {
    if (result.status === 'fulfilled') {
    const value = result.value;
    validNonPdfFiles.push(value);
    updateFileOrderMapping(filesToResize.at(index), value);
    } else {
    const errorMessage = result.reason instanceof Error ? result.reason.message : undefined;
    if (errorMessage === CONST.FILE_VALIDATION_ERRORS.IMAGE_DIMENSIONS_TOO_LARGE) {
    collectedErrors.current.push({error: CONST.FILE_VALIDATION_ERRORS.IMAGE_DIMENSIONS_TOO_LARGE, isValidatingMultipleFiles: validationState.isValidatingMultipleFiles});
    } else {
    collectedErrors.current.push({error: CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED, isValidatingMultipleFiles: validationState.isValidatingMultipleFiles});
    }
    }
    }
    }

    for (const file of filesToConvert) {
        await new Promise<void>((resolve) => {
            convertHeicImage(file, {
                onSuccess: (convertedFile) => {
                    updateFileOrderMapping(file, convertedFile);
                    if (validationState.isValidatingReceipts && convertedFile.size && convertedFile.size > CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE) {
                        convertedFilesToResize.push(convertedFile);
                    } else if (!validationState.isValidatingReceipts && convertedFile.size && convertedFile.size > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) {
                        collectedErrors.current.push({
                            error: CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE,
                            isValidatingMultipleFiles: validationState.isValidatingMultipleFiles,
                        });
                    } else {
                        convertedFiles.push(convertedFile);
                    }
                    resolve();
                },
                onError: () => {
                    Log.warn('HEIC conversion failed, falling back to original file', {fileName: file.name});
                    convertedFiles.push(file);
                    resolve();
                },
            });
        });
    }
    
    for (const file of filesToResize) {
        try {
            const value = await resizeImageIfNeeded(file);
            validNonPdfFiles.push(value);
            updateFileOrderMapping(file, value);
        } catch (reason) {
            const errorMessage = reason instanceof Error ? reason.message : undefined;
            collectedErrors.current.push({
                error: errorMessage === CONST.FILE_VALIDATION_ERRORS.IMAGE_DIMENSIONS_TOO_LARGE
                    ? CONST.FILE_VALIDATION_ERRORS.IMAGE_DIMENSIONS_TOO_LARGE
                    : CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED,
                isValidatingMultipleFiles: validationState.isValidatingMultipleFiles,
            });
        }
    }

    3. AttachmentPicker/index.native.tsx - showImagePicker

    Process assets sequentially and release manipulator objects after each HEIC conversion:

    for (const asset of assets) {
    if (!asset.uri) {
    checkAllProcessed();
    continue;
    }
    if (asset.type?.startsWith('image')) {
    verifyFileFormat({fileUri: asset.uri, formatSignatures: CONST.HEIC_SIGNATURES})
    .then((isHEIC) => {
    // react-native-image-picker incorrectly changes file extension without transcoding the HEIC file, so we are doing it manually if we detect HEIC signature
    if (isHEIC && asset.uri) {
    ImageManipulator.manipulate(asset.uri)
    .renderAsync()
    .then((manipulatedImage) => manipulatedImage.saveAsync({format: SaveFormat.JPEG}))
    .then((manipulationResult) => {
    const uri = manipulationResult.uri;
    const convertedAsset = {
    uri,
    name: uri
    .substring(uri.lastIndexOf('/') + 1)
    .split('?')
    .at(0),
    type: 'image/jpeg',
    width: manipulationResult.width,
    height: manipulationResult.height,
    };
    processedAssets.push(convertedAsset);
    checkAllProcessed();
    })
    .catch((error: Error) => {
    Log.warn('Failed to convert HEIC image, falling back to original', {error: error.message});
    const fallbackAsset = processAssetWithFallbacks(asset);
    processedAssets.push(fallbackAsset);
    checkAllProcessed();
    });
    } else {
    // Ensure the asset has proper fileName and type for non-HEIC images
    const processedAsset = processAssetWithFallbacks(asset);
    processedAssets.push(processedAsset);
    checkAllProcessed();
    }
    })
    .catch((error: Error) => {
    showGeneralAlert(error.message ?? 'An unknown error occurred');
    checkAllProcessed();
    });
    } else {
    // Ensure the asset has proper fileName and type
    const processedAsset = processAssetWithFallbacks(asset);
    processedAssets.push(processedAsset);
    checkAllProcessed();
    }

    for (const asset of assets) {
        if (!asset.uri) {
            continue;
        }
        if (!asset.type?.startsWith('image')) {
            processedAssets.push(processAssetWithFallbacks(asset));
            continue;
        }
        const isHEIC = await verifyFileFormat({fileUri: asset.uri, formatSignatures: CONST.HEIC_SIGNATURES});
        if (!isHEIC || !asset.uri) {
            processedAssets.push(processAssetWithFallbacks(asset));
            continue;
        }
        const imageManipulatorContext = ImageManipulator.manipulate(asset.uri);
        try {
            const manipulatedImage = await imageManipulatorContext.renderAsync();
            try {
                const manipulationResult = await manipulatedImage.saveAsync({format: SaveFormat.JPEG});
                processedAssets.push({
                    uri: manipulationResult.uri,
                    fileName: manipulationResult.uri.substring(manipulationResult.uri.lastIndexOf('/') + 1).split('?').at(0),
                    type: 'image/jpeg',
                    width: manipulationResult.width,
                    height: manipulationResult.height,
                });
            } finally {
                manipulatedImage.release();
            }
        } catch (error) {
            Log.warn('Failed to convert HEIC image, falling back to original', {
                error: error instanceof Error ? error.message : String(error),
            });
            processedAssets.push(processAssetWithFallbacks(asset));
        } finally {
            imageManipulatorContext.release();
        }
    }

    What alternative solutions did you explore? (Optional)

    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.

    TEST BRANCH

    Real iPhone iOS Simulator (Bug) iOS Simulator (Fixed)
    IOS.MP4
    IOS.Simulator.mp4
    IOS.solution.mp4
  11. melvin-bot commented on Jul 17, 2026

    @melvin-bot

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

  12. 99 remaining items

  13. melvin-bot commented on Sep 16, 2026

    @melvin-bot

    Triggered auto assignment to @mallenexpensify (Awaiting Payment)

  14. melvin-bot commented on Sep 16, 2026

    @melvin-bot

    Payment Summary

    Resolving PRs:

    Upwork Job

    BugZero Checklist (@mallenexpensify)

    • I have confirmed assignees, roles, and Upwork contracts look correct
    • I have paid out Upwork contracts / manual NewDot requests
    • [BugZero Assignee] I have created a GH issue for creating/updating the regression test once above steps have been agreed upon
  15. ahmedGaber93 commented on Sep 16, 2026

    @ahmedGaber93
    Contributor

    No payment for me, @marufsharifi has taken over this.

  16. melvin-bot commented on Sep 21, 2026

    @melvin-bot

    @mallenexpensify Huh... This is 4 days overdue. Who can take care of this?

  17. mallenexpensify commented on Sep 22, 2026

    @mallenexpensify
    Contributor

    Payment Summary

    Contributor: @nabi-ebrahimi paid $250 via Upwork
    Contributor+: @marufsharifi paid $250 via Upwork

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

  18. marufsharifi commented on Sep 22, 2026

    @marufsharifi

    @mallenexpensify, I've accepted the offer. thanks.

  19. nabi-ebrahimi commented on Sep 22, 2026

    @nabi-ebrahimi

    @mallenexpensify, Accepted the offer. thx.

  20. mallenexpensify commented on Sep 22, 2026

    @mallenexpensify
    Contributor

    @abbasifaizan70 @marufsharifi paid, summary updated above. Thx.

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