rn-file-toolkit 1.0.1 → 1.0.2
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/README.md +83 -433
- package/android/src/main/java/com/filetoolkit/FileToolkitModule.kt +123 -2
- package/ios/FileToolkit.mm +61 -19
- package/lib/commonjs/index.js +147 -562
- package/lib/commonjs/index.js.map +1 -1
- package/lib/module/index.js +147 -562
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/commonjs/src/index.d.ts +27 -443
- package/lib/typescript/commonjs/src/index.d.ts.map +1 -1
- package/lib/typescript/module/src/index.d.ts +27 -443
- package/lib/typescript/module/src/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/index.tsx +177 -783
|
@@ -6,6 +6,12 @@ import android.content.Intent
|
|
|
6
6
|
import android.net.Uri
|
|
7
7
|
import android.os.Environment
|
|
8
8
|
import androidx.core.content.FileProvider
|
|
9
|
+
import android.content.BroadcastReceiver
|
|
10
|
+
import android.content.IntentFilter
|
|
11
|
+
import android.database.Cursor
|
|
12
|
+
import android.os.Handler
|
|
13
|
+
import android.os.Looper
|
|
14
|
+
import android.os.Build
|
|
9
15
|
import com.facebook.react.bridge.ReactApplicationContext
|
|
10
16
|
import com.facebook.react.bridge.ReadableMap
|
|
11
17
|
import com.facebook.react.bridge.Promise
|
|
@@ -48,6 +54,34 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
|
|
|
48
54
|
// Stored promises for foreground downloads — resolved on completion (not early)
|
|
49
55
|
private val foregroundPromises = ConcurrentHashMap<String, Promise>()
|
|
50
56
|
|
|
57
|
+
private val bgPollHandler = Handler(Looper.getMainLooper())
|
|
58
|
+
private val bgPollRunnable = object : Runnable {
|
|
59
|
+
override fun run() {
|
|
60
|
+
if (bgDownloadIds.isNotEmpty()) {
|
|
61
|
+
pollBackgroundDownloads()
|
|
62
|
+
}
|
|
63
|
+
bgPollHandler.postDelayed(this, 1500)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private val downloadReceiver = object : BroadcastReceiver() {
|
|
68
|
+
override fun onReceive(context: Context?, intent: Intent?) {
|
|
69
|
+
if (intent?.action == DownloadManager.ACTION_DOWNLOAD_COMPLETE) {
|
|
70
|
+
val id = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1L)
|
|
71
|
+
handleBackgroundDownloadComplete(id)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
init {
|
|
77
|
+
val filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
|
|
78
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
79
|
+
reactContext.registerReceiver(downloadReceiver, filter, Context.RECEIVER_EXPORTED)
|
|
80
|
+
} else {
|
|
81
|
+
reactContext.registerReceiver(downloadReceiver, filter)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
51
85
|
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
52
86
|
|
|
53
87
|
private fun emit(event: String, map: com.facebook.react.bridge.WritableMap) {
|
|
@@ -114,7 +148,7 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
|
|
|
114
148
|
val isBackground = options.takeIf { it.hasKey("background") }?.getBoolean("background") ?: false
|
|
115
149
|
val rawFileName = if (options.hasKey("fileName")) options.getString("fileName") else null
|
|
116
150
|
val fileName = resolveFileName(urlString, rawFileName)
|
|
117
|
-
val downloadId = UUID.randomUUID().toString()
|
|
151
|
+
val downloadId = if (options.hasKey("downloadId")) options.getString("downloadId")!! else UUID.randomUUID().toString()
|
|
118
152
|
|
|
119
153
|
val headersMap = options.getMap("headers")
|
|
120
154
|
val destination = options.takeIf { it.hasKey("destination") }?.getString("destination")
|
|
@@ -154,6 +188,10 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
|
|
|
154
188
|
bgDownloadIds[downloadId] = bgId
|
|
155
189
|
activeDownloads.remove(downloadId)
|
|
156
190
|
|
|
191
|
+
// Start polling if not running
|
|
192
|
+
bgPollHandler.removeCallbacks(bgPollRunnable)
|
|
193
|
+
bgPollHandler.post(bgPollRunnable)
|
|
194
|
+
|
|
157
195
|
promise.resolve(Arguments.createMap().apply {
|
|
158
196
|
putBoolean("success", true)
|
|
159
197
|
putString("downloadId", downloadId)
|
|
@@ -353,7 +391,85 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
|
|
|
353
391
|
}
|
|
354
392
|
}
|
|
355
393
|
|
|
356
|
-
|
|
394
|
+
private fun pollBackgroundDownloads() {
|
|
395
|
+
val dm = reactContext.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
|
396
|
+
val query = DownloadManager.Query()
|
|
397
|
+
val cursor = dm.query(query) ?: return
|
|
398
|
+
|
|
399
|
+
val currentBgIds = bgDownloadIds.values.toSet()
|
|
400
|
+
|
|
401
|
+
if (cursor.moveToFirst()) {
|
|
402
|
+
val descIdx = cursor.getColumnIndex(DownloadManager.COLUMN_DESCRIPTION)
|
|
403
|
+
val idIdx = cursor.getColumnIndex(DownloadManager.COLUMN_ID)
|
|
404
|
+
val statusIdx = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)
|
|
405
|
+
val totalIdx = cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
|
|
406
|
+
val currentIdx = cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
|
|
407
|
+
|
|
408
|
+
do {
|
|
409
|
+
val bgId = cursor.getLong(idIdx)
|
|
410
|
+
if (!currentBgIds.contains(bgId)) continue
|
|
411
|
+
|
|
412
|
+
val description = cursor.getString(descIdx) ?: ""
|
|
413
|
+
if (description.startsWith("rn-file-toolkit-id:")) {
|
|
414
|
+
val downloadId = description.removePrefix("rn-file-toolkit-id:")
|
|
415
|
+
val status = cursor.getInt(statusIdx)
|
|
416
|
+
val total = cursor.getLong(totalIdx)
|
|
417
|
+
val current = cursor.getLong(currentIdx)
|
|
418
|
+
|
|
419
|
+
if (status == DownloadManager.STATUS_RUNNING || status == DownloadManager.STATUS_PENDING) {
|
|
420
|
+
val progress = if (total > 0) (current * 100 / total).toInt() else 0
|
|
421
|
+
val evt = Arguments.createMap().apply {
|
|
422
|
+
putString("downloadId", downloadId)
|
|
423
|
+
putInt("progress", progress)
|
|
424
|
+
putDouble("bytesDownloaded", current.toDouble())
|
|
425
|
+
putDouble("totalBytes", total.toDouble())
|
|
426
|
+
}
|
|
427
|
+
emit("onDownloadProgress", evt)
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
} while (cursor.moveToNext())
|
|
431
|
+
}
|
|
432
|
+
cursor.close()
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private fun handleBackgroundDownloadComplete(bgId: Long) {
|
|
436
|
+
val dm = reactContext.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
|
437
|
+
val query = DownloadManager.Query().setFilterById(bgId)
|
|
438
|
+
val cursor = dm.query(query) ?: return
|
|
439
|
+
if (cursor.moveToFirst()) {
|
|
440
|
+
val descIdx = cursor.getColumnIndex(DownloadManager.COLUMN_DESCRIPTION)
|
|
441
|
+
val statusIdx = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)
|
|
442
|
+
val uriIdx = cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)
|
|
443
|
+
val reasonIdx = cursor.getColumnIndex(DownloadManager.COLUMN_REASON)
|
|
444
|
+
|
|
445
|
+
val description = cursor.getString(descIdx) ?: ""
|
|
446
|
+
if (description.startsWith("rn-file-toolkit-id:")) {
|
|
447
|
+
val downloadId = description.removePrefix("rn-file-toolkit-id:")
|
|
448
|
+
val status = cursor.getInt(statusIdx)
|
|
449
|
+
bgDownloadIds.remove(downloadId) // cleanup
|
|
450
|
+
|
|
451
|
+
if (status == DownloadManager.STATUS_SUCCESSFUL) {
|
|
452
|
+
val localUri = cursor.getString(uriIdx)
|
|
453
|
+
val filePath = localUri?.removePrefix("file://") ?: ""
|
|
454
|
+
emit("onDownloadComplete", Arguments.createMap().apply {
|
|
455
|
+
putBoolean("success", true)
|
|
456
|
+
putString("downloadId", downloadId)
|
|
457
|
+
putString("filePath", filePath)
|
|
458
|
+
})
|
|
459
|
+
} else {
|
|
460
|
+
val reason = cursor.getInt(reasonIdx)
|
|
461
|
+
emit("onDownloadError", Arguments.createMap().apply {
|
|
462
|
+
putBoolean("success", false)
|
|
463
|
+
putString("downloadId", downloadId)
|
|
464
|
+
putString("error", "DownloadManager failed with reason/code: $reason")
|
|
465
|
+
})
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
cursor.close()
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// ─── executeDownload (Foreground) ─────────────────────────────────────────────────────────
|
|
357
473
|
|
|
358
474
|
override fun pauseDownload(downloadId: String, promise: Promise) {
|
|
359
475
|
val state = activeDownloads[downloadId]
|
|
@@ -753,6 +869,7 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
|
|
|
753
869
|
}
|
|
754
870
|
|
|
755
871
|
val fieldName = if (options.hasKey("fieldName")) options.getString("fieldName") else "file"
|
|
872
|
+
val uploadId = if (options.hasKey("uploadId")) options.getString("uploadId") else UUID.randomUUID().toString()
|
|
756
873
|
val headersMap = options.getMap("headers")
|
|
757
874
|
val paramsMap = options.getMap("parameters")
|
|
758
875
|
|
|
@@ -778,6 +895,7 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
|
|
|
778
895
|
connection.requestMethod = "POST"
|
|
779
896
|
connection.setRequestProperty("Connection", "Keep-Alive")
|
|
780
897
|
connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary")
|
|
898
|
+
connection.setChunkedStreamingMode(0)
|
|
781
899
|
|
|
782
900
|
// Add custom headers
|
|
783
901
|
headersMap?.toHashMap()?.forEach { (key, value) ->
|
|
@@ -819,6 +937,7 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
|
|
|
819
937
|
lastProgress = progress
|
|
820
938
|
val evt = Arguments.createMap().apply {
|
|
821
939
|
putString("url", urlString)
|
|
940
|
+
putString("uploadId", uploadId)
|
|
822
941
|
putInt("progress", progress)
|
|
823
942
|
}
|
|
824
943
|
emit("onUploadProgress", evt)
|
|
@@ -845,6 +964,7 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
|
|
|
845
964
|
putBoolean("success", responseCode in 200..299)
|
|
846
965
|
putInt("status", responseCode)
|
|
847
966
|
putString("data", responseBody)
|
|
967
|
+
putString("uploadId", uploadId)
|
|
848
968
|
if (responseCode !in 200..299) putString("error", "HTTP $responseCode")
|
|
849
969
|
})
|
|
850
970
|
|
|
@@ -852,6 +972,7 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
|
|
|
852
972
|
promise.resolve(Arguments.createMap().apply {
|
|
853
973
|
putBoolean("success", false)
|
|
854
974
|
putString("error", e.message ?: "UPLOAD_ERROR")
|
|
975
|
+
putString("uploadId", uploadId)
|
|
855
976
|
})
|
|
856
977
|
}
|
|
857
978
|
}
|
package/ios/FileToolkit.mm
CHANGED
|
@@ -137,7 +137,7 @@ RCT_EXPORT_MODULE()
|
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
BOOL isBackground = [options[@"background"] boolValue];
|
|
140
|
-
NSString *downloadId = [self generateDownloadId];
|
|
140
|
+
NSString *downloadId = options[@"downloadId"] ?: [self generateDownloadId];
|
|
141
141
|
NSURL *url = [NSURL URLWithString:urlString];
|
|
142
142
|
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url];
|
|
143
143
|
|
|
@@ -651,9 +651,14 @@ didCompleteWithError:(NSError *)error {
|
|
|
651
651
|
if (uploadId) {
|
|
652
652
|
NSDictionary *funcs = self.uploadPromises[uploadId];
|
|
653
653
|
RCTPromiseResolveBlock uploadResolve = funcs[@"resolve"];
|
|
654
|
+
NSString *tempFile = funcs[@"tempFile"];
|
|
655
|
+
|
|
656
|
+
if (tempFile) {
|
|
657
|
+
[[NSFileManager defaultManager] removeItemAtPath:tempFile error:nil];
|
|
658
|
+
}
|
|
654
659
|
|
|
655
660
|
if (error) {
|
|
656
|
-
if (uploadResolve) uploadResolve(@{@"success": @NO, @"error": error.localizedDescription});
|
|
661
|
+
if (uploadResolve) uploadResolve(@{@"success": @NO, @"error": error.localizedDescription, @"uploadId": uploadId});
|
|
657
662
|
} else {
|
|
658
663
|
NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)task.response;
|
|
659
664
|
NSData *responseData = self.uploadResponseData[uploadId] ?: [NSData data];
|
|
@@ -662,7 +667,8 @@ didCompleteWithError:(NSError *)error {
|
|
|
662
667
|
if (uploadResolve) uploadResolve(@{
|
|
663
668
|
@"success": @(httpResponse.statusCode >= 200 && httpResponse.statusCode < 300),
|
|
664
669
|
@"status": @(httpResponse.statusCode),
|
|
665
|
-
@"data": respString
|
|
670
|
+
@"data": respString,
|
|
671
|
+
@"uploadId": uploadId
|
|
666
672
|
});
|
|
667
673
|
}
|
|
668
674
|
|
|
@@ -778,7 +784,7 @@ totalBytesExpectedToSend:(int64_t)totalBytesExpectedToSend {
|
|
|
778
784
|
if (totalBytesExpectedToSend > 0) {
|
|
779
785
|
int progress = (int)((totalBytesSent * 100) / totalBytesExpectedToSend);
|
|
780
786
|
[self sendEventWithName:@"onUploadProgress"
|
|
781
|
-
body:@{@"url": url, @"progress": @(progress)}];
|
|
787
|
+
body:@{@"url": url, @"uploadId": uploadId, @"progress": @(progress)}];
|
|
782
788
|
}
|
|
783
789
|
}
|
|
784
790
|
|
|
@@ -822,6 +828,7 @@ totalBytesExpectedToSend:(int64_t)totalBytesExpectedToSend {
|
|
|
822
828
|
return;
|
|
823
829
|
}
|
|
824
830
|
|
|
831
|
+
NSString *uploadId = options[@"uploadId"] ?: [self generateDownloadId];
|
|
825
832
|
NSString *fieldName = options[@"fieldName"] ?: @"file";
|
|
826
833
|
NSDictionary *headers = options[@"headers"];
|
|
827
834
|
NSDictionary *params = options[@"parameters"];
|
|
@@ -837,28 +844,63 @@ totalBytesExpectedToSend:(int64_t)totalBytesExpectedToSend {
|
|
|
837
844
|
}
|
|
838
845
|
}
|
|
839
846
|
|
|
840
|
-
|
|
847
|
+
// Create a temporary file to avoid OutOfMemory crash for large uploads
|
|
848
|
+
NSString *tempFileName = [NSString stringWithFormat:@"upload_%@.tmp", [[NSUUID UUID] UUIDString]];
|
|
849
|
+
NSString *tempFilePath = [NSTemporaryDirectory() stringByAppendingPathComponent:tempFileName];
|
|
850
|
+
|
|
851
|
+
[[NSFileManager defaultManager] createFileAtPath:tempFilePath contents:nil attributes:nil];
|
|
852
|
+
NSFileHandle *fileHandle = [NSFileHandle fileHandleForWritingAtPath:tempFilePath];
|
|
853
|
+
if (!fileHandle) {
|
|
854
|
+
resolve(@{@"success": @NO, @"error": @"Failed to create temp file for upload"});
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
NSMutableData *preamble = [NSMutableData data];
|
|
841
859
|
[params enumerateKeysAndObjectsUsingBlock:^(id key, id value, BOOL *stop) {
|
|
842
|
-
[
|
|
843
|
-
[
|
|
844
|
-
[
|
|
860
|
+
[preamble appendData:[[NSString stringWithFormat:@"--%@\r\n", boundary] dataUsingEncoding:NSUTF8StringEncoding]];
|
|
861
|
+
[preamble appendData:[[NSString stringWithFormat:@"Content-Disposition: form-data; name=\"%@\"\r\n\r\n", key] dataUsingEncoding:NSUTF8StringEncoding]];
|
|
862
|
+
[preamble appendData:[[NSString stringWithFormat:@"%@\r\n", value] dataUsingEncoding:NSUTF8StringEncoding]];
|
|
845
863
|
}];
|
|
846
864
|
|
|
847
865
|
NSString *fileName = [filePath lastPathComponent];
|
|
848
|
-
[
|
|
849
|
-
[
|
|
850
|
-
[
|
|
851
|
-
|
|
852
|
-
[
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
866
|
+
[preamble appendData:[[NSString stringWithFormat:@"--%@\r\n", boundary] dataUsingEncoding:NSUTF8StringEncoding]];
|
|
867
|
+
[preamble appendData:[[NSString stringWithFormat:@"Content-Disposition: form-data; name=\"%@\"; filename=\"%@\"\r\n", fieldName, fileName] dataUsingEncoding:NSUTF8StringEncoding]];
|
|
868
|
+
[preamble appendData:[@"Content-Type: application/octet-stream\r\n\r\n" dataUsingEncoding:NSUTF8StringEncoding]];
|
|
869
|
+
|
|
870
|
+
[fileHandle writeData:preamble];
|
|
871
|
+
|
|
872
|
+
// Stream the actual file content to the temp file
|
|
873
|
+
NSInputStream *inputStream = [NSInputStream inputStreamWithFileAtPath:filePath];
|
|
874
|
+
[inputStream open];
|
|
875
|
+
uint8_t buffer[32768]; // 32KB chunks
|
|
876
|
+
while ([inputStream hasBytesAvailable]) {
|
|
877
|
+
NSInteger bytesRead = [inputStream read:buffer maxLength:sizeof(buffer)];
|
|
878
|
+
if (bytesRead > 0) {
|
|
879
|
+
[fileHandle writeData:[NSData dataWithBytes:buffer length:bytesRead]];
|
|
880
|
+
} else if (bytesRead < 0) {
|
|
881
|
+
break;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
[inputStream close];
|
|
885
|
+
|
|
886
|
+
NSMutableData *postamble = [NSMutableData data];
|
|
887
|
+
[postamble appendData:[@"\r\n" dataUsingEncoding:NSUTF8StringEncoding]];
|
|
888
|
+
[postamble appendData:[[NSString stringWithFormat:@"--%@--\r\n", boundary] dataUsingEncoding:NSUTF8StringEncoding]];
|
|
889
|
+
[fileHandle writeData:postamble];
|
|
890
|
+
[fileHandle closeFile];
|
|
891
|
+
|
|
892
|
+
NSURL *tempFileURL = [NSURL fileURLWithPath:tempFilePath];
|
|
893
|
+
|
|
894
|
+
// Use delegate-based session for upload progress support with fromFile: instead of fromData:
|
|
895
|
+
NSURLSessionUploadTask *task = [self.fgSession uploadTaskWithRequest:request fromFile:tempFileURL];
|
|
858
896
|
NSString *taskKey = [NSString stringWithFormat:@"%lu", (unsigned long)task.taskIdentifier];
|
|
859
897
|
|
|
860
898
|
self.uploadTaskIdMap[taskKey] = uploadId;
|
|
861
|
-
self.uploadPromises[uploadId] = @{
|
|
899
|
+
self.uploadPromises[uploadId] = @{
|
|
900
|
+
@"resolve": resolve,
|
|
901
|
+
@"reject": reject,
|
|
902
|
+
@"tempFile": tempFilePath
|
|
903
|
+
};
|
|
862
904
|
self.uploadUrls[uploadId] = urlString;
|
|
863
905
|
|
|
864
906
|
[task resume];
|