[RNE Rewrite] Integrate resource fetcher (react-native-blob-util) - #1328
[RNE Rewrite] Integrate resource fetcher (react-native-blob-util)#1328msluszniak wants to merge 8 commits into
Conversation
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.
6c9c66e to
cc2b050
Compare
barhanc
left a comment
There was a problem hiding this comment.
We should also delete the resource fetcher packages. It can be in a follow-up PR though as not to clutter this one.
| } catch {} | ||
| } | ||
|
|
||
| function getCountryCode(): string { |
There was a problem hiding this comment.
This might return language code which is not a valid country code, e.g. if locale is 'en'.
There was a problem hiding this comment.
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?
| // 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; |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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.
| 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); | ||
| }; |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
| // 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; | ||
|
|
There was a problem hiding this comment.
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 };
}There was a problem hiding this comment.
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.
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. |
Description
Adds resource fetching to the rewrite as a single
react-native-blob-util-backed fetcher insidereact-native-executorch. Replaces the temporaryreact-native-fshook.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 anhttp(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: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?
Type of change
Tested on
Testing instructions
computer-visionexample app.createClassifier(await download(models.classification.EFFICIENTNET_V2_S.XNNPACK_FP32)).Screenshots
Related issues
Closes #1253
Checklist
Additional notes
react-native-fspeer dependency forreact-native-blob-util.isEmulatoris now installed on the__rnexecutorch_jsi__module by the native layer.