Skip to content

[RNE Rewrite] Integrate resource fetcher (react-native-blob-util) - #1328

Open
msluszniak wants to merge 8 commits into
rne-rewritefrom
@ms/resource-fetcher
Open

[RNE Rewrite] Integrate resource fetcher (react-native-blob-util)#1328
msluszniak wants to merge 8 commits into
rne-rewritefrom
@ms/resource-fetcher

Conversation

@msluszniak

@msluszniak msluszniak commented Jul 24, 2026

Copy link
Copy Markdown
Member

Description

Adds resource fetching to the rewrite as a single react-native-blob-util-backed fetcher inside react-native-executorch. Replaces the temporary react-native-fs hook.

download(source, { onProgress, signal }) is a single generic entry point: it takes any nested structure of plain objects and arrays, downloads every string leaf that is an http(s) URL, and returns the value with those URLs replaced by local paths. Everything else passes through untouched, so the result is structurally identical to the input. download<T>(source: T): Promise<T> types correctly for every task config without per-task overloads:

const model = await download(models.classification.EFFICIENTNET_V2_S.XNNPACK_FP32);
const { classify, dispose } = await createClassifier(model);

Backend is split by platform: Android uses the system DownloadManager (reliable for files >2 GB, background), iOS streams via NSURLSession with .partial/HTTP-Range resume.

Introduces a breaking change?

  • Yes
  • No

Type of change

  • Bug fix (change which fixes an issue)
  • New feature (change which adds functionality)
  • Documentation update (improves or adds clarity to existing documentation)
  • Other (chores, tests, code style improvements etc.)

Tested on

  • iOS
  • Android

Testing instructions

  1. Run the computer-vision example app.
  2. Open Classification, pick an XNNPACK model — it downloads with progress, then loads and runs.
  3. Reopen the screen — the model is served from cache instantly (no re-download).
  4. Open Speech-to-Text — model, tokenizer and the nested VAD model resolve in one pass, with progress weighted across all three.
  5. Imperative: createClassifier(await download(models.classification.EFFICIENTNET_V2_S.XNNPACK_FP32)).

Screenshots

Related issues

Closes #1253

Checklist

  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • I have updated the documentation accordingly
  • My changes generate no new warnings

Additional notes

  • Swaps the react-native-fs peer dependency for react-native-blob-util.
  • Android must use DownloadManager.
  • isEmulator is now installed on the __rnexecutorch_jsi__ module by the native layer.
  • File management (list/delete downloaded models) is intentionally out of scope; the fetcher only downloads and returns paths.

@msluszniak
msluszniak marked this pull request as draft July 24, 2026 16:12
@msluszniak msluszniak self-assigned this Jul 25, 2026
@msluszniak msluszniak added refactoring feature PRs that implement a new feature labels Jul 25, 2026
@msluszniak msluszniak linked an issue Jul 25, 2026 that may be closed by this pull request
@msluszniak
msluszniak marked this pull request as ready for review July 25, 2026 09:58
Comment thread packages/react-native-executorch/src/utils.ts
Comment thread packages/react-native-executorch/src/fetcher/telemetry.ts Outdated
Comment thread packages/react-native-executorch/src/fetcher/fetcher.ts
Comment thread packages/react-native-executorch/src/fetcher/ResourceFetcher.ts Outdated
Replace the temporary react-native-fs download hook with a single
blob-util-backed fetcher living in react-native-executorch (no separate
expo/bare packages).

- src/fetcher: imperative `download(source | source[], { onProgress, signal })`
  returning local path(s); persistent DocumentDir cache keyed by URL hash.
- HTTP-Range auto-resume of interrupted downloads via a .partial file, with a
  safe fallback to a fresh full download if partial assembly fails.
- Byte-weighted unified progress across multiple files; AbortSignal cancel that
  preserves bytes for later resume.
- Bundled download telemetry (HF download counter + anonymous download event),
  fired once per genuine, non-cached fetch.
- Rewire useResourceDownload + inspectModel onto the new fetcher; swap the
  react-native-fs dependency for react-native-blob-util in the lib and example
  apps.

Closes #1253
…large downloads

On-device testing revealed react-native-blob-util's in-process streaming
download is broken on modern Android (a 0.24.10 regression, upstream #475):
it aborts after 8 KB with "Download interrupted", so every model download
failed. curl and the system DownloadManager fetch the same URLs fine.

Split the fetcher backend by platform:
- Android: route through blob-util's system DownloadManager. It reliably
  handles files >2 GB (the whole point — LLM .pte files exceed the OkHttp
  2 GB in-process limit), continues in the background / across app kill, and
  resumes transient network drops itself. Downloads stage in the app-private
  external files dir so DownloadManager can write there and the move into the
  cache stays on one volume (no multi-GB cross-filesystem copy).
- iOS: keep the NSURLSession streaming path with .partial/HTTP-Range resume
  (unaffected by the Android regression, no 2 GB limit).

Verified on a physical Android device: byte-weighted progress, cache-hit
short-circuit, and create<Task>(models.X) URL resolution all pass end to end.
Analytics stay enabled by default; setTelemetryEnabled(false) opts out of the
anonymous download-event POST to Software Mansion. The Hugging Face download
counter is unaffected and always fires.
…e in a config

Downloading is now fully separated from pipeline creation: `create<Task>`
factories stay untouched and `download()` becomes a single generic entry point.

`download(source)` accepts any nested structure of plain objects and arrays,
downloads every string leaf that is an http(s) URL, and returns the value with
those URLs replaced by local paths. Non-URL leaves (local paths, labels,
thresholds) pass through untouched, so the result is structurally identical to
the input — `download<T>(source: T): Promise<T>` types correctly for every task
config without per-task overloads:

    const model = await download(models.classification.EFFICIENTNET_V2_S.XNNPACK_FP32);
    const { classify, dispose } = await createClassifier(model);

URLs are deduplicated so a file referenced twice is fetched once, and branches
with nothing to substitute keep their original reference.

`useResourceDownload` takes the whole config too and returns the resolved one,
so the hooks no longer hand-write path substitution. Whisper resolves its
model, tokenizer and nested VAD model in one pass (three hook calls before) and
now reports progress weighted across all three rather than the model alone.

Also renames `fetcher/ResourceFetcher.ts` to `fetcher/fetcher.ts` for
consistency with the surrounding file naming.
inspectModel deliberately bypasses `download()` so inspection doesn't populate
the persistent resource cache. That was only an inline comment; now that the
utility is part of the public API, document the consequence in its JSDoc —
inspecting a remote model re-downloads it on every call and leaves nothing
behind.
Download analytics read `globalThis.__rne_isEmulator`, a global the old
architecture set from its native installer. The rewrite's installer never set
it, so the flag was silently always false and emulator traffic was
indistinguishable from real devices.

Install the value on the `__rnexecutorch_jsi__` module object instead of adding
a second bare global, and read it from there. Detection is ported from the old
installer: Android reads `ro.build.fingerprint` / `ro.hardware` (goldfish and
ranchu are the QEMU emulator kernels), Apple platforms use TARGET_OS_SIMULATOR.
@msluszniak
msluszniak force-pushed the @ms/resource-fetcher branch from 6c9c66e to cc2b050 Compare August 4, 2026 07:29

@barhanc barhanc left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We should also delete the resource fetcher packages. It can be in a follow-up PR though as not to clutter this one.

Comment thread packages/react-native-executorch/src/fetcher/telemetry.ts Outdated
} catch {}
}

function getCountryCode(): string {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This might return language code which is not a valid country code, e.g. if locale is 'en'.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yes, I know. But this is how it has been implemented since now. Do you know if there are any better ways to get country code?

Comment thread packages/react-native-executorch/src/fetcher/telemetry.ts Outdated
Comment thread packages/react-native-executorch/src/hooks/useClassifier.ts Outdated
Comment thread packages/react-native-executorch/src/fetcher/fetcher.ts Outdated
// Android: the app-private EXTERNAL files dir (getExternalFilesDir), so the
// system DownloadManager can write there and same-volume moves stay cheap
// even for multi-GB files. Falls back to DocumentDir if unmounted.
const ANDROID_DIRECTORY = RNBlobUtil.fs.dirs.SDCardDir || RNBlobUtil.fs.dirs.DocumentDir;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

According to clanker RNBlobUtil.fs.dirs.SDCardDir resolves to the root external storage directory (/storage/emulated/0), not an app-private external files directory. On Android 10+ (API 29+), Scoped Storage blocks direct writes to /storage/emulated/0/react-native-executorch without broad WRITE_EXTERNAL_STORAGE or MANAGE_EXTERNAL_STORAGE permissions. Android DownloadManager and RNBlobUtil.fs.mkdir/mv operations will throw EACCES (Permission denied).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I'll check that.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I think this claim is genuinely wrong. With current version (0.24.10) of blob-util, SDCardDir is getExternalFilesDirPath(ctx, null)ctx.getExternalFilesDir(null). This results in /storage/emulated/0/Android/data/<pkg>/files directory which is cool. The legacy and incorrect one is: LegacySDCardDir (Environment.getExternalStorageDirectory()). I already tested fetcher on Android, and if it would be a problem, this should have probably appeared.

Comment on lines +347 to +352
const received = new Array<number>(urls.length).fill(0);
const report = () => {
if (!options.onProgress) return;
const sum = received.reduce((a, b) => a + b, 0);
options.onProgress(total > 0 ? Math.min(sum / total, 1) : 0);
};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This seems slightly convoluted. Why do we need an array received, wouldn't just a single number that we update directly in onBytes be enough?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Probably not, a single accumulator would be wrong. onBytes reports absolute bytes-so-far per file, not deltas. DownloadManager gives cumulative and the iOS path reports offset + recv to account for resume. += would double-count every progress tick.

Comment thread packages/react-native-executorch/src/fetcher/fetcher.ts Outdated
Comment thread packages/react-native-executorch/cpp/core/utils.cpp Outdated
Comment on lines +32 to +39
// Downloading is keyed purely on the URLs referenced by `config`, so passing
// an inline config object doesn't restart the download on every render.
const sourcesKey = useMemo(() => [...collectRemoteSources(config)].sort().join('\n'), [config]);

// Read at substitution time only, so unrelated config edits don't re-download.
const configRef = useRef(config);
configRef.current = config;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Couldn't we just do something like this. We don't need to expose the collectRemoteSources and substituteRemoteSources this way and since the download caches downloads internally there shouldn't be any problems with re-renders.

export function useResourceDownload<T>(config: T, preventLoad?: boolean) {
  const [resource, setResource] = useState<T>();
  const [downloadProgress, setDownloadProgress] = useState(0);
  const [downloadError, setDownloadError] = useState<Error | null>(null);

  useEffect(() => {
    setResource(undefined);
    setDownloadProgress(0);
    setDownloadError(null);

    if (preventLoad) return;

    let isMounted = true;
    const controller = new AbortController();

    download(config, {
      signal: controller.signal,
      onProgress: (progress) => {
        if (isMounted) setDownloadProgress(progress * 100);
      },
    })
      .then((resolved) => {
        if (!isMounted) return;
        setResource(resolved);
        setDownloadProgress(100);
      })
      .catch((e) => {
        if (!isMounted || e instanceof AbortError) return;
        setDownloadError(e instanceof Error ? e : new Error(String(e)));
      });

    return () => {
      isMounted = false;
      controller.abort();
    };
  }, [config, preventLoad]);

  return { resource, downloadProgress, downloadError };
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Ok, I'll test it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Deps [config, preventLoad] are fine for every current call site, but an inline config: useClassifier({ modelPath, modelOpts }) loops forever. Effect runs → setResource(resolved) → re-render → new object identity → effect re-runs. So this really only prevents re-downloads. Each cycle still tears down and reloads the native model via useModel. So we need to decide: indirection and tolerate inline configs or your variant, document that configs must be referentially stable. I think your approach is a cleaner one, but want to make sure wa are on the same page.

…leanups

Concurrent `download()` calls for the same URL each missed the cache check,
double-counted the fetch in telemetry, and wrote the same temporary file — on
Android the second call's opening `unlink` deleted the first one's partially
downloaded data. Downloads are now shared through an in-flight registry: one
request per URL, progress fanned out to every joined caller, and the underlying
request cancelled only once the last caller has aborted.

Review cleanups alongside it:

- Add a `forceDownload` option to re-fetch an already cached resource.
- Return the full resolved resource from the `use<Task>` hooks instead of just
  `localPath`, so callers can manage every downloaded file.
- Read `rnexecutorchJsi.isEmulator` directly instead of wrapping it.
- Widen Android emulator detection: Cuttlefish (`cutf`/`vsoc` hardware) and
  `sdk_gphone` / `google_sdk` / `Emulator` product models were all missed.
- Report swallowed telemetry failures via `console.warn` under `__DEV__`
  instead of silently discarding them.
- Rename `downloadOne` to `downloadUrl` and the platform backends to
  `downloadUrlViaAndroidDownloadManager` / `downloadUrlViaIosStream`.
- Import telemetry as a namespace, drop the single-use
  `DownloadProgressCallback` type, and build `collectRemoteSources`' accumulator
  in an inner closure rather than a default parameter.
@msluszniak

Copy link
Copy Markdown
Member Author

We should also delete the resource fetcher packages. It can be in a follow-up PR though as not to clutter this one.

What do you mean by deleting fetcher packages? We cannot delete them as such, because we need them for versions <= 0.9.x. I guess the first moment when we could remove them will be release of version 2.0.0. In the current implementation we don't ship any resource fetchers.

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

Labels

feature PRs that implement a new feature refactoring

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[RNE Rewrite] Integrate ResourceFetcher with refactor

2 participants