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
3 changes: 3 additions & 0 deletions .cspell-wordlist.txt
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ RNFS
pogodin
kesha
antonov
Radtke
rfdetr
basemodule
IMAGENET
Expand Down Expand Up @@ -347,3 +348,5 @@ unclip
phonemizations
phonemizes
həlˈoʊ
NSURL
backgrounding
2 changes: 1 addition & 1 deletion packages/react-native-executorch/__tests__/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ misleading leak error. A test that means to leak calls `allowNativeLeaks()`.
| Path | Contents |
| --- | --- |
| `core/` | `tensor`, `model`, `runtime`, the coded `error` type, and the `schema` spec matcher |
| `fetcher/` | `download` (caching, resume, cancellation, shared requests), telemetry, the Android backend |
| `fetcher/` | `download` (caching, resume, cancellation, shared requests), telemetry, the Android backend, the optional background downloader |
| `tasks/` | One suite per task pipeline, plus the shared construction-failure behavior. `remainingTasks.ts` holds the pipelines that only get schema acceptance and disposal |
| `hooks/` | `useModel`, `useResourceDownload`, and the task hooks end to end |
| `extensions/` | The pure-TypeScript helpers: box/point scaling, seeded generators |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
/**
* The optional background downloader, on both platforms.
*
* `src/fetcher/fetcher.ts` prefers `@kesha-antonov/react-native-background-downloader`
* over the per-platform fallbacks whenever the app has it, so the behavior worth
* pinning here is that the SAME backend is chosen on iOS and Android — the point
* of preferring it is that an app installing it stops getting a different
* download mechanism per platform.
*
* The package is an optional peer dependency and is not installed for these
* suites, so it is mocked virtually. Both the platform and the presence of the
* native module are read at import time (`Platform.OS`, `TurboModuleRegistry`),
* and `loadBackgroundDownloader` memoizes its answer, so each case re-imports
* the module graph — hence the `load()` helper rather than file-level imports.
*/
import type { Route } from '../support/blobUtilMock';
import type {
BackgroundDownloader,
BackgroundDownloadTask,
} from '../../src/fetcher/backgroundDownloader';

const PACKAGE = '@kesha-antonov/react-native-background-downloader';
const NATIVE_MODULE = 'RNBackgroundDownloader';

const URL_A = 'https://huggingface.co/software-mansion/model/resolve/v1/model.pte';
const HF_COUNTER = 'https://huggingface.co/software-mansion/model/resolve/main/config.json';

const BODY = 'model-bytes';

type FakeDownloader = BackgroundDownloader & {
/** Every task the fetcher actually started, in order. */
started: { id: string; url: string; destination: string }[];
};

/**
* A stand-in for the library that completes every transfer immediately.
*
* It writes the body straight to the task's `destination`, which is what the
* real one does on Android; on iOS the file is staged privately and moved there
* at the end. Either way the fetcher only ever sees it at `destination`, so one
* fake covers both.
* @param write Writes a file into the blob-util mock's filesystem.
* @returns The fake downloader, with the tasks it started recorded on it.
*/
function createFakeDownloader(write: (path: string, contents: string) => void): FakeDownloader {
const started: FakeDownloader['started'] = [];

return {
started,

createDownloadTask({ id, url, destination }) {
let onBegin: ((params: { expectedBytes: number }) => void) | undefined;
let onProgress:
| ((params: { bytesDownloaded: number; bytesTotal: number }) => void)
| undefined;
let onDone: ((params: { bytesDownloaded: number; bytesTotal: number }) => void) | undefined;

const task: BackgroundDownloadTask = {
id,
state: 'PENDING',
begin: (handler) => {
onBegin = handler;
return task;
},
progress: (handler) => {
onProgress = handler;
return task;
},
done: (handler) => {
onDone = handler;
return task;
},
error: () => task,
start: () => {
started.push({ id, url, destination });
const total = BODY.length;
onBegin?.({ expectedBytes: total });
onProgress?.({ bytesDownloaded: total, bytesTotal: total });
write(destination, BODY);
onDone?.({ bytesDownloaded: total, bytesTotal: total });
},
pause: async () => {},
resume: async () => {},
stop: async () => {},
};

return task;
},

getExistingDownloadTasks: async () => [],
completeHandler: () => {},
};
}

type Harness = {
download: typeof import('../../src/fetcher/fetcher').download;
downloader: FakeDownloader;
serve: (url: string, route?: Route) => void;
paths: () => string[];
readText: (path: string) => string | undefined;
countRequests: (method: string, url: string) => number;
};

const load = async (os: 'ios' | 'android'): Promise<Harness> => {
jest.resetModules();

// A Proxy rather than a spread: the `react-native` entry point defines its
// exports as lazy getters, and spreading it evaluates every one of them —
// including native modules like `DevMenu` that do not exist under Jest.
jest.doMock('react-native', () => {
const actual = jest.requireActual('react-native');
return new Proxy(actual, {
get: (target, property) => {
if (property === 'Platform') return { ...target.Platform, OS: os };
// Having the JS is not having the native module, and the fetcher checks
// for it before using the library — so the fake has to be visible here
// too, under the name both platforms register it as.
if (property === 'TurboModuleRegistry') {
return {
...target.TurboModuleRegistry,
get: (name: string) => (name === NATIVE_MODULE ? {} : null),
};
}
return target[property as keyof typeof target];
},
});
});

const blobUtil = await import('../support/blobUtilMock');
const downloader = createFakeDownloader(blobUtil.fakeFs.write);
jest.doMock(PACKAGE, () => downloader, { virtual: true });

const { download } = await import('../../src/fetcher/fetcher');
const { setTelemetryEnabled } = await import('../../src/fetcher/telemetry');
setTelemetryEnabled(false);

// The freshly loaded fetcher talks to the freshly loaded mock, so the global
// `fetch` installed by the shared setup file has to be pointed at it.
globalThis.fetch = blobUtil.fakeFetch as unknown as typeof globalThis.fetch;
blobUtil.fakeNet.serve(HF_COUNTER);

return {
download,
downloader,
serve: blobUtil.fakeNet.serve,
paths: blobUtil.fakeFs.paths,
readText: blobUtil.fakeFs.readText,
countRequests: blobUtil.fakeNet.countRequests,
};
};

afterEach(() => {
jest.dontMock('react-native');
jest.dontMock(PACKAGE);
jest.resetModules();
});

describe.each(['ios', 'android'] as const)(
'download on %s with the background downloader',
(os) => {
it('routes the transfer through it instead of the platform fallback', async () => {
const harness = await load(os);
harness.serve(URL_A, { body: BODY });

const path = await harness.download(URL_A);

expect(harness.downloader.started).toHaveLength(1);
expect(harness.downloader.started[0]!.url).toBe(URL_A);
// Neither fallback ran: both of them fetch through blob-util.
expect(harness.countRequests('GET', URL_A)).toBe(0);
expect(harness.readText(path)).toBe(BODY);
});

it('stages through a partial file and leaves none behind', async () => {
const harness = await load(os);
harness.serve(URL_A, { body: BODY });

const path = await harness.download(URL_A);

expect(harness.downloader.started[0]!.destination).toBe(`${path}.partial`);
expect(harness.paths().filter((p) => p.endsWith('.partial'))).toEqual([]);
});

it('serves a second call from the cache', async () => {
const harness = await load(os);
harness.serve(URL_A, { body: BODY });

const first = await harness.download(URL_A);
const second = await harness.download(URL_A);

expect(second).toBe(first);
expect(harness.downloader.started).toHaveLength(1);
});
}
);

// Installing the package must not move the cache: the directory is chosen per
// platform and a model already downloaded under the fallback has to stay a hit.
it('keeps caching under the app-private external directory on Android', async () => {
const harness = await load('android');
harness.serve(URL_A, { body: BODY });

const path = await harness.download(URL_A);

expect(path.startsWith('/fake/sdcard/react-native-executorch/')).toBe(true);
});
6 changes: 6 additions & 0 deletions packages/react-native-executorch/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,17 @@
"@huggingface/jinja": "^0.5.9"
},
"peerDependencies": {
"@kesha-antonov/react-native-background-downloader": ">=4.4.0",
"react": "*",
"react-native": "*",
"react-native-blob-util": "^0.24.0",
"react-native-worklets": "^0.10.0"
},
"peerDependenciesMeta": {
"@kesha-antonov/react-native-background-downloader": {
"optional": true
}
},
"devDependencies": {
"@babel/core": "^7.25.1",
"@react-native/babel-preset": "0.83.6",
Expand Down
100 changes: 100 additions & 0 deletions packages/react-native-executorch/src/fetcher/backgroundDownloader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { NativeModules, TurboModuleRegistry } from 'react-native';

// Optional integration with `@kesha-antonov/react-native-background-downloader`.
//
// An in-process transfer on iOS dies with the app: measured on device, suspending
// it tore the connection down ONE SECOND later, 43 MB into a 314 MB file, and only
// a background `NSURLSession` keeps going. That session is native work, and a
// download helper is not the part of this library that should be growing native
// code for it — there are React Native packages that do nothing else.
//
// So the fetcher uses one WHEN THE APP HAPPENS TO HAVE IT INSTALLED. It is an
// optional peer dependency: apps that install it get transfers that survive
// backgrounding (and an app kill), apps that don't keep the platform's own
// backend and pull in nothing.
//
// It backs BOTH platforms, so an app that installs it gets one download
// mechanism behaving the same way everywhere rather than a per-platform one the
// caller cannot see. The library covers both natively — a background
// `NSURLSession` on iOS, a foreground service on Android — and exposes the same
// task API for each, which is why the backend below needs no platform split.

// The slice of the library's API the fetcher uses, declared structurally so this
// file typechecks with the dependency absent.
export interface BackgroundDownloadTask {
id: string;
state: 'PENDING' | 'DOWNLOADING' | 'PAUSED' | 'DONE' | 'FAILED' | 'STOPPED';
begin(handler: (params: { expectedBytes: number }) => void): BackgroundDownloadTask;
progress(
handler: (params: { bytesDownloaded: number; bytesTotal: number }) => void
): BackgroundDownloadTask;
done(
handler: (params: { bytesDownloaded: number; bytesTotal: number }) => void
): BackgroundDownloadTask;
error(handler: (params: { error: string; errorCode: number }) => void): BackgroundDownloadTask;
start(): void;
// Keeps the bytes fetched so far as resume data, and resolves once that data
// has been written — not merely once the transfer has been asked to stop.
pause(): Promise<void>;
resume(): Promise<void>;
// Cancels and discards, unlike `pause`.
stop(): Promise<void>;
}

export interface BackgroundDownloader {
createDownloadTask(options: {
id: string;
url: string;
destination: string;
}): BackgroundDownloadTask;
// Tasks the session is still holding, including ones started by a previous
// launch of the app and ones paused with resume data.
getExistingDownloadTasks(): Promise<BackgroundDownloadTask[]>;
// Releases the OS's background-session completion handler for a finished task.
// Resolves through the native module, so it can also reject.
completeHandler(id: string): void | Promise<void>;
}

// `null` once resolution has been attempted and come up empty; `undefined` while
// it has not been attempted at all.
let cached: BackgroundDownloader | null | undefined;

// The library, or null when the app has not installed it (or installed the JS
// without linking the native side, as in Expo Go). Resolved once and remembered.
export function loadBackgroundDownloader(): BackgroundDownloader | null {
if (cached !== undefined) return cached;
cached = null;

try {
// A `require` inside a try/catch is Metro's own escape hatch for optional
// dependencies (`resolver.allowOptionalDependencies`, which React Native's
// Metro config turns on): a module that isn't installed is left unresolved
// instead of failing the bundle, and the throw lands right here. The name
// has to stay a literal — Metro collects dependencies statically and rejects
// a `require` of anything it cannot read off the call itself.
const required = require('@kesha-antonov/react-native-background-downloader');
const candidate = (required?.default ?? required) as Partial<BackgroundDownloader>;

// Having the JS is not the same as having the native module: without the
// native build step (or in Expo Go) every call would throw, so fall back to
// the platform's own backend instead. The module name is the same on both
// platforms.
const isLinked =
TurboModuleRegistry.get('RNBackgroundDownloader') != null ||
NativeModules.RNBackgroundDownloader != null;

// Older majors expose a different surface (`download`, and a `pause` that
// drops the fetched bytes). Treat anything but the shape used below as
// absent rather than half-supporting it.
const hasApi =
typeof candidate?.createDownloadTask === 'function' &&
typeof candidate?.getExistingDownloadTasks === 'function' &&
typeof candidate?.completeHandler === 'function';

if (isLinked && hasApi) cached = candidate as BackgroundDownloader;
} catch {
// Not installed — the fetcher stays on its per-platform fallback.
}

return cached;
}
Loading