From 0beeda18defe198f84a2cd7c8511875632f9deef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20S=C5=82uszniak?= Date: Fri, 24 Jul 2026 11:54:34 +0200 Subject: [PATCH 1/5] [RNE Rewrite] feat: integrate resource fetcher on react-native-blob-util 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 --- apps/computer-vision/package.json | 1 + apps/nlp/package.json | 1 + apps/speech/package.json | 1 + packages/react-native-executorch/package.json | 4 +- .../src/fetcher/ResourceFetcher.ts | 244 ++++++++++++++++++ .../src/fetcher/index.ts | 2 + .../src/fetcher/telemetry.ts | 79 ++++++ .../src/hooks/useResourceDownload.ts | 88 ++----- packages/react-native-executorch/src/index.ts | 3 + packages/react-native-executorch/src/utils.ts | 11 +- yarn.lock | 73 +++--- 11 files changed, 394 insertions(+), 113 deletions(-) create mode 100644 packages/react-native-executorch/src/fetcher/ResourceFetcher.ts create mode 100644 packages/react-native-executorch/src/fetcher/index.ts create mode 100644 packages/react-native-executorch/src/fetcher/telemetry.ts diff --git a/apps/computer-vision/package.json b/apps/computer-vision/package.json index f8a99e6a6b..0f7d19cd7e 100644 --- a/apps/computer-vision/package.json +++ b/apps/computer-vision/package.json @@ -37,6 +37,7 @@ "expo-router": "~56.2.9", "react": "19.2.3", "react-native": "0.85.3", + "react-native-blob-util": "^0.24.0", "react-native-drawer-layout": "^4.2.2", "react-native-executorch": "workspace:*", "react-native-gesture-handler": "~2.31.1", diff --git a/apps/nlp/package.json b/apps/nlp/package.json index 4216dc89cb..b932d704ba 100644 --- a/apps/nlp/package.json +++ b/apps/nlp/package.json @@ -27,6 +27,7 @@ "expo-router": "~56.2.9", "react": "19.2.3", "react-native": "0.85.3", + "react-native-blob-util": "^0.24.0", "react-native-drawer-layout": "^4.2.2", "react-native-executorch": "workspace:*", "react-native-gesture-handler": "~2.31.1", diff --git a/apps/speech/package.json b/apps/speech/package.json index 9bbe0197d2..c461c21484 100644 --- a/apps/speech/package.json +++ b/apps/speech/package.json @@ -27,6 +27,7 @@ "react": "19.2.3", "react-native": "0.85.3", "react-native-audio-api": "0.13.1", + "react-native-blob-util": "^0.24.0", "react-native-device-info": "^15.0.2", "react-native-drawer-layout": "^4.2.2", "react-native-executorch": "workspace:*", diff --git a/packages/react-native-executorch/package.json b/packages/react-native-executorch/package.json index ad2b4b435e..9311d4952c 100644 --- a/packages/react-native-executorch/package.json +++ b/packages/react-native-executorch/package.json @@ -85,7 +85,7 @@ "peerDependencies": { "react": "*", "react-native": "*", - "react-native-fs": "^2.20.0", + "react-native-blob-util": "^0.24.0", "react-native-worklets": "*" }, "devDependencies": { @@ -96,8 +96,8 @@ "del-cli": "^6.0.0", "react": "19.2.0", "react-native": "0.83.6", + "react-native-blob-util": "^0.24.0", "react-native-builder-bob": "^0.40.18", - "react-native-fs": "^2.20.0", "react-native-worklets": "0.9.1", "typescript": "~5.9.2" }, diff --git a/packages/react-native-executorch/src/fetcher/ResourceFetcher.ts b/packages/react-native-executorch/src/fetcher/ResourceFetcher.ts new file mode 100644 index 0000000000..345fea24c4 --- /dev/null +++ b/packages/react-native-executorch/src/fetcher/ResourceFetcher.ts @@ -0,0 +1,244 @@ +/* eslint-disable no-bitwise */ +import RNBlobUtil from 'react-native-blob-util'; +import { triggerDownloadEvent, triggerHuggingFaceDownloadCounter } from './telemetry'; + +// Persistent, per-app directory where downloaded model assets are cached. We use +// DocumentDir rather than CacheDir so the OS won't evict large models between +// runs, forcing a costly re-download. +const RNE_DIRECTORY = `${RNBlobUtil.fs.dirs.DocumentDir}/react-native-executorch`; + +/** + * Progress callback reporting overall completion as a fraction in `[0, 1]`. + * @category Types + */ +export type DownloadProgressCallback = (progress: number) => void; + +/** + * Options controlling a {@link download} call. + * @category Types + */ +export interface DownloadOptions { + /** Called with overall progress in `[0, 1]` as bytes arrive. */ + onProgress?: DownloadProgressCallback; + /** + * Aborts the download. Bytes fetched so far are kept on disk so a later + * {@link download} of the same source resumes instead of restarting. + */ + signal?: AbortSignal; +} + +// djb2 — cheap, dependency-free hash used to derive a stable cache key per URL. +const djb2 = (s: string): number => { + let h = 5381; + for (let i = 0; i < s.length; i++) { + h = (((h << 5) + h) ^ s.charCodeAt(i)) >>> 0; + } + return h; +}; + +const isRemote = (source: string): boolean => /^https?:\/\//.test(source); + +function cachePathFor(url: string): string { + const urlWithoutQuery = url.split('?')[0]!; + const basename = urlWithoutQuery.split('/').pop() || 'model'; + // Hash guards against basename collisions across different repos/versions. + return `${RNE_DIRECTORY}/${djb2(urlWithoutQuery)}_${basename}`; +} + +async function fileSize(path: string): Promise { + try { + const stat = await RNBlobUtil.fs.stat(path); + return Number(stat.size) || 0; + } catch { + return 0; + } +} + +// Best-effort remote content length via a HEAD request; 0 when unknown. +async function remoteSize(url: string): Promise { + try { + const res = await fetch(url, { method: 'HEAD' }); + const len = res.headers.get('content-length'); + return len ? Number(len) : 0; + } catch { + return 0; + } +} + +function abortError(): Error { + const err = new Error('The download was aborted.'); + err.name = 'AbortError'; + return err; +} + +interface DownloadOneCallbacks { + // Reports absolute received/total bytes for this file, including any bytes + // already present from a previous partial download. + onBytes?: (received: number, total: number) => void; + signal?: AbortSignal; +} + +/** + * Downloads a single remote file into the cache with HTTP-Range resume support. + * Returns the local path. `canResume` is set to `false` on an internal retry to + * avoid recursing forever when partial-file assembly fails. + */ +async function downloadOne( + url: string, + cb: DownloadOneCallbacks, + canResume = true +): Promise { + const dest = cachePathFor(url); + + // Cache hit — nothing to download. + if (await RNBlobUtil.fs.exists(dest)) { + const size = await fileSize(dest); + cb.onBytes?.(size, size); + return dest; + } + + // Count this actual (non-cached) fetch once — not on the internal retry. + if (canResume) { + triggerHuggingFaceDownloadCounter(url); + triggerDownloadEvent(url); + } + + await RNBlobUtil.fs.mkdir(RNE_DIRECTORY).catch(() => {}); + + const part = `${dest}.partial`; + if (!canResume) await RNBlobUtil.fs.unlink(part).catch(() => {}); + const offset = canResume ? await fileSize(part) : 0; + + // Resumed byte ranges land in a separate chunk file that we append onto the + // partial; fresh downloads stream straight into the partial. + const target = offset > 0 ? `${dest}.chunk` : part; + await RNBlobUtil.fs.unlink(target).catch(() => {}); // clear any stale chunk + + const headers: Record = {}; + if (offset > 0) headers.Range = `bytes=${offset}-`; + + if (cb.signal?.aborted) throw abortError(); + + const task = RNBlobUtil.config({ path: target, fileCache: true }).fetch('GET', url, headers); + const onAbort = () => task.cancel(); + cb.signal?.addEventListener('abort', onAbort); + + task.progress({ count: 20 }, (received, total) => { + const recv = Number(received); + const tot = Number(total); + cb.onBytes?.(offset + recv, offset + tot); + }); + + let status: number; + try { + const res = await task; + status = res.info().status; + } catch (e) { + // Network drop / cancel. Keep the fresh partial for a future resume, but + // discard a resumed chunk — its offset assumptions may not hold. + if (offset > 0) await RNBlobUtil.fs.unlink(target).catch(() => {}); + throw cb.signal?.aborted ? abortError() : e; + } finally { + cb.signal?.removeEventListener('abort', onAbort); + } + + try { + if (status === 416) { + // Range not satisfiable — the partial already holds the whole file. + await RNBlobUtil.fs.unlink(target).catch(() => {}); + } else if (status >= 400) { + await RNBlobUtil.fs.unlink(target).catch(() => {}); + throw new Error(`Download of ${url} failed with HTTP status ${status}.`); + } else if (offset > 0) { + if (status === 206) { + // Server honored the range: append the new bytes onto the partial. + await RNBlobUtil.fs.appendFile(part, `file://${target}`, 'uri'); + await RNBlobUtil.fs.unlink(target).catch(() => {}); + } else { + // Server ignored the range (200) and re-sent the whole file: replace. + await RNBlobUtil.fs.unlink(part).catch(() => {}); + await RNBlobUtil.fs.mv(target, part); + } + } + await RNBlobUtil.fs.mv(part, dest); + return dest; + } catch (assemblyErr) { + // A `4xx` we deliberately threw must propagate as-is. + if (status >= 400 && status !== 416) throw assemblyErr; + // Assembling the partial failed (e.g. append unsupported). Wipe and retry + // once as a plain full download so correctness never depends on resume. + await RNBlobUtil.fs.unlink(part).catch(() => {}); + await RNBlobUtil.fs.unlink(target).catch(() => {}); + if (canResume) return downloadOne(url, cb, false); + throw assemblyErr; + } +} + +/** + * Downloads one or more model resources into a persistent local cache and + * resolves with their local file paths. + * + * Local paths (anything not starting with `http`) are returned unchanged. + * Remote files are streamed to disk with resume support: an interrupted + * download continues from where it stopped on the next call rather than + * restarting. When several sources are passed, overall progress is weighted by + * their byte sizes so a large model isn't reported the same as a tiny + * tokenizer. + * @category Fetching + * @param source A single URL/local path, or an array of them. + * @param options Progress and cancellation options. + * @returns The local path (for a single source) or paths (for an array), + * matching the shape of `source`. + */ +export function download(source: string, options?: DownloadOptions): Promise; +export function download(sources: string[], options?: DownloadOptions): Promise; +export async function download( + source: string | string[], + options: DownloadOptions = {} +): Promise { + const single = !Array.isArray(source); + const sources = single ? [source] : source; + + const remoteIndices = sources.map((s, i) => (isRemote(s) ? i : -1)).filter((i) => i >= 0); + + // Nothing to fetch — every source is already a local path. + if (remoteIndices.length === 0) { + options.onProgress?.(1); + return single ? sources[0]! : sources; + } + + // Weight overall progress by real byte sizes so a 1 GB model isn't treated + // like a 1 MB tokenizer. When any HEAD fails we fall back to equal weighting. + const sizes = await Promise.all( + sources.map((s, i) => (remoteIndices.includes(i) ? remoteSize(s) : Promise.resolve(0))) + ); + const haveAllSizes = remoteIndices.every((i) => sizes[i]! > 0); + const total = haveAllSizes ? sizes.reduce((a, b) => a + b, 0) : remoteIndices.length; + + const received = new Array(sources.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); + }; + + const results: string[] = [...sources]; + await Promise.all( + remoteIndices.map((i) => { + const url = sources[i]!; + // Telemetry fires inside downloadOne, only for genuine (non-cached) fetches. + return downloadOne(url, { + signal: options.signal, + onBytes: (recv, tot) => { + received[i] = haveAllSizes ? recv : tot > 0 ? recv / tot : 0; + report(); + }, + }).then((path) => { + results[i] = path; + }); + }) + ); + + options.onProgress?.(1); + return single ? results[0]! : results; +} diff --git a/packages/react-native-executorch/src/fetcher/index.ts b/packages/react-native-executorch/src/fetcher/index.ts new file mode 100644 index 0000000000..85c442e601 --- /dev/null +++ b/packages/react-native-executorch/src/fetcher/index.ts @@ -0,0 +1,2 @@ +export { download } from './ResourceFetcher'; +export type { DownloadOptions, DownloadProgressCallback } from './ResourceFetcher'; diff --git a/packages/react-native-executorch/src/fetcher/telemetry.ts b/packages/react-native-executorch/src/fetcher/telemetry.ts new file mode 100644 index 0000000000..ff390c8dc1 --- /dev/null +++ b/packages/react-native-executorch/src/fetcher/telemetry.ts @@ -0,0 +1,79 @@ +import { Platform } from 'react-native'; + +// Anonymous download analytics endpoint. +const DOWNLOAD_EVENT_ENDPOINT = 'https://ai.swmansion.com/telemetry/downloads/api/downloads'; + +// Informational only. +// TODO: source this from the package version once version handling lands. +// See https://github.com/software-mansion/react-native-executorch/issues/1291 +const LIB_VERSION = '0.0.0'; + +/** + * Whether the given URL points to a Software Mansion Hugging Face repo. + */ +function isSwmHuggingFaceRepo(url: URL): boolean { + return url.host === 'huggingface.co' && url.pathname.startsWith('/software-mansion'); +} + +/** + * Increments the Hugging Face download counter for Software Mansion repos by + * issuing a HEAD request to the repo's `config.json`, following HF's + * download-stats convention. No-op for any other host. + * + * See https://huggingface.co/docs/hub/models-download-stats + * @param uri The URI of the file being downloaded. + */ +export function triggerHuggingFaceDownloadCounter(uri: string): void { + try { + const url = new URL(uri); + if (!isSwmHuggingFaceRepo(url)) return; + const base = `${url.protocol}//${url.host}${url.pathname.split('resolve')[0]}`; + // Fire-and-forget; its success is irrelevant to the download itself. + fetch(`${base}resolve/main/config.json`, { method: 'HEAD' }).catch(() => {}); + } catch {} +} + +function getCountryCode(): string { + try { + const locale = Intl.DateTimeFormat().resolvedOptions().locale; + const region = locale.split('-').pop(); + if (region && region.length === 2) return region.toUpperCase(); + } catch {} + return 'UNKNOWN'; +} + +// Set by the native layer when running on a simulator/emulator, so dev traffic +// can be filtered out server-side. Absent (⇒ false) until then. +function isEmulator(): boolean { + return (globalThis as { __rne_isEmulator?: boolean }).__rne_isEmulator === true; +} + +function getModelNameFromUri(uri: string): string { + try { + const filename = new URL(uri).pathname.split('/').pop() ?? uri; + return filename.replace(/\.[^.]+$/, ''); + } catch { + return uri; + } +} + +/** + * Sends an anonymous download event to the Software Mansion analytics endpoint. + * Fire-and-forget; never throws and never blocks the download. + * @param uri The URI of the downloaded resource. + */ +export function triggerDownloadEvent(uri: string): void { + try { + fetch(DOWNLOAD_EVENT_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + modelName: getModelNameFromUri(uri), + countryCode: getCountryCode(), + isEmulator: isEmulator(), + platform: Platform.OS, + libVersion: LIB_VERSION, + }), + }).catch(() => {}); + } catch {} +} diff --git a/packages/react-native-executorch/src/hooks/useResourceDownload.ts b/packages/react-native-executorch/src/hooks/useResourceDownload.ts index 3768910653..5043ae12a3 100644 --- a/packages/react-native-executorch/src/hooks/useResourceDownload.ts +++ b/packages/react-native-executorch/src/hooks/useResourceDownload.ts @@ -1,14 +1,5 @@ -/* eslint-disable no-bitwise */ import { useState, useEffect } from 'react'; -import RNFS from 'react-native-fs'; - -const djb2 = (s: string): number => { - let h = 5381; - for (let i = 0; i < s.length; i++) { - h = (((h << 5) + h) ^ s.charCodeAt(i)) >>> 0; - } - return h; -}; +import { download } from '../fetcher'; /** * React hook to manage downloading and local caching of remote resources (e.g. @@ -16,12 +7,11 @@ const djb2 = (s: string): number => { * * If the source is a remote URL starting with `http`, the hook checks the local * filesystem cache for a matching file. If cached, it immediately returns the - * local file path. If not cached, it starts downloading the file to the - * application cache directory, reporting download progress (0-100) and any - * network/disk errors. + * local file path. If not cached, it downloads the file into the persistent + * resource cache, reporting download progress (0-100) and any network/disk + * errors. Interrupted downloads resume on the next attempt instead of + * restarting. * @category Hooks - * @experimental Subject to change once the temporary react-native-fs dependency is replaced. - * See [Issue #1253](https://github.com/software-mansion/react-native-executorch/issues/1253). * @param source The remote URL or local path to the resource. If it's already a * local path, it is returned immediately as is. * @param preventLoad If true, prevents checks and downloads, resetting the hook @@ -46,63 +36,29 @@ export function useResourceDownload(source?: string, preventLoad?: boolean) { return; } - if (!source.startsWith('http')) { - setLocalPath(source); - setDownloadProgress(100); - return; - } - let isMounted = true; - const urlWithoutQuery = source.split('?')[0]!; - const basename = urlWithoutQuery.split('/').pop() ?? 'model'; - const dest = `${RNFS.CachesDirectoryPath}/${djb2(urlWithoutQuery)}_${basename}`; - const uid = `${Date.now()}_${Math.random().toString(36).slice(2)}`; - const tmp = `${dest}.${uid}.partial`; - - RNFS.exists(dest).then((exists) => { - if (!isMounted) return; - - if (exists) { - setLocalPath(dest); - setDownloadProgress(100); - return; - } + const controller = new AbortController(); - RNFS.downloadFile({ - fromUrl: source, - toFile: tmp, - progressInterval: 100, - begin: () => { - if (isMounted) setDownloadProgress(0); - }, - progress: (r: any) => { - if (isMounted) - setDownloadProgress(r.contentLength > 0 ? (r.bytesWritten / r.contentLength) * 100 : 0); - }, + download(source, { + signal: controller.signal, + onProgress: (progress) => { + if (isMounted) setDownloadProgress(progress * 100); + }, + }) + .then((path) => { + if (isMounted) { + setLocalPath(path); + setDownloadProgress(100); + } }) - .promise.then(async (res) => { - if (res.statusCode && res.statusCode >= 400) { - throw new Error(`Download failed with HTTP status ${res.statusCode}`); - } - try { - await RNFS.moveFile(tmp, dest); - } catch (err) { - if (!(await RNFS.exists(dest))) throw err; - await RNFS.unlink(tmp).catch(() => {}); - } - if (isMounted) { - setLocalPath(dest); - setDownloadProgress(100); - } - }) - .catch((e) => { - RNFS.unlink(tmp).catch(() => {}); - if (isMounted) setDownloadError(e instanceof Error ? e : new Error(String(e))); - }); - }); + .catch((e) => { + if (!isMounted || e?.name === 'AbortError') return; + setDownloadError(e instanceof Error ? e : new Error(String(e))); + }); return () => { isMounted = false; + controller.abort(); }; }, [source, preventLoad]); diff --git a/packages/react-native-executorch/src/index.ts b/packages/react-native-executorch/src/index.ts index 3aad5c8d77..9945683d1a 100644 --- a/packages/react-native-executorch/src/index.ts +++ b/packages/react-native-executorch/src/index.ts @@ -14,6 +14,9 @@ export * from './hooks/useTextToImage'; export * from './hooks/useResourceDownload'; export * from './hooks/useModel'; +// Resource fetching — imperative download API +export * from './fetcher'; + // Constants export { models } from './models'; export * as constants from './constants'; diff --git a/packages/react-native-executorch/src/utils.ts b/packages/react-native-executorch/src/utils.ts index 1c457b3062..be81634de8 100644 --- a/packages/react-native-executorch/src/utils.ts +++ b/packages/react-native-executorch/src/utils.ts @@ -1,6 +1,6 @@ import { rnexecutorchJsi } from './native/bridge'; import { loadModel, type ModelMethodMeta } from './core/model'; -import RNFS from 'react-native-fs'; +import RNBlobUtil from 'react-native-blob-util'; /** * Retrieves the names of all ExecuTorch backends compiled and registered in the @@ -22,7 +22,6 @@ export function getRegisteredBackends(): string[] { * (inputs/outputs shapes, types, and tags), and deletes the temporary file * before returning. * @category Utils - * @experimental Subject to change once the temporary react-native-fs dependency is replaced. See [Issue #1253](https://github.com/software-mansion/react-native-executorch/issues/1253). * @param source The remote HTTP URL or local path to the `.pte` model file. * @returns A promise resolving to an object containing the model source and * method signature metadata. @@ -35,8 +34,10 @@ export async function inspectModel(source: string): Promise<{ let downloaded = false; if (source.startsWith('http')) { - localPath = `${RNFS.TemporaryDirectoryPath}/inspect_model_${Date.now()}.pte`; - await RNFS.downloadFile({ fromUrl: source, toFile: localPath }).promise; + // Throwaway download to a temp path — inspection shouldn't populate the + // persistent resource cache, so we don't go through `download()`. + localPath = `${RNBlobUtil.fs.dirs.CacheDir}/inspect_model_${Date.now()}.pte`; + await RNBlobUtil.config({ path: localPath }).fetch('GET', source); downloaded = true; } @@ -58,7 +59,7 @@ export async function inspectModel(source: string): Promise<{ model.dispose(); } if (downloaded) { - await RNFS.unlink(localPath).catch(() => {}); + await RNBlobUtil.fs.unlink(localPath).catch(() => {}); } } } diff --git a/yarn.lock b/yarn.lock index 1bc411beae..c3ada1c5a0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6234,10 +6234,10 @@ __metadata: languageName: node linkType: hard -"base-64@npm:^0.1.0": - version: 0.1.0 - resolution: "base-64@npm:0.1.0" - checksum: 10/5a42938f82372ab5392cbacc85a5a78115cbbd9dbef9f7540fa47d78763a3a8bd7d598475f0d92341f66285afd377509851a9bb5c67bbecb89686e9255d5b3eb +"base-64@npm:1.0.0": + version: 1.0.0 + resolution: "base-64@npm:1.0.0" + checksum: 10/d10b64a1fc9b2c5a5f39f1ce1e6c9d1c5b249222bbfa3a0604c592d90623caf74419983feadd8a170f27dc0c3389704f72faafa3e645aeb56bfc030c93ff074a languageName: node linkType: hard @@ -6806,6 +6806,7 @@ __metadata: expo-router: "npm:~56.2.9" react: "npm:19.2.3" react-native: "npm:0.85.3" + react-native-blob-util: "npm:^0.24.0" react-native-drawer-layout: "npm:^4.2.2" react-native-executorch: "workspace:*" react-native-gesture-handler: "npm:~2.31.1" @@ -9036,6 +9037,17 @@ __metadata: languageName: node linkType: hard +"glob@npm:13.0.6, glob@npm:^13.0.0": + version: 13.0.6 + resolution: "glob@npm:13.0.6" + dependencies: + minimatch: "npm:^10.2.2" + minipass: "npm:^7.1.3" + path-scurry: "npm:^2.0.2" + checksum: 10/201ad69e5f0aa74e1d8c00a481581f8b8c804b6a4fbfabeeb8541f5d756932800331daeba99b58fb9e4cd67e12ba5a7eba5b82fb476691588418060b84353214 + languageName: node + linkType: hard + "glob@npm:^10.5.0": version: 10.5.0 resolution: "glob@npm:10.5.0" @@ -9052,17 +9064,6 @@ __metadata: languageName: node linkType: hard -"glob@npm:^13.0.0": - version: 13.0.6 - resolution: "glob@npm:13.0.6" - dependencies: - minimatch: "npm:^10.2.2" - minipass: "npm:^7.1.3" - path-scurry: "npm:^2.0.2" - checksum: 10/201ad69e5f0aa74e1d8c00a481581f8b8c804b6a4fbfabeeb8541f5d756932800331daeba99b58fb9e4cd67e12ba5a7eba5b82fb476691588418060b84353214 - languageName: node - linkType: hard - "glob@npm:^7.1.1, glob@npm:^7.1.3, glob@npm:^7.1.4": version: 7.2.3 resolution: "glob@npm:7.2.3" @@ -11921,6 +11922,7 @@ __metadata: expo-router: "npm:~56.2.9" react: "npm:19.2.3" react-native: "npm:0.85.3" + react-native-blob-util: "npm:^0.24.0" react-native-drawer-layout: "npm:^4.2.2" react-native-executorch: "workspace:*" react-native-gesture-handler: "npm:~2.31.1" @@ -12794,6 +12796,19 @@ __metadata: languageName: node linkType: hard +"react-native-blob-util@npm:^0.24.0": + version: 0.24.10 + resolution: "react-native-blob-util@npm:0.24.10" + dependencies: + base-64: "npm:1.0.0" + glob: "npm:13.0.6" + peerDependencies: + react: "*" + react-native: "*" + checksum: 10/3999fbe4c856fd45f09248a80c324c5cd464d9774c705138e864e06d8dab826d2be1ccba25beea09ce1352b66922094eef35435612d5b5e9bde5443803f0269a + languageName: node + linkType: hard + "react-native-builder-bob@npm:^0.40.18": version: 0.40.18 resolution: "react-native-builder-bob@npm:0.40.18" @@ -12943,34 +12958,18 @@ __metadata: del-cli: "npm:^6.0.0" react: "npm:19.2.0" react-native: "npm:0.83.6" + react-native-blob-util: "npm:^0.24.0" react-native-builder-bob: "npm:^0.40.18" - react-native-fs: "npm:^2.20.0" react-native-worklets: "npm:0.9.1" typescript: "npm:~5.9.2" peerDependencies: react: "*" react-native: "*" - react-native-fs: ^2.20.0 + react-native-blob-util: ^0.24.0 react-native-worklets: "*" languageName: unknown linkType: soft -"react-native-fs@npm:^2.20.0": - version: 2.20.0 - resolution: "react-native-fs@npm:2.20.0" - dependencies: - base-64: "npm:^0.1.0" - utf8: "npm:^3.0.0" - peerDependencies: - react-native: "*" - react-native-windows: "*" - peerDependenciesMeta: - react-native-windows: - optional: true - checksum: 10/372e59f55d5e544e87093d832896fa3d56ba9802ca140c17b4b8903afe047714e022d3b1a2ddf18d744cb7f4f973593c8083641db9ae0768688b1390078d67a2 - languageName: node - linkType: hard - "react-native-gesture-handler@npm:~2.31.1": version: 2.31.2 resolution: "react-native-gesture-handler@npm:2.31.2" @@ -14093,6 +14092,7 @@ __metadata: react: "npm:19.2.3" react-native: "npm:0.85.3" react-native-audio-api: "npm:0.13.1" + react-native-blob-util: "npm:^0.24.0" react-native-device-info: "npm:^15.0.2" react-native-drawer-layout: "npm:^4.2.2" react-native-executorch: "workspace:*" @@ -14907,13 +14907,6 @@ __metadata: languageName: node linkType: hard -"utf8@npm:^3.0.0": - version: 3.0.0 - resolution: "utf8@npm:3.0.0" - checksum: 10/31d19c4faacbb65b09ebc1c21c32b20bdb0919c6f6773cee5001b99bb83f8e503e7233c08fc71ebb34f7cfebd95cec3243b81d90176097aa2f286cccb4ce866e - languageName: node - linkType: hard - "utils-merge@npm:1.0.1": version: 1.0.1 resolution: "utils-merge@npm:1.0.1" From 7eb47bf7eca2af4a43b5b5d1832f0a6fa83b7e37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20S=C5=82uszniak?= Date: Fri, 24 Jul 2026 12:06:25 +0200 Subject: [PATCH 2/5] [RNE Rewrite] feat: resolve remote model URLs inside create factories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every create factory now runs its model/tokenizer path fields through the fetcher's download() before loading, so remote URLs are transparently downloaded and cached. Because download() passes local paths through untouched, the use hooks (which still pre-download for progress) hit an instant no-op — no double download, single telemetry event. - Wrap each loadModel/loadTokenizer arg with download() across all 12 factories (single modelPath, plus tokenizerPath and nested vadModel for the multi-file tasks). Whisper delegates VAD resolution to the FSMN factory. - Add a tuple-preserving download() overload so download([a, b]) resolves to [string, string] under noUncheckedIndexedAccess. - Imperative create(models.X) now works without an explicit download(). --- .../src/extensions/cv/tasks/classification.ts | 3 ++- .../src/extensions/cv/tasks/imageEmbedding.ts | 3 ++- .../src/extensions/cv/tasks/instanceSegmentation.ts | 3 ++- .../src/extensions/cv/tasks/keypointDetection.ts | 3 ++- .../src/extensions/cv/tasks/objectDetection.ts | 3 ++- .../src/extensions/cv/tasks/sdxsTextToImage.ts | 6 ++++-- .../src/extensions/cv/tasks/semanticSegmentation.ts | 3 ++- .../src/extensions/cv/tasks/styleTransfer.ts | 3 ++- .../src/extensions/nlp/tasks/textEmbedding.ts | 6 ++++-- .../src/extensions/nlp/tasks/tokenization.ts | 6 ++++-- .../extensions/speech/tasks/fsmnVoiceActivityDetection.ts | 3 ++- .../src/extensions/speech/tasks/whisperSpeechToText.ts | 6 ++++-- .../react-native-executorch/src/fetcher/ResourceFetcher.ts | 7 ++++++- 13 files changed, 38 insertions(+), 17 deletions(-) diff --git a/packages/react-native-executorch/src/extensions/cv/tasks/classification.ts b/packages/react-native-executorch/src/extensions/cv/tasks/classification.ts index 3a8b2d244c..e34e2bf49f 100644 --- a/packages/react-native-executorch/src/extensions/cv/tasks/classification.ts +++ b/packages/react-native-executorch/src/extensions/cv/tasks/classification.ts @@ -4,6 +4,7 @@ import { tensor } from '../../../core/tensor'; import { loadModel } from '../../../core/model'; import { validateModelSchema, SymbolicTensor } from '../../../core/modelSchema'; import { wrapAsync } from '../../../core/runtime'; +import { download } from '../../../fetcher'; import { softmax } from '../../math'; import type { ImageBuffer } from '../image'; @@ -75,7 +76,7 @@ export async function createClassifier( classifyWorklet: (input: ImageBuffer, options?: { topk?: number }) => Classification[]; }> { const { modelPath, classifierOpts } = config; - const model = await wrapAsync(loadModel, runtime)(modelPath); + const model = await wrapAsync(loadModel, runtime)(await download(modelPath)); const meta = validateModelSchema( model, diff --git a/packages/react-native-executorch/src/extensions/cv/tasks/imageEmbedding.ts b/packages/react-native-executorch/src/extensions/cv/tasks/imageEmbedding.ts index afd4c63383..8bb8b9055d 100644 --- a/packages/react-native-executorch/src/extensions/cv/tasks/imageEmbedding.ts +++ b/packages/react-native-executorch/src/extensions/cv/tasks/imageEmbedding.ts @@ -4,6 +4,7 @@ import { tensor } from '../../../core/tensor'; import { loadModel } from '../../../core/model'; import { validateModelSchema, SymbolicTensor } from '../../../core/modelSchema'; import { wrapAsync } from '../../../core/runtime'; +import { download } from '../../../fetcher'; import type { ImageBuffer } from '../image'; import { createImagePreprocessor, type ImagePreprocessorOptions } from './preprocessing'; @@ -54,7 +55,7 @@ export async function createImageEmbedder( embedWorklet: (input: ImageBuffer) => Float32Array; }> { const { modelPath, opts } = config; - const model = await wrapAsync(loadModel, runtime)(modelPath); + const model = await wrapAsync(loadModel, runtime)(await download(modelPath)); const meta = validateModelSchema( model, diff --git a/packages/react-native-executorch/src/extensions/cv/tasks/instanceSegmentation.ts b/packages/react-native-executorch/src/extensions/cv/tasks/instanceSegmentation.ts index d26407ea39..6659c84c26 100644 --- a/packages/react-native-executorch/src/extensions/cv/tasks/instanceSegmentation.ts +++ b/packages/react-native-executorch/src/extensions/cv/tasks/instanceSegmentation.ts @@ -4,6 +4,7 @@ import { tensor } from '../../../core/tensor'; import { loadModel } from '../../../core/model'; import { validateModelSchema, SymbolicTensor } from '../../../core/modelSchema'; import { wrapAsync } from '../../../core/runtime'; +import { download } from '../../../fetcher'; import type { ImageBuffer } from '../image'; import { createImagePreprocessor, type ImagePreprocessorOptions } from './preprocessing'; @@ -114,7 +115,7 @@ export async function createInstanceSegmenter( ) => InstanceSegmentationResult[]; }> { const { modelPath, opts } = config; - const model = await wrapAsync(loadModel, runtime)(modelPath); + const model = await wrapAsync(loadModel, runtime)(await download(modelPath)); const meta = validateModelSchema( model, 'forward', diff --git a/packages/react-native-executorch/src/extensions/cv/tasks/keypointDetection.ts b/packages/react-native-executorch/src/extensions/cv/tasks/keypointDetection.ts index d6ea03a294..3a0feeb3da 100644 --- a/packages/react-native-executorch/src/extensions/cv/tasks/keypointDetection.ts +++ b/packages/react-native-executorch/src/extensions/cv/tasks/keypointDetection.ts @@ -4,6 +4,7 @@ import { tensor, type Tensor } from '../../../core/tensor'; import { loadModel } from '../../../core/model'; import { validateModelSchema, SymbolicTensor } from '../../../core/modelSchema'; import { wrapAsync } from '../../../core/runtime'; +import { download } from '../../../fetcher'; import type { ImageBuffer } from '../image'; import { createImagePreprocessor, type ImagePreprocessorOptions } from './preprocessing'; @@ -180,7 +181,7 @@ export async function createKeypointDetector { const { modelPath, opts } = config; const { landmarks } = opts; - const model = await wrapAsync(loadModel, runtime)(modelPath); + const model = await wrapAsync(loadModel, runtime)(await download(modelPath)); const meta = validateModelSchema( model, 'forward', diff --git a/packages/react-native-executorch/src/extensions/cv/tasks/objectDetection.ts b/packages/react-native-executorch/src/extensions/cv/tasks/objectDetection.ts index f3289dfc5b..9cf9a21458 100644 --- a/packages/react-native-executorch/src/extensions/cv/tasks/objectDetection.ts +++ b/packages/react-native-executorch/src/extensions/cv/tasks/objectDetection.ts @@ -4,6 +4,7 @@ import { tensor } from '../../../core/tensor'; import { loadModel } from '../../../core/model'; import { validateModelSchema, SymbolicTensor } from '../../../core/modelSchema'; import { wrapAsync } from '../../../core/runtime'; +import { download } from '../../../fetcher'; import type { ResizeMode } from '../ops/image'; import type { ImageBuffer } from '../image'; @@ -94,7 +95,7 @@ export async function createObjectDetector( ) => ObjectDetection[]; }> { const { modelPath, opts } = config; - const model = await wrapAsync(loadModel, runtime)(modelPath); + const model = await wrapAsync(loadModel, runtime)(await download(modelPath)); const meta = validateModelSchema( model, diff --git a/packages/react-native-executorch/src/extensions/cv/tasks/sdxsTextToImage.ts b/packages/react-native-executorch/src/extensions/cv/tasks/sdxsTextToImage.ts index 7747a4517c..6c7a2052ca 100644 --- a/packages/react-native-executorch/src/extensions/cv/tasks/sdxsTextToImage.ts +++ b/packages/react-native-executorch/src/extensions/cv/tasks/sdxsTextToImage.ts @@ -4,6 +4,7 @@ import { tensor } from '../../../core/tensor'; import { loadModel } from '../../../core/model'; import { validateModelSchema, SymbolicTensor } from '../../../core/modelSchema'; import { wrapAsync } from '../../../core/runtime'; +import { download } from '../../../fetcher'; import { randomNormal } from '../../math'; import { loadTokenizer } from '../../nlp/tokenizer'; @@ -76,8 +77,9 @@ export async function createSdxsTextToImage( generateWorklet: (prompt: string, seed?: number) => ImageBuffer; }> { const { modelPath, tokenizerPath } = config; - const model = await wrapAsync(loadModel, runtime)(modelPath); - const tokenizer = await wrapAsync(loadTokenizer, runtime)(tokenizerPath); + const [localModelPath, localTokenizerPath] = await download([modelPath, tokenizerPath]); + const model = await wrapAsync(loadModel, runtime)(localModelPath); + const tokenizer = await wrapAsync(loadTokenizer, runtime)(localTokenizerPath); validateModelSchema( model, diff --git a/packages/react-native-executorch/src/extensions/cv/tasks/semanticSegmentation.ts b/packages/react-native-executorch/src/extensions/cv/tasks/semanticSegmentation.ts index 148cf9b267..665791607b 100644 --- a/packages/react-native-executorch/src/extensions/cv/tasks/semanticSegmentation.ts +++ b/packages/react-native-executorch/src/extensions/cv/tasks/semanticSegmentation.ts @@ -4,6 +4,7 @@ import { tensor } from '../../../core/tensor'; import { loadModel } from '../../../core/model'; import { validateModelSchema, SymbolicTensor } from '../../../core/modelSchema'; import { wrapAsync } from '../../../core/runtime'; +import { download } from '../../../fetcher'; import type { ImageBuffer } from '../image'; import { createImagePreprocessor, type ImagePreprocessorOptions } from './preprocessing'; @@ -119,7 +120,7 @@ export async function createSemanticSegmenter( ) => SemanticSegmentationResult; }> { const { modelPath, opts } = config; - const model = await wrapAsync(loadModel, runtime)(modelPath); + const model = await wrapAsync(loadModel, runtime)(await download(modelPath)); const meta = validateModelSchema( model, diff --git a/packages/react-native-executorch/src/extensions/cv/tasks/styleTransfer.ts b/packages/react-native-executorch/src/extensions/cv/tasks/styleTransfer.ts index 1c1a89cbe5..1a60d71dc9 100644 --- a/packages/react-native-executorch/src/extensions/cv/tasks/styleTransfer.ts +++ b/packages/react-native-executorch/src/extensions/cv/tasks/styleTransfer.ts @@ -4,6 +4,7 @@ import { tensor } from '../../../core/tensor'; import { loadModel } from '../../../core/model'; import { validateModelSchema, SymbolicTensor } from '../../../core/modelSchema'; import { wrapAsync } from '../../../core/runtime'; +import { download } from '../../../fetcher'; import type { ImageBuffer } from '../image'; import { createImagePreprocessor, type ImagePreprocessorOptions } from './preprocessing'; @@ -67,7 +68,7 @@ export async function createStyleTransfer( transferStyleWorklet: (input: ImageBuffer) => ImageBuffer; }> { const { modelPath, opts } = config; - const model = await wrapAsync(loadModel, runtime)(modelPath); + const model = await wrapAsync(loadModel, runtime)(await download(modelPath)); const meta = validateModelSchema( model, diff --git a/packages/react-native-executorch/src/extensions/nlp/tasks/textEmbedding.ts b/packages/react-native-executorch/src/extensions/nlp/tasks/textEmbedding.ts index 857457d89f..df3b77076c 100644 --- a/packages/react-native-executorch/src/extensions/nlp/tasks/textEmbedding.ts +++ b/packages/react-native-executorch/src/extensions/nlp/tasks/textEmbedding.ts @@ -4,6 +4,7 @@ import { tensor } from '../../../core/tensor'; import { loadModel } from '../../../core/model'; import { validateModelSchema, SymbolicTensor } from '../../../core/modelSchema'; import { wrapAsync } from '../../../core/runtime'; +import { download } from '../../../fetcher'; import { loadTokenizer } from '../tokenizer'; @@ -60,9 +61,10 @@ export async function createTextEmbedder( embedWorklet: (input: string, prompt?: string) => Float32Array; }> { const { modelPath, tokenizerPath, defaultPrompt } = config; + const [localModelPath, localTokenizerPath] = await download([modelPath, tokenizerPath]); const [model, tokenizer] = await Promise.all([ - wrapAsync(loadModel, runtime)(modelPath), - wrapAsync(loadTokenizer, runtime)(tokenizerPath), + wrapAsync(loadModel, runtime)(localModelPath), + wrapAsync(loadTokenizer, runtime)(localTokenizerPath), ]); // Text embedding models take two int64 inputs: the token ids and the diff --git a/packages/react-native-executorch/src/extensions/nlp/tasks/tokenization.ts b/packages/react-native-executorch/src/extensions/nlp/tasks/tokenization.ts index 297028f320..4607c6eba4 100644 --- a/packages/react-native-executorch/src/extensions/nlp/tasks/tokenization.ts +++ b/packages/react-native-executorch/src/extensions/nlp/tasks/tokenization.ts @@ -1,19 +1,21 @@ import type { WorkletRuntime } from 'react-native-worklets'; import { wrapAsync } from '../../../core/runtime'; +import { download } from '../../../fetcher'; import { loadTokenizer } from '../tokenizer'; /** * Loads a tokenizer and exposes its operations with lifetime management for the * `useTokenizer` hook. * @category Typescript API - * @param tokenizerPath Absolute local path to a `tokenizer.json` file. + * @param tokenizerPath Remote URL or local path to a `tokenizer.json` file. + * Remote URLs are downloaded and cached automatically. * @param runtime Optional worklet runtime thread to run the tokenizer on. * @returns A promise resolving to the tokenizer operations and a `dispose` * handle that releases the native tokenizer. */ export async function createTokenizer(tokenizerPath: string, runtime?: WorkletRuntime) { - const tokenizer = await wrapAsync(loadTokenizer, runtime)(tokenizerPath); + const tokenizer = await wrapAsync(loadTokenizer, runtime)(await download(tokenizerPath)); const dispose = () => tokenizer.dispose(); return { diff --git a/packages/react-native-executorch/src/extensions/speech/tasks/fsmnVoiceActivityDetection.ts b/packages/react-native-executorch/src/extensions/speech/tasks/fsmnVoiceActivityDetection.ts index 9225c743ff..ca59b51a6a 100644 --- a/packages/react-native-executorch/src/extensions/speech/tasks/fsmnVoiceActivityDetection.ts +++ b/packages/react-native-executorch/src/extensions/speech/tasks/fsmnVoiceActivityDetection.ts @@ -4,6 +4,7 @@ import { tensor } from '../../../core/tensor'; import { loadModel } from '../../../core/model'; import { validateModelSchema, SymbolicTensor } from '../../../core/modelSchema'; import { wrapAsync } from '../../../core/runtime'; +import { download } from '../../../fetcher'; import { extractFrames } from '../utils/vadUtils'; /** @@ -216,7 +217,7 @@ export async function createFsmnVoiceActivityDetector( }> { const { modelPath, defaultOptions } = config; - const model = await wrapAsync(loadModel, runtime)(modelPath); + const model = await wrapAsync(loadModel, runtime)(await download(modelPath)); // Input is [frames, fftLength] with a dynamic frame count; the output is // [1, frames, classes] where class 0 is the non-speech class. The output frame diff --git a/packages/react-native-executorch/src/extensions/speech/tasks/whisperSpeechToText.ts b/packages/react-native-executorch/src/extensions/speech/tasks/whisperSpeechToText.ts index e1f14bfbb1..43280a31f6 100644 --- a/packages/react-native-executorch/src/extensions/speech/tasks/whisperSpeechToText.ts +++ b/packages/react-native-executorch/src/extensions/speech/tasks/whisperSpeechToText.ts @@ -5,6 +5,7 @@ import { tensor } from '../../../core/tensor'; import { loadModel } from '../../../core/model'; import { validateModelSchema, SymbolicTensor } from '../../../core/modelSchema'; import { wrapAsync } from '../../../core/runtime'; +import { download } from '../../../fetcher'; import { argmax } from '../../../extensions/math'; import { loadTokenizer } from '../../nlp/tokenizer'; @@ -159,8 +160,9 @@ export async function createWhisperSpeechToText void; }> { const { modelPath, tokenizerPath, supportedLanguages, vadModel } = config; - const model = await wrapAsync(loadModel, runtime)(modelPath); - const tokenizer = await wrapAsync(loadTokenizer, runtime)(tokenizerPath); + const [localModelPath, localTokenizerPath] = await download([modelPath, tokenizerPath]); + const model = await wrapAsync(loadModel, runtime)(localModelPath); + const tokenizer = await wrapAsync(loadTokenizer, runtime)(localTokenizerPath); const voiceDetector = await createFsmnVoiceActivityDetector(vadModel, runtime); const eotToken = tokenizer.tokenToId('<|endoftext|>')!; diff --git a/packages/react-native-executorch/src/fetcher/ResourceFetcher.ts b/packages/react-native-executorch/src/fetcher/ResourceFetcher.ts index 345fea24c4..d6029731e8 100644 --- a/packages/react-native-executorch/src/fetcher/ResourceFetcher.ts +++ b/packages/react-native-executorch/src/fetcher/ResourceFetcher.ts @@ -191,7 +191,12 @@ async function downloadOne( * matching the shape of `source`. */ export function download(source: string, options?: DownloadOptions): Promise; -export function download(sources: string[], options?: DownloadOptions): Promise; +// `const T` preserves tuple length so `download([a, b])` resolves to a +// fixed-length `[string, string]` rather than a `(string | undefined)[]`. +export function download( + sources: T, + options?: DownloadOptions +): Promise<{ [K in keyof T]: string }>; export async function download( source: string | string[], options: DownloadOptions = {} From 3e9729026504caefe3282870ee71ca1ec93e09f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20S=C5=82uszniak?= Date: Fri, 24 Jul 2026 13:32:47 +0200 Subject: [PATCH 3/5] [RNE Rewrite] fix(fetcher): use Android DownloadManager for reliable large downloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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(models.X) URL resolution all pass end to end. --- .../src/fetcher/ResourceFetcher.ts | 120 ++++++++++++++---- 1 file changed, 97 insertions(+), 23 deletions(-) diff --git a/packages/react-native-executorch/src/fetcher/ResourceFetcher.ts b/packages/react-native-executorch/src/fetcher/ResourceFetcher.ts index d6029731e8..ab64dd7511 100644 --- a/packages/react-native-executorch/src/fetcher/ResourceFetcher.ts +++ b/packages/react-native-executorch/src/fetcher/ResourceFetcher.ts @@ -1,11 +1,20 @@ /* eslint-disable no-bitwise */ +import { Platform } from 'react-native'; import RNBlobUtil from 'react-native-blob-util'; import { triggerDownloadEvent, triggerHuggingFaceDownloadCounter } from './telemetry'; -// Persistent, per-app directory where downloaded model assets are cached. We use -// DocumentDir rather than CacheDir so the OS won't evict large models between -// runs, forcing a costly re-download. -const RNE_DIRECTORY = `${RNBlobUtil.fs.dirs.DocumentDir}/react-native-executorch`; +const IS_ANDROID = Platform.OS === 'android'; + +// Persistent, per-app directory where downloaded model assets are cached. +// iOS: internal DocumentDir (not CacheDir) so the OS won't evict large models +// between runs and force a costly re-download. +// 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; +const RNE_DIRECTORY = IS_ANDROID + ? `${ANDROID_DIRECTORY}/react-native-executorch` + : `${RNBlobUtil.fs.dirs.DocumentDir}/react-native-executorch`; /** * Progress callback reporting overall completion as a fraction in `[0, 1]`. @@ -21,8 +30,8 @@ export interface DownloadOptions { /** Called with overall progress in `[0, 1]` as bytes arrive. */ onProgress?: DownloadProgressCallback; /** - * Aborts the download. Bytes fetched so far are kept on disk so a later - * {@link download} of the same source resumes instead of restarting. + * Aborts the download. On iOS the bytes fetched so far are kept on disk so a + * later {@link download} of the same source resumes instead of restarting. */ signal?: AbortSignal; } @@ -79,15 +88,10 @@ interface DownloadOneCallbacks { } /** - * Downloads a single remote file into the cache with HTTP-Range resume support. - * Returns the local path. `canResume` is set to `false` on an internal retry to - * avoid recursing forever when partial-file assembly fails. + * Downloads a single remote file into the cache, dispatching to the + * platform-appropriate backend. Returns the local path. */ -async function downloadOne( - url: string, - cb: DownloadOneCallbacks, - canResume = true -): Promise { +async function downloadOne(url: string, cb: DownloadOneCallbacks): Promise { const dest = cachePathFor(url); // Cache hit — nothing to download. @@ -97,14 +101,83 @@ async function downloadOne( return dest; } - // Count this actual (non-cached) fetch once — not on the internal retry. - if (canResume) { - triggerHuggingFaceDownloadCounter(url); - triggerDownloadEvent(url); - } + // Count this actual (non-cached) fetch once. + triggerHuggingFaceDownloadCounter(url); + triggerDownloadEvent(url); await RNBlobUtil.fs.mkdir(RNE_DIRECTORY).catch(() => {}); + return IS_ANDROID ? downloadViaDownloadManager(url, dest, cb) : downloadViaStream(url, dest, cb); +} + +/** + * Android backend: the system DownloadManager streams to app-private external + * storage. Unlike blob-util's in-process reader it handles files larger than + * 2 GB, keeps downloading while the app is backgrounded or killed, and resumes + * across transient network drops on its own — so no manual Range logic here. + */ +async function downloadViaDownloadManager( + url: string, + dest: string, + cb: DownloadOneCallbacks +): Promise { + const tmp = `${dest}.downloading`; + await RNBlobUtil.fs.unlink(tmp).catch(() => {}); + + if (cb.signal?.aborted) throw abortError(); + + const task = RNBlobUtil.config({ + addAndroidDownloads: { + useDownloadManager: true, + path: tmp, + notification: false, + mediaScannable: false, + mime: 'application/octet-stream', + }, + }).fetch('GET', url); + + const onAbort = () => task.cancel(); + cb.signal?.addEventListener('abort', onAbort); + + // DownloadManager reports total as -1 until the size is known; forward the + // received byte count regardless so byte-weighted progress still advances. + task.progress({ count: 100 }, (received, total) => { + const recv = Number(received); + const tot = Number(total); + cb.onBytes?.(recv, tot > 0 ? tot : recv); + }); + + try { + await task; + } catch (e) { + await RNBlobUtil.fs.unlink(tmp).catch(() => {}); + throw cb.signal?.aborted ? abortError() : e; + } finally { + cb.signal?.removeEventListener('abort', onAbort); + } + + // DownloadManager doesn't surface an HTTP status; an empty file means failure. + const size = await fileSize(tmp); + if (size <= 0) { + await RNBlobUtil.fs.unlink(tmp).catch(() => {}); + throw new Error(`Download of ${url} failed (empty response).`); + } + await RNBlobUtil.fs.mv(tmp, dest); + return dest; +} + +/** + * iOS backend: blob-util streams via NSURLSession straight to disk. Interrupted + * downloads resume from a `.partial` file via an HTTP Range request. `canResume` + * is set to `false` on an internal retry to avoid recursing forever if + * partial-file assembly ever fails. + */ +async function downloadViaStream( + url: string, + dest: string, + cb: DownloadOneCallbacks, + canResume = true +): Promise { const part = `${dest}.partial`; if (!canResume) await RNBlobUtil.fs.unlink(part).catch(() => {}); const offset = canResume ? await fileSize(part) : 0; @@ -169,7 +242,7 @@ async function downloadOne( // once as a plain full download so correctness never depends on resume. await RNBlobUtil.fs.unlink(part).catch(() => {}); await RNBlobUtil.fs.unlink(target).catch(() => {}); - if (canResume) return downloadOne(url, cb, false); + if (canResume) return downloadViaStream(url, dest, cb, false); throw assemblyErr; } } @@ -179,9 +252,10 @@ async function downloadOne( * resolves with their local file paths. * * Local paths (anything not starting with `http`) are returned unchanged. - * Remote files are streamed to disk with resume support: an interrupted - * download continues from where it stopped on the next call rather than - * restarting. When several sources are passed, overall progress is weighted by + * Remote files are downloaded to a persistent cache: on Android via the system + * DownloadManager (handles multi-GB files and continues in the background), on + * iOS via a streaming request that resumes an interrupted download from where + * it stopped. When several sources are passed, overall progress is weighted by * their byte sizes so a large model isn't reported the same as a tiny * tokenizer. * @category Fetching From 9a60511e7b0ee2c06144d8c1691dd525b0983dd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20S=C5=82uszniak?= Date: Fri, 24 Jul 2026 18:21:58 +0200 Subject: [PATCH 4/5] chore(fetcher): satisfy jsdoc lint (internal helpers, @category) --- .../src/fetcher/ResourceFetcher.ts | 28 ++++++++----------- .../src/fetcher/telemetry.ts | 4 +-- 2 files changed, 12 insertions(+), 20 deletions(-) diff --git a/packages/react-native-executorch/src/fetcher/ResourceFetcher.ts b/packages/react-native-executorch/src/fetcher/ResourceFetcher.ts index ab64dd7511..9107bb611a 100644 --- a/packages/react-native-executorch/src/fetcher/ResourceFetcher.ts +++ b/packages/react-native-executorch/src/fetcher/ResourceFetcher.ts @@ -87,10 +87,8 @@ interface DownloadOneCallbacks { signal?: AbortSignal; } -/** - * Downloads a single remote file into the cache, dispatching to the - * platform-appropriate backend. Returns the local path. - */ +// Downloads a single remote file into the cache, dispatching to the +// platform-appropriate backend. Returns the local path. async function downloadOne(url: string, cb: DownloadOneCallbacks): Promise { const dest = cachePathFor(url); @@ -110,12 +108,10 @@ async function downloadOne(url: string, cb: DownloadOneCallbacks): Promise Date: Tue, 28 Jul 2026 15:06:06 +0200 Subject: [PATCH 5/5] feat(fetcher): add setTelemetryEnabled opt-out for download analytics 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. --- .../src/fetcher/index.ts | 1 + .../src/fetcher/telemetry.ts | 21 +++++++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/react-native-executorch/src/fetcher/index.ts b/packages/react-native-executorch/src/fetcher/index.ts index 85c442e601..da2e00d719 100644 --- a/packages/react-native-executorch/src/fetcher/index.ts +++ b/packages/react-native-executorch/src/fetcher/index.ts @@ -1,2 +1,3 @@ export { download } from './ResourceFetcher'; export type { DownloadOptions, DownloadProgressCallback } from './ResourceFetcher'; +export { setTelemetryEnabled } from './telemetry'; diff --git a/packages/react-native-executorch/src/fetcher/telemetry.ts b/packages/react-native-executorch/src/fetcher/telemetry.ts index b9feeaaf86..2060dae92b 100644 --- a/packages/react-native-executorch/src/fetcher/telemetry.ts +++ b/packages/react-native-executorch/src/fetcher/telemetry.ts @@ -8,6 +8,21 @@ const DOWNLOAD_EVENT_ENDPOINT = 'https://ai.swmansion.com/telemetry/downloads/ap // See https://github.com/software-mansion/react-native-executorch/issues/1291 const LIB_VERSION = '0.0.0'; +// Anonymous analytics are on by default; apps opt out via setTelemetryEnabled. +let telemetryEnabled = true; + +/** + * Enables or disables the anonymous download analytics sent to Software Mansion. + * Analytics are enabled by default; call `setTelemetryEnabled(false)` (e.g. once + * at app startup) to opt out. This does not affect the Hugging Face download + * counter, a standard model-download stat that always fires. + * @category Utils + * @param enabled Whether to send anonymous download analytics. + */ +export function setTelemetryEnabled(enabled: boolean): void { + telemetryEnabled = enabled; +} + // Whether the given URL points to a Software Mansion Hugging Face repo. function isSwmHuggingFaceRepo(url: URL): boolean { return url.host === 'huggingface.co' && url.pathname.startsWith('/software-mansion'); @@ -56,11 +71,13 @@ function getModelNameFromUri(uri: string): string { } /** - * Sends an anonymous download event to the Software Mansion analytics endpoint. - * Fire-and-forget; never throws and never blocks the download. + * Sends an anonymous download event to the Software Mansion analytics endpoint, + * unless the app has opted out via {@link setTelemetryEnabled}. Fire-and-forget; + * never throws and never blocks the download. * @param uri The URI of the downloaded resource. */ export function triggerDownloadEvent(uri: string): void { + if (!telemetryEnabled) return; try { fetch(DOWNLOAD_EVENT_ENDPOINT, { method: 'POST',