|
23 | 23 | * - Pause/resume/cancel operations work only for remote resources |
24 | 24 | * - Most functions accept multiple `ResourceSource` arguments (string, number, or object) |
25 | 25 | * - The {@link fetch} method accepts a progress callback (0-1) and returns file paths or null if interrupted |
26 | | - * |
27 | | - * **Technical Implementation:** |
28 | | - * - Maintains a `downloads` Map to track active and paused downloads |
29 | | - * - Successful downloads are automatically removed from the Map |
30 | | - * - Uses `ResourceSourceExtended` interface for pause/resume functionality with linked-list behavior |
31 | 26 | */ |
32 | 27 |
|
| 28 | +import { |
| 29 | + deleteAsync, |
| 30 | + readDirectoryAsync, |
| 31 | + readAsStringAsync, |
| 32 | + moveAsync, |
| 33 | +} from 'expo-file-system/legacy'; |
| 34 | +import { RNEDirectory } from './constants/directories'; |
33 | 35 | import { |
34 | 36 | ResourceSource, |
35 | 37 | ResourceFetcherAdapter, |
36 | | - DownloadStatus, |
37 | | - SourceType, |
38 | | - RnExecutorchError, |
39 | 38 | RnExecutorchErrorCode, |
| 39 | + RnExecutorchError, |
40 | 40 | } from 'react-native-executorch'; |
41 | 41 | import { |
42 | | - ResourceSourceExtended, |
43 | | - DownloadResource, |
44 | 42 | ResourceFetcherUtils, |
| 43 | + HTTP_CODE, |
| 44 | + DownloadStatus, |
| 45 | + SourceType, |
45 | 46 | } from './ResourceFetcherUtils'; |
46 | 47 | import { |
47 | | - cacheDirectory, |
48 | | - createDownloadResumable, |
49 | | - DownloadResumable, |
50 | | -} from 'expo-file-system/legacy'; |
51 | | -import { RNEDirectory } from './constants/directories'; |
| 48 | + type ActiveDownload, |
| 49 | + handleObject, |
| 50 | + handleLocalFile, |
| 51 | + handleAsset, |
| 52 | + handleRemote, |
| 53 | +} from './handlers'; |
52 | 54 |
|
53 | | -export interface DownloadMetadata { |
54 | | - source: ResourceSource; |
55 | | - type: SourceType; |
56 | | - remoteUri?: string; |
57 | | - localUri: string; |
58 | | - onDownloadProgress?: (downloadProgress: number) => void; |
59 | | -} |
| 55 | +class ExpoResourceFetcherClass implements ResourceFetcherAdapter { |
| 56 | + private downloads = new Map<ResourceSource, ActiveDownload>(); |
60 | 57 |
|
61 | | -export interface DownloadAsset { |
62 | | - downloadObj: DownloadResumable; |
63 | | - status: DownloadStatus; |
64 | | - metadata: DownloadMetadata; |
65 | | -} |
| 58 | + /** |
| 59 | + * Fetches resources (remote URLs, local files or embedded assets), downloads or stores them locally |
| 60 | + * for use by React Native ExecuTorch. |
| 61 | + * |
| 62 | + * @param callback - Optional callback to track progress of all downloads, reported between 0 and 1. |
| 63 | + * @param sources - Multiple resources that can be strings, asset references, or objects. |
| 64 | + * @returns If the fetch was successful, resolves to an array of local file paths for the |
| 65 | + * downloaded/stored resources (without file:// prefix). |
| 66 | + * If the fetch was interrupted by `pauseFetching` or `cancelFetching`, resolves to `null`. |
| 67 | + */ |
| 68 | + async fetch( |
| 69 | + callback: (downloadProgress: number) => void = () => {}, |
| 70 | + ...sources: ResourceSource[] |
| 71 | + ): Promise<string[] | null> { |
| 72 | + if (sources.length === 0) { |
| 73 | + throw new RnExecutorchError( |
| 74 | + RnExecutorchErrorCode.InvalidUserInput, |
| 75 | + 'Empty list given as an argument to Resource Fetcher' |
| 76 | + ); |
| 77 | + } |
66 | 78 |
|
67 | | -interface ExpoResourceFetcherInterface extends ResourceFetcherAdapter { |
68 | | - downloadAssets: Record<string, DownloadAsset>; |
69 | | - singleFetch(source: ResourceSource): Promise<DownloadAsset>; |
70 | | - returnOrStartNext( |
71 | | - sourceExtended: ResourceSourceExtended, |
72 | | - result: string | string[] |
73 | | - ): string[] | Promise<string[] | null>; |
74 | | - pause(source: ResourceSource): Promise<void>; |
75 | | - resume(source: ResourceSource): Promise<string[] | null>; |
76 | | - cancel(source: ResourceSource): Promise<void>; |
77 | | - findActive(sources: ResourceSource[]): ResourceSource; |
78 | | - pauseFetching(...sources: ResourceSource[]): Promise<void>; |
79 | | - resumeFetching(...sources: ResourceSource[]): Promise<void>; |
80 | | - cancelFetching(...sources: ResourceSource[]): Promise<void>; |
81 | | - listDownloadedFiles(): Promise<string[]>; |
82 | | - listDownloadedModels(): Promise<string[]>; |
83 | | - deleteResources(...sources: ResourceSource[]): Promise<void>; |
84 | | - getFilesTotalSize(...sources: ResourceSource[]): Promise<number>; |
85 | | - handleObject(source: ResourceSource): Promise<string>; |
86 | | - handleLocalFile(source: ResourceSource): string; |
87 | | - handleReleaseModeFile(source: DownloadAsset): Promise<string>; |
88 | | - handleDevModeFile(source: DownloadAsset): Promise<string>; |
89 | | - handleRemoteFile(source: DownloadAsset): Promise<string>; |
90 | | - handleCachedDownload(source: ResourceSource): Promise<string>; |
91 | | -} |
| 79 | + const { results: info, totalLength } = |
| 80 | + await ResourceFetcherUtils.getFilesSizes(sources); |
| 81 | + // Key by source so we can look up progress info without relying on index alignment |
| 82 | + // (getFilesSizes skips sources whose HEAD request fails) |
| 83 | + const infoMap = new Map(info.map((entry) => [entry.source, entry])); |
| 84 | + const results: string[] = []; |
92 | 85 |
|
93 | | -export const ExpoResourceFetcher: ExpoResourceFetcherInterface = { |
94 | | - downloadAssets: {}, |
| 86 | + for (const source of sources) { |
| 87 | + const fileInfo = infoMap.get(source); |
| 88 | + const progressCallback = |
| 89 | + fileInfo?.type === SourceType.REMOTE_FILE |
| 90 | + ? ResourceFetcherUtils.calculateDownloadProgress( |
| 91 | + totalLength, |
| 92 | + fileInfo.previousFilesTotalLength, |
| 93 | + fileInfo.length, |
| 94 | + callback |
| 95 | + ) |
| 96 | + : () => {}; |
95 | 97 |
|
96 | | - async singleFetch(source: DownloadAsset): Promise<DownloadAsset> { |
97 | | - switch (source.metadata.type) { |
98 | | - case SourceType.OBJECT: { |
99 | | - return await this.handleObject(source); |
100 | | - } |
101 | | - case SourceType.LOCAL_FILE: { |
102 | | - return this.handleLocalFile(source); |
103 | | - } |
104 | | - case SourceType.RELEASE_MODE_FILE: { |
105 | | - return this.handleReleaseModeFile(source); |
106 | | - } |
107 | | - case SourceType.DEV_MODE_FILE: { |
108 | | - return this.handleDevModeFile(source); |
109 | | - } |
110 | | - case SourceType.REMOTE_FILE: { |
111 | | - return this.handleRemoteFile(source); |
112 | | - } |
| 98 | + const path = await this.fetchOne(source, progressCallback); |
| 99 | + if (path === null) return null; |
| 100 | + results.push(path); |
113 | 101 | } |
114 | | - }, |
115 | 102 |
|
116 | | - async handleCachedDownload(source: ResourceSource) {}, |
| 103 | + return results; |
| 104 | + } |
117 | 105 |
|
118 | | - async handleRemoteFile(source: ResourceSource) { |
119 | | - if (typeof source === 'object') { |
120 | | - throw new RnExecutorchError( |
121 | | - RnExecutorchErrorCode.InvalidModelSource, |
122 | | - 'Model source is expected to be a string or a number.' |
| 106 | + /** |
| 107 | + * Reads the contents of a file as a string. |
| 108 | + * |
| 109 | + * @param path - Absolute file path or file URI to read. |
| 110 | + * @returns A promise that resolves to the file contents as a string. |
| 111 | + */ |
| 112 | + async readAsString(path: string): Promise<string> { |
| 113 | + const uri = path.startsWith('file://') ? path : `file://${path}`; |
| 114 | + return readAsStringAsync(uri); |
| 115 | + } |
| 116 | + |
| 117 | + /** |
| 118 | + * Pauses an ongoing download of files. |
| 119 | + * |
| 120 | + * @param sources - The resource identifiers used when calling `fetch`. |
| 121 | + * @returns A promise that resolves once the download is paused. |
| 122 | + */ |
| 123 | + async pauseFetching(...sources: ResourceSource[]): Promise<void> { |
| 124 | + const source = this.findActive(sources); |
| 125 | + await this.pause(source); |
| 126 | + } |
| 127 | + |
| 128 | + /** |
| 129 | + * Resumes a paused download of files. |
| 130 | + * |
| 131 | + * The result of the resumed download flows back through the original `fetch` promise. |
| 132 | + * |
| 133 | + * @param sources - The resource identifiers used when calling `fetch`. |
| 134 | + * @returns A promise that resolves once the resume handoff is complete. |
| 135 | + */ |
| 136 | + async resumeFetching(...sources: ResourceSource[]): Promise<void> { |
| 137 | + const source = this.findActive(sources); |
| 138 | + await this.resume(source); |
| 139 | + } |
| 140 | + |
| 141 | + /** |
| 142 | + * Cancels an ongoing/paused download of files. |
| 143 | + * |
| 144 | + * @param sources - The resource identifiers used when calling `fetch()`. |
| 145 | + * @returns A promise that resolves once the download is canceled. |
| 146 | + */ |
| 147 | + async cancelFetching(...sources: ResourceSource[]): Promise<void> { |
| 148 | + const source = this.findActive(sources); |
| 149 | + await this.cancel(source); |
| 150 | + } |
| 151 | + |
| 152 | + /** |
| 153 | + * Lists all the downloaded files used by React Native ExecuTorch. |
| 154 | + * |
| 155 | + * @returns A promise that resolves to an array of URIs for all the downloaded files. |
| 156 | + */ |
| 157 | + async listDownloadedFiles(): Promise<string[]> { |
| 158 | + const files = await readDirectoryAsync(RNEDirectory); |
| 159 | + return files.map((file: string) => `${RNEDirectory}${file}`); |
| 160 | + } |
| 161 | + |
| 162 | + /** |
| 163 | + * Lists all the downloaded models used by React Native ExecuTorch. |
| 164 | + * |
| 165 | + * @returns A promise that resolves to an array of URIs for all the downloaded models. |
| 166 | + */ |
| 167 | + async listDownloadedModels(): Promise<string[]> { |
| 168 | + const files = await this.listDownloadedFiles(); |
| 169 | + return files.filter((file: string) => file.endsWith('.pte')); |
| 170 | + } |
| 171 | + |
| 172 | + /** |
| 173 | + * Deletes downloaded resources from the local filesystem. |
| 174 | + * |
| 175 | + * @param sources - The resource identifiers used when calling `fetch`. |
| 176 | + * @returns A promise that resolves once all specified resources have been removed. |
| 177 | + */ |
| 178 | + async deleteResources(...sources: ResourceSource[]): Promise<void> { |
| 179 | + for (const source of sources) { |
| 180 | + const filename = ResourceFetcherUtils.getFilenameFromUri( |
| 181 | + source as string |
123 | 182 | ); |
| 183 | + const fileUri = `${RNEDirectory}${filename}`; |
| 184 | + if (await ResourceFetcherUtils.checkFileExists(fileUri)) { |
| 185 | + await deleteAsync(fileUri); |
| 186 | + } |
124 | 187 | } |
125 | | - if (this.downloadAssets[source]) { |
126 | | - return this.handleCachedDownload(source); |
127 | | - } |
| 188 | + } |
128 | 189 |
|
129 | | - const uri = 'asda'; |
130 | | - const targetFilename = ResourceFetcherUtils.getFilenameFromUri(uri); |
131 | | - const fileUri = `${RNEDirectory}${targetFilename}`; |
132 | | - const cacheFileUri = `${cacheDirectory}${targetFilename}`; |
| 190 | + /** |
| 191 | + * Fetches the total size of remote files. Works only for remote files. |
| 192 | + * |
| 193 | + * @param sources - The resource identifiers (URLs). |
| 194 | + * @returns A promise that resolves to the combined size of files in bytes. |
| 195 | + */ |
| 196 | + async getFilesTotalSize(...sources: ResourceSource[]): Promise<number> { |
| 197 | + return (await ResourceFetcherUtils.getFilesSizes(sources)).totalLength; |
| 198 | + } |
133 | 199 |
|
134 | | - if (await ResourceFetcherUtils.checkFileExists(fileUri)) { |
135 | | - return ResourceFetcherUtils.removeFilePrefix(fileUri); |
| 200 | + private async fetchOne( |
| 201 | + source: ResourceSource, |
| 202 | + progressCallback: (progress: number) => void |
| 203 | + ): Promise<string | null> { |
| 204 | + const type = ResourceFetcherUtils.getType(source); |
| 205 | + switch (type) { |
| 206 | + case SourceType.OBJECT: |
| 207 | + return handleObject(source as object); |
| 208 | + case SourceType.LOCAL_FILE: |
| 209 | + return handleLocalFile(source as string); |
| 210 | + case SourceType.RELEASE_MODE_FILE: |
| 211 | + case SourceType.DEV_MODE_FILE: |
| 212 | + return handleAsset(source as number, progressCallback, this.downloads); |
| 213 | + default: // REMOTE_FILE |
| 214 | + return handleRemote( |
| 215 | + source as string, |
| 216 | + source, |
| 217 | + progressCallback, |
| 218 | + this.downloads |
| 219 | + ); |
136 | 220 | } |
| 221 | + } |
137 | 222 |
|
138 | | - await ResourceFetcherUtils.createDirectoryIfNoExists(); |
139 | | - |
140 | | - const downloadResumable = createDownloadResumable(uri, fileUri); |
141 | | - }, |
| 223 | + private async pause(source: ResourceSource): Promise<void> { |
| 224 | + const dl = this.downloads.get(source)!; |
| 225 | + if (dl.status === DownloadStatus.PAUSED) { |
| 226 | + throw new RnExecutorchError( |
| 227 | + RnExecutorchErrorCode.ResourceFetcherAlreadyPaused, |
| 228 | + "The file download is currently paused. Can't pause the download of the same file twice." |
| 229 | + ); |
| 230 | + } |
| 231 | + dl.status = DownloadStatus.PAUSED; |
| 232 | + await dl.downloadResumable.pauseAsync(); |
| 233 | + } |
142 | 234 |
|
143 | | - async fetch( |
144 | | - callback: (downloadProgress: number) => void = () => {}, |
145 | | - ...sources: ResourceSource[] |
146 | | - ) { |
147 | | - if (sources.length === 0) { |
| 235 | + private async resume(source: ResourceSource): Promise<void> { |
| 236 | + const dl = this.downloads.get(source)!; |
| 237 | + if (dl.status === DownloadStatus.ONGOING) { |
148 | 238 | throw new RnExecutorchError( |
149 | | - RnExecutorchErrorCode.InvalidUserInput, |
150 | | - "Empty list given as an argument to Resource Fetcher's fetch() function!" |
| 239 | + RnExecutorchErrorCode.ResourceFetcherAlreadyOngoing, |
| 240 | + "The file download is currently ongoing. Can't resume the ongoing download." |
151 | 241 | ); |
152 | 242 | } |
| 243 | + dl.status = DownloadStatus.ONGOING; |
| 244 | + const result = await dl.downloadResumable.resumeAsync(); |
| 245 | + const current = this.downloads.get(source); |
| 246 | + // Paused again or canceled during resume — settle/reject handled elsewhere. |
| 247 | + if (!current || current.status === DownloadStatus.PAUSED) return; |
153 | 248 |
|
154 | | - const totalFilesLength = (await ResourceFetcherUtils.getFilesSizes(sources)) |
155 | | - .totalLength; |
156 | | - const downloadedBytesCounter = 0; |
157 | | - for (let source of sources) { |
158 | | - const downloadResult = await this.singleFetch(source); |
| 249 | + if ( |
| 250 | + !result || |
| 251 | + (result.status !== HTTP_CODE.OK && |
| 252 | + result.status !== HTTP_CODE.PARTIAL_CONTENT) |
| 253 | + ) { |
| 254 | + this.downloads.delete(source); |
| 255 | + // Propagate the failure through the original fetch() promise. |
| 256 | + dl.reject( |
| 257 | + new RnExecutorchError( |
| 258 | + RnExecutorchErrorCode.ResourceFetcherDownloadFailed, |
| 259 | + `Failed to resume download from '${dl.uri}', status: ${result?.status}` |
| 260 | + ) |
| 261 | + ); |
| 262 | + return; |
159 | 263 | } |
160 | | - return ['']; |
161 | | - }, |
162 | | -}; |
| 264 | + |
| 265 | + await moveAsync({ from: dl.cacheFileUri, to: dl.fileUri }); |
| 266 | + this.downloads.delete(source); |
| 267 | + ResourceFetcherUtils.triggerHuggingFaceDownloadCounter(dl.uri); |
| 268 | + dl.settle(ResourceFetcherUtils.removeFilePrefix(dl.fileUri)); |
| 269 | + } |
| 270 | + |
| 271 | + private async cancel(source: ResourceSource): Promise<void> { |
| 272 | + const dl = this.downloads.get(source)!; |
| 273 | + await dl.downloadResumable.cancelAsync(); |
| 274 | + this.downloads.delete(source); |
| 275 | + dl.settle(null); |
| 276 | + } |
| 277 | + |
| 278 | + private findActive(sources: ResourceSource[]): ResourceSource { |
| 279 | + for (const source of sources) { |
| 280 | + if (this.downloads.has(source)) { |
| 281 | + return source; |
| 282 | + } |
| 283 | + } |
| 284 | + throw new RnExecutorchError( |
| 285 | + RnExecutorchErrorCode.ResourceFetcherNotActive, |
| 286 | + 'None of given sources are currently during downloading process.' |
| 287 | + ); |
| 288 | + } |
| 289 | +} |
| 290 | + |
| 291 | +export const ExpoResourceFetcher = new ExpoResourceFetcherClass(); |
0 commit comments