@hxa-rn/rn-fetch-blob 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +290 -0
- package/harmony/fetch_blob/LICENSE +21 -0
- package/harmony/fetch_blob/NOTICE +33 -0
- package/harmony/fetch_blob/OAT.xml +38 -0
- package/harmony/fetch_blob/build-profile.json5 +16 -0
- package/harmony/fetch_blob/hvigorfile.ts +2 -0
- package/harmony/fetch_blob/index.ets +1 -0
- package/harmony/fetch_blob/oh-package.json5 +14 -0
- package/harmony/fetch_blob/src/main/cpp/CMakeLists.txt +15 -0
- package/harmony/fetch_blob/src/main/cpp/FetchBlobPackage.h +13 -0
- package/harmony/fetch_blob/src/main/cpp/generated/RNOH/generated/BaseFetchBlobPackage.h +72 -0
- package/harmony/fetch_blob/src/main/cpp/generated/RNOH/generated/turbo_modules/RNFetchBlob.cpp +55 -0
- package/harmony/fetch_blob/src/main/cpp/generated/RNOH/generated/turbo_modules/RNFetchBlob.h +16 -0
- package/harmony/fetch_blob/src/main/ets/FetchBlobTurboModulesFactory.ets +18 -0
- package/harmony/fetch_blob/src/main/ets/Logger.ts +40 -0
- package/harmony/fetch_blob/src/main/ets/RNFetchBlobConfig.ts +45 -0
- package/harmony/fetch_blob/src/main/ets/RNFetchBlobFS.ts +673 -0
- package/harmony/fetch_blob/src/main/ets/RNFetchBlobImpl.ts +246 -0
- package/harmony/fetch_blob/src/main/ets/RNFetchBlobReq.ts +530 -0
- package/harmony/fetch_blob/src/main/ets/RNFetchBlobStream.ts +169 -0
- package/harmony/fetch_blob/src/main/ets/RNFetchBlobTurboModule.ts +192 -0
- package/harmony/fetch_blob/src/main/ets/generated/index.ets +5 -0
- package/harmony/fetch_blob/src/main/ets/generated/ts.ts +5 -0
- package/harmony/fetch_blob/src/main/ets/generated/turboModules/RNFetchBlob.ts +92 -0
- package/harmony/fetch_blob/src/main/ets/generated/turboModules/ts.ts +5 -0
- package/harmony/fetch_blob/src/main/module.json5 +22 -0
- package/harmony/fetch_blob/src/main/resources/base/element/string.json +8 -0
- package/harmony/fetch_blob/src/main/resources/en_US/element/string.json +8 -0
- package/harmony/fetch_blob/src/main/resources/zh_CN/element/string.json +8 -0
- package/harmony/fetch_blob.har +0 -0
- package/package.json +59 -0
- package/src/android.js +62 -0
- package/src/class/RNFetchBlobFile.js +17 -0
- package/src/class/RNFetchBlobReadStream.js +82 -0
- package/src/class/RNFetchBlobSession.js +74 -0
- package/src/class/RNFetchBlobWriteStream.js +57 -0
- package/src/class/StatefulPromise.js +7 -0
- package/src/fs.js +432 -0
- package/src/index.d.ts +690 -0
- package/src/index.js +577 -0
- package/src/ios.js +54 -0
- package/src/json-stream.js +45 -0
- package/src/lib/oboe-browser.min.js +1 -0
- package/src/polyfill/Blob.js +362 -0
- package/src/polyfill/Event.js +11 -0
- package/src/polyfill/EventTarget.js +79 -0
- package/src/polyfill/Fetch.js +213 -0
- package/src/polyfill/File.js +27 -0
- package/src/polyfill/FileReader.js +90 -0
- package/src/polyfill/ProgressEvent.js +32 -0
- package/src/polyfill/XMLHttpRequest.js +446 -0
- package/src/polyfill/XMLHttpRequestEventTarget.js +118 -0
- package/src/polyfill/index.js +11 -0
- package/src/specs/v1/NativeRNFetchBlob.ts +85 -0
- package/src/types.js +64 -0
- package/src/utils/log.js +40 -0
- package/src/utils/unicode.js +7 -0
- package/src/utils/uri.js +28 -0
- package/src/utils/uuid.js +4 -0
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2026. All rights reserved.
|
|
3
|
+
* HarmonyOS adaptation of rn-fetch-blob (RNFetchBlob TurboModule).
|
|
4
|
+
*
|
|
5
|
+
* HTTP request engine aligned with the original library's request module. Uses
|
|
6
|
+
* @ohos.net.http requestInStream for streaming downloads (avoids OOM on large
|
|
7
|
+
* files) and multiFormDataList for multipart uploads. Progress / state events
|
|
8
|
+
* keep the original RNFetchBlob* event names so the JS layer is unchanged.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import http from '@ohos.net.http';
|
|
12
|
+
import connection from '@ohos.net.connection';
|
|
13
|
+
import fs from '@ohos.file.fs';
|
|
14
|
+
import buffer from '@ohos.buffer';
|
|
15
|
+
import util from '@ohos.util';
|
|
16
|
+
import wifiManager from '@ohos.wifiManager';
|
|
17
|
+
import { BusinessError } from '@ohos.base';
|
|
18
|
+
import common from '@ohos.app.ability.common';
|
|
19
|
+
import { UITurboModuleContext } from '@rnoh/react-native-openharmony/ts';
|
|
20
|
+
import { RNFetchBlobConfig, ResponseInfo, RespType } from './RNFetchBlobConfig';
|
|
21
|
+
import hilog from '@ohos.hilog';
|
|
22
|
+
|
|
23
|
+
const DOMAIN: number = 0xFF00;
|
|
24
|
+
const TAG: string = 'RNFetchBlob';
|
|
25
|
+
|
|
26
|
+
const FILE_PREFIX = 'RNFetchBlob-file://';
|
|
27
|
+
const CONTENT_PREFIX = 'RNFetchBlob-content://';
|
|
28
|
+
const CONTENT_FILE = 'file://';
|
|
29
|
+
|
|
30
|
+
enum ResponseFormat {
|
|
31
|
+
Auto,
|
|
32
|
+
UTF8,
|
|
33
|
+
BASE64,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
enum ResponseType {
|
|
37
|
+
KeepInMemory,
|
|
38
|
+
FileStorage,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
enum RNFB_RESPONSE {
|
|
42
|
+
BASE64 = 'base64',
|
|
43
|
+
UTF8 = 'utf8',
|
|
44
|
+
PATH = 'path',
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A single multipart form item. The bridge delivers plain objects from JS, so
|
|
49
|
+
* items are parsed via Record access in the request builder.
|
|
50
|
+
*/
|
|
51
|
+
export type FormData = Object[] | string;
|
|
52
|
+
|
|
53
|
+
class DataReceiveProgressInfo {
|
|
54
|
+
receiveSize: number = 0;
|
|
55
|
+
totalSize: number = 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
class DataSendProgressInfo {
|
|
59
|
+
sendSize: number = 0;
|
|
60
|
+
totalSize: number = 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type ReqCallback = (err: string | null, rawType: string, data: string, responseInfo?: Object | null) => void;
|
|
64
|
+
|
|
65
|
+
export default class RNFetchBlobReq {
|
|
66
|
+
private ctx: UITurboModuleContext;
|
|
67
|
+
private context: common.UIAbilityContext;
|
|
68
|
+
|
|
69
|
+
private destPath: string = '';
|
|
70
|
+
private httpRequest: http.HttpRequest;
|
|
71
|
+
private responseType: ResponseType = ResponseType.KeepInMemory;
|
|
72
|
+
private responseFormat: ResponseFormat = ResponseFormat.Auto;
|
|
73
|
+
private resHeaders: Object = {};
|
|
74
|
+
private totalReceiveData: ArrayBuffer[] = [];
|
|
75
|
+
private fileFd: number = -1;
|
|
76
|
+
|
|
77
|
+
private downloadTimer: number = 0;
|
|
78
|
+
private uploadTimer: number = 0;
|
|
79
|
+
private downloadInfo: DataReceiveProgressInfo = new DataReceiveProgressInfo();
|
|
80
|
+
private uploadInfo: DataSendProgressInfo = new DataSendProgressInfo();
|
|
81
|
+
private taskId: string = '';
|
|
82
|
+
private finished: boolean = false;
|
|
83
|
+
|
|
84
|
+
constructor(ctx: UITurboModuleContext, context: common.UIAbilityContext) {
|
|
85
|
+
this.ctx = ctx;
|
|
86
|
+
this.context = context;
|
|
87
|
+
this.httpRequest = http.createHttp();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
startHttp(options: RNFetchBlobConfig, taskId: string, method: string, url: string, headers: Object,
|
|
91
|
+
form: FormData, callback: ReqCallback): void {
|
|
92
|
+
let config = this.mergeConfig(options);
|
|
93
|
+
this.taskId = taskId;
|
|
94
|
+
this.finished = false;
|
|
95
|
+
|
|
96
|
+
if ((config.fileCache || config.path) && !this.shouldTransformFile(config)) {
|
|
97
|
+
this.responseType = ResponseType.FileStorage;
|
|
98
|
+
} else {
|
|
99
|
+
this.responseType = ResponseType.KeepInMemory;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let ext = !config.appendExt ? '' : '.' + config.appendExt;
|
|
103
|
+
let tmpPath = this.context.cacheDir + '/RNFetchBlobTmp_' + taskId;
|
|
104
|
+
|
|
105
|
+
if (config.path !== undefined && config.path !== null && config.path.length > 0) {
|
|
106
|
+
this.destPath = config.path;
|
|
107
|
+
} else if (config.fileCache) {
|
|
108
|
+
this.destPath = tmpPath + ext;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
// wifiOnly: only allow when the *default* network bearer is WiFi
|
|
113
|
+
// (aligns with Android ConnectivityManager + TRANSPORT_WIFI).
|
|
114
|
+
// wifiManager.isConnected() alone is insufficient: WiFi STA may still
|
|
115
|
+
// report connected while traffic already uses cellular, or vice versa.
|
|
116
|
+
if (config.wifiOnly) {
|
|
117
|
+
if (!this.isDefaultNetworkWifi()) {
|
|
118
|
+
callback('No available Wifi connections', '', '', null);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// RNFB-Response header lets the caller force a specific response format.
|
|
124
|
+
let headerKeys: string[] = Object.keys(headers);
|
|
125
|
+
headerKeys.forEach((name: string) => {
|
|
126
|
+
let lowerName = name.toLowerCase();
|
|
127
|
+
if (lowerName === 'rnfb-response') {
|
|
128
|
+
let resValue: string = this.headerValue(headers, name).toLowerCase();
|
|
129
|
+
if (resValue === 'base64') {
|
|
130
|
+
this.responseFormat = ResponseFormat.BASE64;
|
|
131
|
+
} else if (resValue === 'utf8') {
|
|
132
|
+
this.responseFormat = ResponseFormat.UTF8;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
let reqOptions: http.HttpRequestOptions = {
|
|
138
|
+
method: method.toUpperCase() as http.RequestMethod,
|
|
139
|
+
header: headers,
|
|
140
|
+
readTimeout: config.timeout,
|
|
141
|
+
connectTimeout: config.timeout,
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
if (form) {
|
|
145
|
+
if (Array.isArray(form)) {
|
|
146
|
+
let multiFormDataList: http.MultiFormData[] = [];
|
|
147
|
+
form.forEach((itemRaw: Object) => {
|
|
148
|
+
let item = itemRaw as Record<string, Object>;
|
|
149
|
+
let itemData: string = item['data'] !== undefined ? String(item['data']) : '';
|
|
150
|
+
let itemBuf: ArrayBuffer = this.getABData(this.isPathStr(itemData), itemData);
|
|
151
|
+
let multiForm: http.MultiFormData = {
|
|
152
|
+
name: item['name'] !== undefined ? String(item['name']) : undefined,
|
|
153
|
+
contentType: item['type'] !== undefined ? String(item['type']) : undefined,
|
|
154
|
+
remoteFileName: item['filename'] !== undefined ? String(item['filename']) : undefined,
|
|
155
|
+
data: itemBuf,
|
|
156
|
+
};
|
|
157
|
+
multiFormDataList.push(multiForm);
|
|
158
|
+
});
|
|
159
|
+
reqOptions.multiFormDataList = multiFormDataList;
|
|
160
|
+
} else {
|
|
161
|
+
let buf: ArrayBuffer = this.getABData(this.isPathStr(form), form);
|
|
162
|
+
reqOptions.extraData = buf;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (this.responseType === ResponseType.FileStorage) {
|
|
167
|
+
this.prepareDestDir();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
this.httpRequest.on('dataReceive', (data: ArrayBuffer) => {
|
|
171
|
+
if (this.responseType === ResponseType.FileStorage && this.fileFd !== -1) {
|
|
172
|
+
// Write each chunk to disk immediately — avoids buffering large files in memory.
|
|
173
|
+
try {
|
|
174
|
+
fs.writeSync(this.fileFd, data);
|
|
175
|
+
} catch (err) {
|
|
176
|
+
let e: BusinessError = err as BusinessError;
|
|
177
|
+
hilog.error(DOMAIN, TAG, '%{public}s', `dataReceive write failed: ${e.message}`);
|
|
178
|
+
}
|
|
179
|
+
} else {
|
|
180
|
+
this.totalReceiveData.push(data);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// After the request ends, send the final progress tick and clear timers.
|
|
185
|
+
this.httpRequest.on('dataEnd', () => {
|
|
186
|
+
if (this.downloadTimer) {
|
|
187
|
+
this.sendDownloadProgress();
|
|
188
|
+
clearInterval(this.downloadTimer);
|
|
189
|
+
this.downloadTimer = 0;
|
|
190
|
+
} else if (this.uploadTimer) {
|
|
191
|
+
this.sendUploadProgress();
|
|
192
|
+
clearInterval(this.uploadTimer);
|
|
193
|
+
this.uploadTimer = 0;
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
this.httpRequest.on('headersReceive', (header: Object) => {
|
|
198
|
+
this.resHeaders = header;
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
this.httpRequest.requestInStream(url, reqOptions, (err: BusinessError, data: number) => {
|
|
202
|
+
if (!err) {
|
|
203
|
+
let isResBlob: boolean = this.isBlobResponse(this.resHeaders, config);
|
|
204
|
+
let resInfo: ResponseInfo = this.getRespInfo(taskId, this.resHeaders, data, isResBlob);
|
|
205
|
+
this.ctx.rnInstance.emitDeviceEvent('RNFetchBlobState', resInfo);
|
|
206
|
+
|
|
207
|
+
if (this.responseType === ResponseType.FileStorage) {
|
|
208
|
+
if (this.fileFd !== -1) {
|
|
209
|
+
fs.closeSync(this.fileFd);
|
|
210
|
+
this.fileFd = -1;
|
|
211
|
+
}
|
|
212
|
+
callback(null, RNFB_RESPONSE.PATH, this.destPath, resInfo);
|
|
213
|
+
} else {
|
|
214
|
+
let resData = this.mergeReceiveData();
|
|
215
|
+
if (this.responseFormat === ResponseFormat.BASE64) {
|
|
216
|
+
let base64Helper = new util.Base64Helper();
|
|
217
|
+
let b64 = base64Helper.encodeToStringSync(new Uint8Array(resData));
|
|
218
|
+
callback(null, RNFB_RESPONSE.BASE64, b64, resInfo);
|
|
219
|
+
} else {
|
|
220
|
+
let utf8 = buffer.from(resData).toString('utf-8');
|
|
221
|
+
callback(null, RNFB_RESPONSE.UTF8, utf8, resInfo);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
this.cancelRequest();
|
|
225
|
+
} else {
|
|
226
|
+
this.cancelRequest();
|
|
227
|
+
let errMsg = `code=${err.code}, message=${err.message}`;
|
|
228
|
+
hilog.error(DOMAIN, TAG, '%{public}s', errMsg);
|
|
229
|
+
callback(errMsg, '', '', null);
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
} catch (err) {
|
|
233
|
+
let e: BusinessError = err as BusinessError;
|
|
234
|
+
hilog.error(DOMAIN, TAG, '%{public}s', `startHttp failed: code=${e.code}, message=${e.message}`);
|
|
235
|
+
this.cancelRequest();
|
|
236
|
+
callback(`code=${e.code}, message=${e.message}`, '', '', null);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private mergeConfig(options: RNFetchBlobConfig): RNFetchBlobConfig {
|
|
241
|
+
let base = new RNFetchBlobConfig();
|
|
242
|
+
let result = new RNFetchBlobConfig();
|
|
243
|
+
result.fileCache = options.fileCache !== undefined ? options.fileCache : base.fileCache;
|
|
244
|
+
result.path = options.path !== undefined ? options.path : base.path;
|
|
245
|
+
result.appendExt = options.appendExt !== undefined ? options.appendExt : base.appendExt;
|
|
246
|
+
result.session = options.session !== undefined ? options.session : base.session;
|
|
247
|
+
result.overwrite = options.overwrite !== undefined ? options.overwrite : base.overwrite;
|
|
248
|
+
result.timeout = options.timeout !== undefined ? options.timeout : base.timeout;
|
|
249
|
+
result.followRedirect = options.followRedirect !== undefined ? options.followRedirect : base.followRedirect;
|
|
250
|
+
// Bridge delivers a plain Object; read booleans via Record so JS true/false is reliable.
|
|
251
|
+
result.trusty = this.readConfigBool(options, 'trusty', false);
|
|
252
|
+
result.wifiOnly = this.readConfigBool(options, 'wifiOnly', false);
|
|
253
|
+
result.auto = options.auto !== undefined ? options.auto : base.auto;
|
|
254
|
+
result.key = options.key !== undefined ? options.key : base.key;
|
|
255
|
+
result.addAndroidDownloads = options.addAndroidDownloads !== undefined ?
|
|
256
|
+
options.addAndroidDownloads : base.addAndroidDownloads;
|
|
257
|
+
result.indicator = options.indicator !== undefined ? options.indicator : base.indicator;
|
|
258
|
+
result.binaryContentTypes = options.binaryContentTypes !== undefined ?
|
|
259
|
+
options.binaryContentTypes : base.binaryContentTypes;
|
|
260
|
+
return result;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private readConfigBool(options: Object, key: string, fallback: boolean): boolean {
|
|
264
|
+
try {
|
|
265
|
+
let rec = options as Record<string, Object | undefined>;
|
|
266
|
+
let value = rec[key];
|
|
267
|
+
if (value === undefined || value === null) {
|
|
268
|
+
return fallback;
|
|
269
|
+
}
|
|
270
|
+
if (typeof value === 'boolean') {
|
|
271
|
+
return value;
|
|
272
|
+
}
|
|
273
|
+
if (typeof value === 'number') {
|
|
274
|
+
return value !== 0;
|
|
275
|
+
}
|
|
276
|
+
if (typeof value === 'string') {
|
|
277
|
+
let lower = value.toLowerCase();
|
|
278
|
+
return lower === 'true' || lower === '1';
|
|
279
|
+
}
|
|
280
|
+
return Boolean(value);
|
|
281
|
+
} catch (_e) {
|
|
282
|
+
return fallback;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Whether the current default data network is WiFi.
|
|
288
|
+
* Prefer @ohos.net.connection (active bearer); fall back to wifiManager.
|
|
289
|
+
* Fail closed (return false) on errors so wifiOnly rejects safely.
|
|
290
|
+
*/
|
|
291
|
+
private isDefaultNetworkWifi(): boolean {
|
|
292
|
+
try {
|
|
293
|
+
let netHandle = connection.getDefaultNetSync();
|
|
294
|
+
if (netHandle !== undefined && netHandle !== null && netHandle.netId !== 0) {
|
|
295
|
+
let caps = connection.getNetCapabilitiesSync(netHandle);
|
|
296
|
+
let bearers = caps.bearerTypes;
|
|
297
|
+
if (bearers !== undefined && bearers !== null && bearers.length > 0) {
|
|
298
|
+
for (let i = 0; i < bearers.length; i++) {
|
|
299
|
+
if (bearers[i] === connection.NetBearType.BEARER_WIFI) {
|
|
300
|
+
return true;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
} catch (err) {
|
|
307
|
+
let e: BusinessError = err as BusinessError;
|
|
308
|
+
hilog.warn(DOMAIN, TAG, '%{public}s',
|
|
309
|
+
`isDefaultNetworkWifi via connection failed: code=${e.code}, message=${e.message}`);
|
|
310
|
+
}
|
|
311
|
+
try {
|
|
312
|
+
return wifiManager.isWifiActive() && wifiManager.isConnected();
|
|
313
|
+
} catch (err2) {
|
|
314
|
+
let e2: BusinessError = err2 as BusinessError;
|
|
315
|
+
hilog.warn(DOMAIN, TAG, '%{public}s',
|
|
316
|
+
`isDefaultNetworkWifi via wifiManager failed: code=${e2.code}, message=${e2.message}`);
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private prepareDestDir(): void {
|
|
322
|
+
try {
|
|
323
|
+
let dir = this.destPath.substring(0, this.destPath.lastIndexOf('/'));
|
|
324
|
+
if (!fs.accessSync(dir)) {
|
|
325
|
+
fs.mkdirSync(dir, true);
|
|
326
|
+
}
|
|
327
|
+
let file = fs.openSync(this.destPath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE);
|
|
328
|
+
this.fileFd = file.fd;
|
|
329
|
+
} catch (err) {
|
|
330
|
+
let e: BusinessError = err as BusinessError;
|
|
331
|
+
hilog.error(DOMAIN, TAG, '%{public}s', `prepareDestDir failed: ${e.message}`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
private mergeReceiveData(): ArrayBuffer {
|
|
336
|
+
if (this.totalReceiveData.length === 0) {
|
|
337
|
+
return new ArrayBuffer(0);
|
|
338
|
+
}
|
|
339
|
+
let totalLength: number = 0;
|
|
340
|
+
this.totalReceiveData.forEach((arr: ArrayBuffer) => {
|
|
341
|
+
totalLength += arr.byteLength;
|
|
342
|
+
});
|
|
343
|
+
let totalBuffer = new ArrayBuffer(totalLength);
|
|
344
|
+
let result = new Uint8Array(totalBuffer);
|
|
345
|
+
let offset = 0;
|
|
346
|
+
this.totalReceiveData.forEach((arr: ArrayBuffer) => {
|
|
347
|
+
result.set(new Uint8Array(arr), offset);
|
|
348
|
+
offset += arr.byteLength;
|
|
349
|
+
});
|
|
350
|
+
return totalBuffer;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private headerValue(headers: Object, key: string): string {
|
|
354
|
+
let h = headers as Record<string, Object>;
|
|
355
|
+
let val = h[key];
|
|
356
|
+
return val !== undefined && val !== null ? String(val) : '';
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
isPathStr(str: string): boolean {
|
|
360
|
+
return str.startsWith(FILE_PREFIX) || str.startsWith(CONTENT_PREFIX) || str.startsWith('/');
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
getABData(isPath: boolean, data: string): ArrayBuffer {
|
|
364
|
+
let file: fs.File | undefined = undefined;
|
|
365
|
+
try {
|
|
366
|
+
if (isPath) {
|
|
367
|
+
let filePath: string = data;
|
|
368
|
+
if (!filePath.startsWith('/')) {
|
|
369
|
+
filePath = filePath.replace(FILE_PREFIX, '').replace(CONTENT_PREFIX, '').replace(CONTENT_FILE, '');
|
|
370
|
+
}
|
|
371
|
+
let fileInfo = fs.statSync(filePath);
|
|
372
|
+
file = fs.openSync(filePath, fs.OpenMode.READ_ONLY);
|
|
373
|
+
let buf = new ArrayBuffer(fileInfo.size);
|
|
374
|
+
fs.readSync(file.fd, buf);
|
|
375
|
+
return buf;
|
|
376
|
+
} else {
|
|
377
|
+
return buffer.alloc(data.length, data, 'base64').buffer;
|
|
378
|
+
}
|
|
379
|
+
} catch (err) {
|
|
380
|
+
let e: BusinessError = err as BusinessError;
|
|
381
|
+
hilog.error(DOMAIN, TAG, '%{public}s', `getABData failed with error message: ${e.message}, error code: ${e.code}`);
|
|
382
|
+
return new ArrayBuffer(0);
|
|
383
|
+
} finally {
|
|
384
|
+
if (file !== undefined) {
|
|
385
|
+
try {
|
|
386
|
+
fs.closeSync(file);
|
|
387
|
+
} catch (e) {
|
|
388
|
+
let ce: BusinessError = e as BusinessError;
|
|
389
|
+
hilog.error(DOMAIN, TAG, '%{public}s', 'getABData close failed: ' + ce.message);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
onProgressReport(interval: number, count: number): void {
|
|
396
|
+
this.httpRequest.on('dataReceiveProgress', (data: DataReceiveProgressInfo) => {
|
|
397
|
+
this.downloadInfo = data;
|
|
398
|
+
if (count !== -1 && (data.totalSize > 0) && ((data.receiveSize / data.totalSize) > (count / 10))) {
|
|
399
|
+
this.sendDownloadProgress();
|
|
400
|
+
}
|
|
401
|
+
if (count === -1) {
|
|
402
|
+
if (this.downloadTimer) {
|
|
403
|
+
clearInterval(this.downloadTimer);
|
|
404
|
+
}
|
|
405
|
+
this.downloadTimer = setInterval(() => {
|
|
406
|
+
if (!this.downloadInfo.totalSize) {
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
this.sendDownloadProgress();
|
|
410
|
+
}, interval);
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
sendDownloadProgress(): void {
|
|
416
|
+
this.ctx.rnInstance.emitDeviceEvent('RNFetchBlobProgress', {
|
|
417
|
+
taskId: this.taskId,
|
|
418
|
+
written: this.downloadInfo.receiveSize,
|
|
419
|
+
total: this.downloadInfo.totalSize,
|
|
420
|
+
chunk: '',
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
onUploadProgressReport(interval: number, count: number): void {
|
|
425
|
+
this.httpRequest.on('dataSendProgress', (data: DataSendProgressInfo) => {
|
|
426
|
+
this.uploadInfo = data;
|
|
427
|
+
if (count !== -1 && (data.totalSize > 0) && ((data.sendSize / data.totalSize) > (count / 10))) {
|
|
428
|
+
this.sendUploadProgress();
|
|
429
|
+
}
|
|
430
|
+
if (count === -1) {
|
|
431
|
+
if (this.uploadTimer) {
|
|
432
|
+
clearInterval(this.uploadTimer);
|
|
433
|
+
}
|
|
434
|
+
this.uploadTimer = setInterval(() => {
|
|
435
|
+
if (!this.uploadInfo.totalSize) {
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
this.sendUploadProgress();
|
|
439
|
+
}, interval);
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
sendUploadProgress(): void {
|
|
445
|
+
this.ctx.rnInstance.emitDeviceEvent('RNFetchBlobProgress-upload', {
|
|
446
|
+
taskId: this.taskId,
|
|
447
|
+
written: this.uploadInfo.sendSize,
|
|
448
|
+
total: this.uploadInfo.totalSize,
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
isBlobResponse(headers: Object, options: RNFetchBlobConfig): boolean {
|
|
453
|
+
let cType: string = this.headerValue(headers, 'content-type');
|
|
454
|
+
if (cType.length === 0) {
|
|
455
|
+
cType = this.headerValue(headers, 'Content-Type');
|
|
456
|
+
}
|
|
457
|
+
cType = cType.toLowerCase();
|
|
458
|
+
let isText: boolean = cType.indexOf('text/') !== -1;
|
|
459
|
+
let isJson: boolean = cType.indexOf('application/json') !== -1;
|
|
460
|
+
let isCustomBinary: boolean = (options.binaryContentTypes !== undefined && options.binaryContentTypes !== null
|
|
461
|
+
&& options.binaryContentTypes.length > 0);
|
|
462
|
+
return !(isText || isJson) || isCustomBinary;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
getRespInfo(taskId: string, headers: Object, status: number, isBlob: boolean): ResponseInfo {
|
|
466
|
+
let respInfo = new ResponseInfo();
|
|
467
|
+
respInfo.taskId = taskId;
|
|
468
|
+
respInfo.status = status;
|
|
469
|
+
respInfo.state = 2;
|
|
470
|
+
respInfo.headers = headers;
|
|
471
|
+
respInfo.timeout = false;
|
|
472
|
+
respInfo.redirects = [];
|
|
473
|
+
let cType: string = this.headerValue(headers, 'content-type');
|
|
474
|
+
if (cType.length === 0) {
|
|
475
|
+
cType = this.headerValue(headers, 'Content-Type');
|
|
476
|
+
}
|
|
477
|
+
respInfo.respType = this.getRespType(isBlob, cType);
|
|
478
|
+
return respInfo;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
getRespType(isBlob: boolean, headerType: string): RespType {
|
|
482
|
+
if (isBlob) {
|
|
483
|
+
return 'blob';
|
|
484
|
+
} else if (headerType.indexOf('application/json') !== -1) {
|
|
485
|
+
return 'json';
|
|
486
|
+
} else if (headerType.indexOf('text/') !== -1) {
|
|
487
|
+
return 'text';
|
|
488
|
+
} else {
|
|
489
|
+
return '';
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
shouldTransformFile(options: RNFetchBlobConfig): boolean {
|
|
494
|
+
return false;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
cancelRequest(): void {
|
|
498
|
+
if (this.finished) {
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
this.finished = true;
|
|
502
|
+
this.totalReceiveData = [];
|
|
503
|
+
if (this.fileFd !== -1) {
|
|
504
|
+
try {
|
|
505
|
+
fs.closeSync(this.fileFd);
|
|
506
|
+
} catch (err) {
|
|
507
|
+
let ce: BusinessError = err as BusinessError;
|
|
508
|
+
hilog.error(DOMAIN, TAG, '%{public}s', 'cancelRequest close fd failed: ' + ce.message);
|
|
509
|
+
}
|
|
510
|
+
this.fileFd = -1;
|
|
511
|
+
}
|
|
512
|
+
try {
|
|
513
|
+
this.httpRequest.destroy();
|
|
514
|
+
this.httpRequest.off('dataReceiveProgress');
|
|
515
|
+
this.httpRequest.off('dataSendProgress');
|
|
516
|
+
this.httpRequest.off('dataReceive');
|
|
517
|
+
this.httpRequest.off('headersReceive');
|
|
518
|
+
} catch (err) {
|
|
519
|
+
// httpRequest may already be destroyed
|
|
520
|
+
}
|
|
521
|
+
if (this.downloadTimer) {
|
|
522
|
+
clearInterval(this.downloadTimer);
|
|
523
|
+
this.downloadTimer = 0;
|
|
524
|
+
}
|
|
525
|
+
if (this.uploadTimer) {
|
|
526
|
+
clearInterval(this.uploadTimer);
|
|
527
|
+
this.uploadTimer = 0;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2026. All rights reserved.
|
|
3
|
+
* HarmonyOS adaptation of rn-fetch-blob (RNFetchBlob TurboModule).
|
|
4
|
+
*
|
|
5
|
+
* File read/write streams. readStream emits data chunks to the JS side using
|
|
6
|
+
* the streamId as the dynamic DeviceEventEmitter event name (matching the
|
|
7
|
+
* original RNFetchBlobReadStream.js `addListener(streamId)`); write streams
|
|
8
|
+
* are tracked by a globally keyed streamId map.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from '@ohos.file.fs';
|
|
12
|
+
import util from '@ohos.util';
|
|
13
|
+
import HashMap from '@ohos.util.HashMap';
|
|
14
|
+
import { BusinessError } from '@ohos.base';
|
|
15
|
+
import buffer from '@ohos.buffer';
|
|
16
|
+
import { UITurboModuleContext } from '@rnoh/react-native-openharmony/ts';
|
|
17
|
+
import Logger from './Logger';
|
|
18
|
+
import hilog from '@ohos.hilog';
|
|
19
|
+
|
|
20
|
+
const DOMAIN: number = 0xFF00;
|
|
21
|
+
const TAG: string = 'RNFetchBlob';
|
|
22
|
+
|
|
23
|
+
const FILE_OR_DIR_NOT_EXIST: number = 13900002;
|
|
24
|
+
|
|
25
|
+
export type StreamEncoding = 'utf8' | 'ascii' | 'base64';
|
|
26
|
+
|
|
27
|
+
export class StreamEntry {
|
|
28
|
+
encoding: string = 'utf8';
|
|
29
|
+
stream: fs.Stream | undefined = undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export default class RNFetchBlobStream {
|
|
33
|
+
private ctx: UITurboModuleContext;
|
|
34
|
+
private static fileStreams: HashMap<string, StreamEntry> = new HashMap();
|
|
35
|
+
|
|
36
|
+
constructor(ctx: UITurboModuleContext) {
|
|
37
|
+
this.ctx = ctx;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
writeStream(filePath: string, encoding: string, append: boolean,
|
|
41
|
+
callback: (errCode: string | null, errMsg: string | null, streamId?: string) => void): void {
|
|
42
|
+
let appends = append ? 'a+' : 'w+';
|
|
43
|
+
try {
|
|
44
|
+
let stream: fs.Stream = fs.createStreamSync(filePath, appends);
|
|
45
|
+
let uuid: string = util.generateRandomUUID(true);
|
|
46
|
+
let entry = new StreamEntry();
|
|
47
|
+
entry.encoding = encoding;
|
|
48
|
+
entry.stream = stream;
|
|
49
|
+
RNFetchBlobStream.fileStreams.set(uuid, entry);
|
|
50
|
+
callback(null, null, uuid);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
let e: BusinessError = err as BusinessError;
|
|
53
|
+
if (e.code === FILE_OR_DIR_NOT_EXIST) {
|
|
54
|
+
try {
|
|
55
|
+
let dir = filePath.substring(0, filePath.lastIndexOf('/'));
|
|
56
|
+
fs.mkdirSync(dir, true);
|
|
57
|
+
this.writeStream(filePath, encoding, append, callback);
|
|
58
|
+
} catch (e2) {
|
|
59
|
+
let e3: BusinessError = e2 as BusinessError;
|
|
60
|
+
hilog.error(DOMAIN, TAG, '%{public}s', `writeStream mkdir failed: ${e3.message}, code ${e3.code}`);
|
|
61
|
+
callback('EUNSPECIFIED', `Failed to create write stream at path \`${filePath}\`; ${e3.code}`);
|
|
62
|
+
}
|
|
63
|
+
} else {
|
|
64
|
+
hilog.error(DOMAIN, TAG, '%{public}s', `writeStream failed: ${e.message}, code ${e.code}`);
|
|
65
|
+
callback('EUNSPECIFIED', `Failed to create write stream at path \`${filePath}\`; ${e.code}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
readStream(filePath: string, encoding: string, bufferSize: number, tick: number, streamId: string): void {
|
|
71
|
+
try {
|
|
72
|
+
let inputStream: fs.Stream = fs.createStreamSync(filePath, 'r+');
|
|
73
|
+
let buf = new ArrayBuffer(bufferSize <= 0 ? 4096 : bufferSize);
|
|
74
|
+
let readLen: number = inputStream.readSync(buf);
|
|
75
|
+
while (readLen > 0) {
|
|
76
|
+
let bytes = buffer.from(buf, 0, readLen);
|
|
77
|
+
let detail = this.encodeChunk(bytes, encoding);
|
|
78
|
+
this.ctx.rnInstance.emitDeviceEvent(streamId, { event: 'data', detail: detail });
|
|
79
|
+
readLen = inputStream.readSync(buf);
|
|
80
|
+
}
|
|
81
|
+
inputStream.closeSync();
|
|
82
|
+
this.ctx.rnInstance.emitDeviceEvent(streamId, { event: 'end', detail: '' });
|
|
83
|
+
} catch (err) {
|
|
84
|
+
let e: BusinessError = err as BusinessError;
|
|
85
|
+
let errMsg = '';
|
|
86
|
+
if (Number(e.code) === 13900042) {
|
|
87
|
+
errMsg = 'readStream failed with error message: No such file or directory, error code: 13900002';
|
|
88
|
+
} else {
|
|
89
|
+
errMsg = `readStream failed with error message: ${e.message}, error code: ${e.code}`;
|
|
90
|
+
}
|
|
91
|
+
hilog.error(DOMAIN, TAG, '%{public}s', errMsg);
|
|
92
|
+
this.ctx.rnInstance.emitDeviceEvent(streamId, { event: 'error', code: 'EUNSPECIFIED', detail: errMsg });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private encodeChunk(bytes: buffer.Buffer, encoding: string): string {
|
|
97
|
+
let enc: string = encoding.toLowerCase();
|
|
98
|
+
if (enc === 'base64') {
|
|
99
|
+
return bytes.toString('base64');
|
|
100
|
+
} else if (enc === 'ascii') {
|
|
101
|
+
return bytes.toString('latin1');
|
|
102
|
+
} else {
|
|
103
|
+
return bytes.toString('utf-8');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
writeChunk(streamId: string, data: string, callback: (err: string | null) => void): void {
|
|
108
|
+
let ss: StreamEntry | undefined = RNFetchBlobStream.fileStreams.get(streamId);
|
|
109
|
+
if (!ss || ss.stream === undefined) {
|
|
110
|
+
callback('writeChunk failed: stream not found');
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
ss.stream.write(data, {
|
|
115
|
+
encoding: (ss.encoding === 'utf8' || ss.encoding === '') ? 'utf-8' : ss.encoding,
|
|
116
|
+
}, (err: BusinessError, bytesWritten: number) => {
|
|
117
|
+
if (err) {
|
|
118
|
+
callback(`writeChunk failed with error message: ${err.message}, error code: ${err.code}`);
|
|
119
|
+
} else {
|
|
120
|
+
callback('');
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
} catch (err) {
|
|
124
|
+
let e: BusinessError = err as BusinessError;
|
|
125
|
+
hilog.error(DOMAIN, TAG, '%{public}s', `writeChunk failed: ${e.message}`);
|
|
126
|
+
callback(`writeChunk failed with error message: ${e.message}, error code: ${e.code}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
writeArrayChunk(streamId: string, data: number[], callback: (err: string | null) => void): void {
|
|
131
|
+
let ss: StreamEntry | undefined = RNFetchBlobStream.fileStreams.get(streamId);
|
|
132
|
+
if (!ss || ss.stream === undefined) {
|
|
133
|
+
callback('writeArrayChunk failed: stream not found');
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
let buf = buffer.from(data);
|
|
138
|
+
ss.stream.write(buf.toString('utf-8'), { encoding: 'utf-8' }, (err: BusinessError, bytesWritten: number) => {
|
|
139
|
+
if (err) {
|
|
140
|
+
callback(`writeArrayChunk failed with error message: ${err.message}, error code: ${err.code}`);
|
|
141
|
+
} else {
|
|
142
|
+
callback('');
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
} catch (err) {
|
|
146
|
+
let e: BusinessError = err as BusinessError;
|
|
147
|
+
hilog.error(DOMAIN, TAG, '%{public}s', `writeArrayChunk failed: ${e.message}`);
|
|
148
|
+
callback(`writeArrayChunk failed with error message: ${e.message}, error code: ${e.code}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
close(streamId: string, callback: (err: string | null) => void): void {
|
|
153
|
+
let ss: StreamEntry | undefined = RNFetchBlobStream.fileStreams.get(streamId);
|
|
154
|
+
if (!ss || ss.stream === undefined) {
|
|
155
|
+
callback('close failed: stream not found');
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
RNFetchBlobStream.fileStreams.remove(streamId);
|
|
159
|
+
ss.stream.close((err: BusinessError) => {
|
|
160
|
+
if (err) {
|
|
161
|
+
hilog.error(DOMAIN, TAG, '%{public}s', `close stream failed with error message: ${err.message}, error code: ${err.code}`);
|
|
162
|
+
callback(`close failed with error message: ${err.message}, error code: ${err.code}`);
|
|
163
|
+
} else {
|
|
164
|
+
Logger.info('close stream succeed');
|
|
165
|
+
callback('');
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|