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.
@@ -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
- // ─── pauseDownload ─────────────────────────────────────────────────────────
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
  }
@@ -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
- NSMutableData *body = [NSMutableData data];
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
- [body appendData:[[NSString stringWithFormat:@"--%@\r\n", boundary] dataUsingEncoding:NSUTF8StringEncoding]];
843
- [body appendData:[[NSString stringWithFormat:@"Content-Disposition: form-data; name=\"%@\"\r\n\r\n", key] dataUsingEncoding:NSUTF8StringEncoding]];
844
- [body appendData:[[NSString stringWithFormat:@"%@\r\n", value] dataUsingEncoding:NSUTF8StringEncoding]];
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
- [body appendData:[[NSString stringWithFormat:@"--%@\r\n", boundary] dataUsingEncoding:NSUTF8StringEncoding]];
849
- [body appendData:[[NSString stringWithFormat:@"Content-Disposition: form-data; name=\"%@\"; filename=\"%@\"\r\n", fieldName, fileName] dataUsingEncoding:NSUTF8StringEncoding]];
850
- [body appendData:[@"Content-Type: application/octet-stream\r\n\r\n" dataUsingEncoding:NSUTF8StringEncoding]];
851
- [body appendData:[NSData dataWithContentsOfFile:filePath]];
852
- [body appendData:[@"\r\n" dataUsingEncoding:NSUTF8StringEncoding]];
853
- [body appendData:[[NSString stringWithFormat:@"--%@--\r\n", boundary] dataUsingEncoding:NSUTF8StringEncoding]];
854
-
855
- // Use delegate-based session for upload progress support
856
- NSString *uploadId = [self generateDownloadId];
857
- NSURLSessionUploadTask *task = [self.fgSession uploadTaskWithRequest:request fromData:body];
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] = @{@"resolve": resolve, @"reject": reject};
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];