Skip to content

Commit da22171

Browse files
authored
refactor: resource fetcher (#1023)
## Description - Extracted shared base class (BaseResourceFetcherClass<TDownload>) into the core package containing the fetch loop, source dispatching, and pause/resume/cancel plumbing — eliminating duplication between expo and bare implementations - Extracted platform-specific handlers (handlers.ts) for both packages, separating download mechanics from the class structure - Rewrote both fetchers as classes extending the base, implementing only platform-specific file system and download operations - Replaced the linked-list download pattern with a simple sequential for loop - Cancel now throws DownloadInterrupted instead of returning null, giving callers a consistent error-based API **Note**: this PR still uses the legacy FS as the support for background / resumable downloads is WIP. ### Introduces a breaking change? - [ ] Yes - [x] No ### Type of change - [ ] Bug fix (change which fixes an issue) - [ ] New feature (change which adds functionality) - [ ] Documentation update (improves or adds clarity to existing documentation) - [x] Other (chores, tests, code style improvements etc.) ### Tested on - [ ] iOS - [x] Android ### Testing instructions 1. Create a demo app trying to run all the functionalities exposed in Resource Fetchers 2. Fetch function with single and/or multiple resources 3. Cancel long fetching file, also verify fetch throws an error when its canceled 4. Check if a model with downloaded file works 5. Make sure deleting resources work 6. Try running a background download ### Screenshots <!-- Add screenshots here, if applicable --> ### Related issues <!-- Link related issues here using #issue-number --> ### Checklist - [ ] I have performed a self-review of my code - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have updated the documentation accordingly - [ ] My changes generate no new warnings ### Additional notes <!-- Include any additional information, assumptions, or context that reviewers might need to understand this PR. -->
1 parent 61560ff commit da22171

10 files changed

Lines changed: 989 additions & 1094 deletions

File tree

packages/bare-resource-fetcher/src/ResourceFetcher.ts

Lines changed: 120 additions & 541 deletions
Large diffs are not rendered by default.

packages/bare-resource-fetcher/src/ResourceFetcherUtils.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,13 @@ import {
66
HTTP_CODE,
77
DownloadStatus,
88
SourceType,
9-
ResourceSourceExtended,
109
RnExecutorchError,
1110
RnExecutorchErrorCode,
1211
} from 'react-native-executorch';
1312
import { Image } from 'react-native';
1413
import * as RNFS from '@dr.pogodin/react-native-fs';
1514

1615
export { HTTP_CODE, DownloadStatus, SourceType };
17-
export type { ResourceSourceExtended };
1816

1917
/**
2018
* Utility functions for fetching and managing resources.
Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
import {
2+
createDownloadTask,
3+
completeHandler,
4+
DownloadTask,
5+
BeginHandlerParams,
6+
ProgressHandlerParams,
7+
} from '@kesha-antonov/react-native-background-downloader';
8+
import * as RNFS from '@dr.pogodin/react-native-fs';
9+
import { Image, Platform } from 'react-native';
10+
import {
11+
ResourceSource,
12+
RnExecutorchErrorCode,
13+
RnExecutorchError,
14+
} from 'react-native-executorch';
15+
import { RNEDirectory } from './constants/directories';
16+
import { ResourceFetcherUtils, DownloadStatus } from './ResourceFetcherUtils';
17+
18+
export interface ActiveDownload {
19+
status: DownloadStatus;
20+
uri: string;
21+
fileUri: string;
22+
cacheFileUri: string;
23+
// resolve and reject are the resolve/reject of the Promise returned by handleRemote.
24+
// They are stored here so that cancel() and resume() in the fetcher class can
25+
// unblock the fetch() loop from outside the download flow.
26+
resolve: (path: string) => void;
27+
reject: (error: unknown) => void;
28+
// iOS only: background downloader task, used for pause/resume/cancel
29+
task?: DownloadTask;
30+
// Android only: RNFS job ID, used for cancel via RNFS.stopDownload
31+
jobId?: number;
32+
}
33+
34+
interface DownloadContext {
35+
uri: string;
36+
source: ResourceSource;
37+
fileUri: string;
38+
cacheFileUri: string;
39+
progressCallback: (progress: number) => void;
40+
downloads: Map<ResourceSource, ActiveDownload>;
41+
resolve: (path: string) => void;
42+
reject: (error: unknown) => void;
43+
}
44+
45+
export async function handleObject(source: object): Promise<string> {
46+
const jsonString = JSON.stringify(source);
47+
const digest = ResourceFetcherUtils.hashObject(jsonString);
48+
const path = `${RNEDirectory}${digest}.json`;
49+
50+
if (await ResourceFetcherUtils.checkFileExists(path)) {
51+
return ResourceFetcherUtils.removeFilePrefix(path);
52+
}
53+
54+
await ResourceFetcherUtils.createDirectoryIfNoExists();
55+
await RNFS.writeFile(path, jsonString, 'utf8');
56+
return ResourceFetcherUtils.removeFilePrefix(path);
57+
}
58+
59+
export function handleLocalFile(source: string): string {
60+
return ResourceFetcherUtils.removeFilePrefix(source);
61+
}
62+
63+
export async function handleAsset(
64+
source: number,
65+
progressCallback: (progress: number) => void,
66+
downloads: Map<ResourceSource, ActiveDownload>
67+
): Promise<string> {
68+
const assetSource = Image.resolveAssetSource(source);
69+
const uri = assetSource.uri;
70+
71+
if (uri.startsWith('http')) {
72+
// Dev mode: asset served from Metro dev server.
73+
// uri is the resolved HTTP URL; source is the original require() number the
74+
// user holds, so it must be used as the downloads map key for pause/cancel to work.
75+
return (await handleRemote(uri, source, progressCallback, downloads)).path;
76+
}
77+
78+
// Release mode: asset bundled locally, copy to RNEDirectory
79+
const filename = ResourceFetcherUtils.getFilenameFromUri(uri);
80+
const fileUri = `${RNEDirectory}${filename}`;
81+
82+
if (await ResourceFetcherUtils.checkFileExists(fileUri)) {
83+
return ResourceFetcherUtils.removeFilePrefix(fileUri);
84+
}
85+
86+
await ResourceFetcherUtils.createDirectoryIfNoExists();
87+
if (uri.startsWith('file')) {
88+
await RNFS.copyFile(uri, fileUri);
89+
}
90+
return ResourceFetcherUtils.removeFilePrefix(fileUri);
91+
}
92+
93+
function startAndroidDownload(ctx: DownloadContext): void {
94+
const {
95+
uri,
96+
source,
97+
fileUri,
98+
cacheFileUri,
99+
progressCallback,
100+
downloads,
101+
resolve,
102+
reject,
103+
} = ctx;
104+
105+
const rnfsDownload = RNFS.downloadFile({
106+
fromUrl: uri,
107+
toFile: cacheFileUri,
108+
progress: (res: { bytesWritten: number; contentLength: number }) => {
109+
if (res.contentLength > 0) {
110+
progressCallback(res.bytesWritten / res.contentLength);
111+
}
112+
},
113+
progressInterval: 500,
114+
});
115+
116+
downloads.set(source, {
117+
status: DownloadStatus.ONGOING,
118+
uri,
119+
fileUri,
120+
cacheFileUri,
121+
resolve,
122+
reject,
123+
jobId: rnfsDownload.jobId,
124+
});
125+
126+
rnfsDownload.promise
127+
.then(async (result: { statusCode: number }) => {
128+
if (!downloads.has(source)) return; // canceled externally via cancel()
129+
130+
if (result.statusCode < 200 || result.statusCode >= 300) {
131+
downloads.delete(source);
132+
reject(
133+
new RnExecutorchError(
134+
RnExecutorchErrorCode.ResourceFetcherDownloadFailed,
135+
`Failed to fetch resource from '${uri}', status: ${result.statusCode}`
136+
)
137+
);
138+
return;
139+
}
140+
141+
try {
142+
await RNFS.moveFile(cacheFileUri, fileUri);
143+
} catch (error) {
144+
downloads.delete(source);
145+
reject(error);
146+
return;
147+
}
148+
149+
downloads.delete(source);
150+
resolve(ResourceFetcherUtils.removeFilePrefix(fileUri));
151+
})
152+
.catch((error: unknown) => {
153+
if (!downloads.has(source)) return; // canceled externally
154+
downloads.delete(source);
155+
reject(
156+
new RnExecutorchError(
157+
RnExecutorchErrorCode.ResourceFetcherDownloadFailed,
158+
`Failed to fetch resource from '${uri}', context: ${error}`
159+
)
160+
);
161+
});
162+
}
163+
164+
function startIOSDownload(ctx: DownloadContext): void {
165+
const {
166+
uri,
167+
source,
168+
fileUri,
169+
cacheFileUri,
170+
progressCallback,
171+
downloads,
172+
resolve,
173+
reject,
174+
} = ctx;
175+
const filename = cacheFileUri.split('/').pop()!;
176+
177+
const task = createDownloadTask({
178+
id: filename,
179+
url: uri,
180+
destination: cacheFileUri,
181+
})
182+
.begin((_: BeginHandlerParams) => progressCallback(0))
183+
.progress((progress: ProgressHandlerParams) => {
184+
progressCallback(progress.bytesDownloaded / progress.bytesTotal);
185+
})
186+
.done(async () => {
187+
const downloadHandle = downloads.get(source);
188+
// If paused or canceled, resolve/reject will be called externally — do nothing here.
189+
if (!downloadHandle || downloadHandle.status === DownloadStatus.PAUSED)
190+
return;
191+
192+
try {
193+
await RNFS.moveFile(cacheFileUri, fileUri);
194+
// Required by the background downloader library to signal iOS that the
195+
// background download session is complete.
196+
const fn = fileUri.split('/').pop();
197+
if (fn) completeHandler(fn);
198+
} catch (error) {
199+
downloads.delete(source);
200+
reject(error);
201+
return;
202+
}
203+
204+
downloads.delete(source);
205+
resolve(ResourceFetcherUtils.removeFilePrefix(fileUri));
206+
})
207+
.error((error: any) => {
208+
if (!downloads.has(source)) return; // canceled externally
209+
downloads.delete(source);
210+
reject(
211+
new RnExecutorchError(
212+
RnExecutorchErrorCode.ResourceFetcherDownloadFailed,
213+
`Failed to fetch resource from '${uri}', context: ${error}`
214+
)
215+
);
216+
});
217+
218+
task.start();
219+
220+
downloads.set(source, {
221+
status: DownloadStatus.ONGOING,
222+
uri,
223+
fileUri,
224+
cacheFileUri,
225+
resolve,
226+
reject,
227+
task,
228+
});
229+
}
230+
231+
// uri and source are separate parameters because for asset sources (dev mode),
232+
// source is the require() number the user holds (used as the downloads map key),
233+
// while uri is the resolved HTTP URL needed for the actual download.
234+
// For plain remote strings they are the same value.
235+
export async function handleRemote(
236+
uri: string,
237+
source: ResourceSource,
238+
progressCallback: (progress: number) => void,
239+
downloads: Map<ResourceSource, ActiveDownload>
240+
): Promise<{ path: string; wasDownloaded: boolean }> {
241+
if (downloads.has(source)) {
242+
throw new RnExecutorchError(
243+
RnExecutorchErrorCode.ResourceFetcherDownloadInProgress,
244+
'Already downloading this file'
245+
);
246+
}
247+
248+
const filename = ResourceFetcherUtils.getFilenameFromUri(uri);
249+
const fileUri = `${RNEDirectory}${filename}`;
250+
const cacheFileUri = `${RNFS.CachesDirectoryPath}/${filename}`;
251+
252+
if (await ResourceFetcherUtils.checkFileExists(fileUri)) {
253+
return {
254+
path: ResourceFetcherUtils.removeFilePrefix(fileUri),
255+
wasDownloaded: false,
256+
};
257+
}
258+
259+
await ResourceFetcherUtils.createDirectoryIfNoExists();
260+
261+
// We need a Promise whose resolution can be triggered from outside this function —
262+
// by cancel() or resume() in the fetcher class. A plain async function can't do that,
263+
// so we create the Promise manually and store resolve/reject in the downloads map.
264+
let resolve: (path: string) => void = () => {};
265+
let reject: (error: unknown) => void = () => {};
266+
const promise = new Promise<string>((res, rej) => {
267+
resolve = res;
268+
reject = rej;
269+
});
270+
271+
const ctx: DownloadContext = {
272+
uri,
273+
source,
274+
fileUri,
275+
cacheFileUri,
276+
progressCallback,
277+
downloads,
278+
resolve,
279+
reject,
280+
};
281+
282+
if (Platform.OS === 'android') {
283+
startAndroidDownload(ctx);
284+
} else {
285+
startIOSDownload(ctx);
286+
}
287+
288+
return promise.then((path) => ({ path, wasDownloaded: true }));
289+
}

0 commit comments

Comments
 (0)