Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/computer-vision/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
"expo-router": "~56.2.9",
"react": "19.2.3",
"react-native": "0.85.3",
"react-native-blob-util": "^0.24.0",
"react-native-blob-util": "^0.25.0",
"react-native-drawer-layout": "^4.2.2",
"react-native-executorch": "workspace:*",
"react-native-gesture-handler": "~2.31.1",
Expand Down
2 changes: 1 addition & 1 deletion apps/nlp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +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-blob-util": "^0.25.0",
"react-native-drawer-layout": "^4.2.2",
"react-native-executorch": "workspace:*",
"react-native-gesture-handler": "~2.31.1",
Expand Down
2 changes: 1 addition & 1 deletion apps/speech/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,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-blob-util": "^0.25.0",
"react-native-device-info": "^15.0.2",
"react-native-drawer-layout": "^4.2.2",
"react-native-executorch": "workspace:*",
Expand Down
10 changes: 3 additions & 7 deletions docs/docs/01-fundamentals/02-downloading-models.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,7 @@ common needs:
isn't reported the same as a small tokenizer.
- [**`signal`**](../06-api-reference/interfaces/DownloadOptions.md#signal) — an
`AbortSignal` to cancel. The bytes fetched so far are kept so a later download
of the same source resumes instead of restarting (except on Android without
the optional background downloader, where the system `DownloadManager` discards
a cancelled transfer).
of the same source resumes instead of restarting.
- [**`forceDownload`**](../06-api-reference/interfaces/DownloadOptions.md#forcedownload) —
re-download even when cached.

Expand All @@ -111,10 +109,8 @@ launches fast:

- A file that is already cached resolves immediately — no network round trip.
- Concurrent downloads of the same URL are deduplicated into one transfer.
- Without extra dependencies, fetching falls back to what each platform supports
natively: the system `DownloadManager` on Android (which continues in the
background), and a streaming request on iOS (which pauses when the app is
suspended and resumes when reopened).
- Without extra dependencies, both platforms fall back to the same streaming
request, which pauses when the app is suspended and resumes when reopened.
- To keep transfers running in the background across both iOS and Android and survive
the app being killed, install the optional peer dependency
[`@kesha-antonov/react-native-background-downloader`](https://github.com/kesha-antonov/react-native-background-downloader)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
/**
* The Android download path.
*
* `src/fetcher/fetcher.ts` decides between the two backends once, at module
* scope (`const IS_ANDROID = Platform.OS === 'android'`), so exercising the
* Android branch means re-importing the module with a different `Platform`.
* That also re-instantiates the blob-util mock, so every handle used here has
* to come from the same fresh module registry — hence the `load()` helper
* rather than the file-level imports the other fetcher suites use.
* Android used to have a backend of its own — the system `DownloadManager` —
* because blob-util's in-process reader stopped after 8 KB
* (RonRadtke/react-native-blob-util#475). That fix shipped in 0.24.11, so both
* platforms now share the streaming fallback, and what is left that is specific
* to Android is the cache directory.
*
* `src/fetcher/fetcher.ts` derives that directory once, at module scope
* (`const IS_ANDROID = Platform.OS === 'android'`), so exercising the Android
* branch means re-importing the module with a different `Platform`. That also
* re-instantiates the blob-util mock, so every handle used here has to come from
* the same fresh module registry — hence the `load()` helper rather than the
* file-level imports the other fetcher suites use.
*/
import type { Route } from '../support/blobUtilMock';

Expand All @@ -16,8 +22,12 @@ const HF_COUNTER = 'https://huggingface.co/software-mansion/model/resolve/main/c
type Android = {
download: typeof import('../../src/fetcher/fetcher').download;
serve: (url: string, route?: Route) => void;
requests: () => readonly { method: string; url: string; headers: Record<string, string> }[];
paths: () => string[];
readText: (path: string) => string | undefined;
write: (path: string, contents: string) => void;
remove: (path: string) => void;
has: (path: string) => boolean;
countRequests: (method: string, url: string) => number;
};

Expand Down Expand Up @@ -49,8 +59,12 @@ const load = async (): Promise<Android> => {
return {
download,
serve: blobUtil.fakeNet.serve,
requests: blobUtil.fakeNet.requests,
paths: blobUtil.fakeFs.paths,
readText: blobUtil.fakeFs.readText,
write: blobUtil.fakeFs.write,
remove: blobUtil.fakeFs.remove,
has: blobUtil.fakeFs.has,
countRequests: blobUtil.fakeNet.countRequests,
};
};
Expand All @@ -71,30 +85,36 @@ describe('download on Android', () => {
expect(android.readText(path)).toBe('model-bytes');
});

it('downloads through a temporary file and moves it into place', async () => {
it('resumes from a partial file with a Range request, like iOS', async () => {
const android = await load();
android.serve(URL_A);
android.serve(URL_A, { body: 'abcdefgh' });

await android.download(URL_A);

expect(android.paths().filter((p) => p.endsWith('.downloading'))).toEqual([]);
});
// Stage the aftermath of an interrupted download: the cached file is gone
// and `partial` bytes sit next to it. DownloadManager never resumed through
// a Range request, so this is what proves the shared backend is in use.
const path = await android.download(URL_A);
android.remove(path);
android.write(`${path}.partial`, 'abc');
const before = android.requests().length;

it('treats an empty response as a failure, since DownloadManager reports no status', async () => {
const android = await load();
android.serve(URL_A, { body: '' });
await android.download(URL_A);

await expect(android.download(URL_A)).rejects.toThrow(/empty response/);
expect(android.paths()).toEqual([]);
const ranged = android
.requests()
.slice(before)
.find((r) => r.headers.Range !== undefined);
expect(ranged?.headers.Range).toBe('bytes=3-');
expect(android.readText(path)).toBe('abcdefgh');
expect(android.has(`${path}.partial`)).toBe(false);
});

it('does not send a Range header — DownloadManager resumes on its own', async () => {
it('leaves no temporary files behind on success', async () => {
const android = await load();
android.serve(URL_A);

await android.download(URL_A);

expect(android.countRequests('GET', URL_A)).toBe(1);
expect(android.paths().filter((p) => /\.(partial|chunk|downloading)$/.test(p))).toEqual([]);
});

it('serves a second call from the cache', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,7 @@ describe('download — concurrent callers', () => {
});
});

describe('download — iOS resume', () => {
describe('download — resume', () => {
/**
* Stages the aftermath of an interrupted download: the cached file is gone
* and `partial` bytes are sitting next to it. The cache path is only known
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ type FetchTask = Promise<{ info: () => { status: number } }> & {
progress: (config: { count?: number }, cb: ProgressCallback) => FetchTask;
// Undocumented in blob-util's typings, but real: it reports the response
// state, so `src/` learns the status as soon as the headers land rather than
// only once the body is complete. See `downloadUrlViaIosStream`.
// only once the body is complete. See `downloadUrlViaStream`.
stateChange: (cb: StateChangeCallback) => FetchTask;
cancel: () => void;
};
Expand All @@ -223,14 +223,13 @@ class CancelledError extends Error {
}

type Config = {
/** Destination path for a streamed download (iOS-style). */
/** Destination path for a streamed download. */
path?: string;
fileCache?: boolean;
addAndroidDownloads?: { path?: string; useDownloadManager?: boolean; [key: string]: unknown };
};

function startFetch(config: Config, method: string, url: string, headers: Record<string, string>) {
const dest = config.addAndroidDownloads?.path ?? config.path;
const dest = config.path;
requests.push({ method, url, headers });

let onProgress: ProgressCallback | undefined;
Expand Down
4 changes: 2 additions & 2 deletions packages/react-native-executorch/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@
"@kesha-antonov/react-native-background-downloader": ">=4.4.0",
"react": "*",
"react-native": "*",
"react-native-blob-util": "^0.24.0",
"react-native-blob-util": ">=0.24.11",
"react-native-worklets": ">=0.10.0 <0.13.0"
},
"peerDependenciesMeta": {
Expand All @@ -160,7 +160,7 @@
"jest": "^29.7.0",
"react": "19.2.0",
"react-native": "0.83.6",
"react-native-blob-util": "^0.24.0",
"react-native-blob-util": "^0.25.0",
"react-native-builder-bob": "^0.40.18",
"react-native-worklets": "0.10.3",
"test-renderer": "^1.2.0",
Expand Down
Loading
Loading