rn-file-toolkit 1.0.1 → 1.0.3

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]
@@ -482,7 +598,7 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
482
598
  putBoolean("success", true)
483
599
  putBoolean("exists", file.exists())
484
600
  })
485
- } catch (e: Exception) {
601
+ } catch (e: Throwable) {
486
602
  promise.resolve(Arguments.createMap().apply {
487
603
  putBoolean("success", false)
488
604
  putString("error", e.message ?: "EXISTS_ERROR")
@@ -513,7 +629,7 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
513
629
  putDouble("modified", file.lastModified().toDouble())
514
630
  })
515
631
  })
516
- } catch (e: Exception) {
632
+ } catch (e: Throwable) {
517
633
  promise.resolve(Arguments.createMap().apply {
518
634
  putBoolean("success", false)
519
635
  putString("error", e.message ?: "STAT_ERROR")
@@ -534,6 +650,15 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
534
650
  return
535
651
  }
536
652
 
653
+ // Safety check: reject files > 50MB to prevent crashing the RN bridge
654
+ if (file.length() > 50L * 1024 * 1024) {
655
+ promise.resolve(Arguments.createMap().apply {
656
+ putBoolean("success", false)
657
+ putString("error", "File exceeds 50MB limit for readFile. Use streaming or base64 encoding for large files.")
658
+ })
659
+ return
660
+ }
661
+
537
662
  val data = if (encoding.equals("base64", ignoreCase = true)) {
538
663
  android.util.Base64.encodeToString(file.readBytes(), android.util.Base64.NO_WRAP)
539
664
  } else {
@@ -544,7 +669,7 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
544
669
  putBoolean("success", true)
545
670
  putString("data", data)
546
671
  })
547
- } catch (e: Exception) {
672
+ } catch (e: Throwable) {
548
673
  promise.resolve(Arguments.createMap().apply {
549
674
  putBoolean("success", false)
550
675
  putString("error", e.message ?: "READ_FILE_ERROR")
@@ -569,7 +694,7 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
569
694
  promise.resolve(Arguments.createMap().apply {
570
695
  putBoolean("success", true)
571
696
  })
572
- } catch (e: Exception) {
697
+ } catch (e: Throwable) {
573
698
  promise.resolve(Arguments.createMap().apply {
574
699
  putBoolean("success", false)
575
700
  putString("error", e.message ?: "WRITE_FILE_ERROR")
@@ -597,7 +722,7 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
597
722
  promise.resolve(Arguments.createMap().apply {
598
723
  putBoolean("success", true)
599
724
  })
600
- } catch (e: Exception) {
725
+ } catch (e: Throwable) {
601
726
  promise.resolve(Arguments.createMap().apply {
602
727
  putBoolean("success", false)
603
728
  putString("error", e.message ?: "COPY_FILE_ERROR")
@@ -637,7 +762,7 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
637
762
  promise.resolve(Arguments.createMap().apply {
638
763
  putBoolean("success", true)
639
764
  })
640
- } catch (e: Exception) {
765
+ } catch (e: Throwable) {
641
766
  promise.resolve(Arguments.createMap().apply {
642
767
  putBoolean("success", false)
643
768
  putString("error", e.message ?: "MOVE_FILE_ERROR")
@@ -655,7 +780,7 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
655
780
  putBoolean("success", ok)
656
781
  if (!ok) putString("error", "Could not create directory: $dirPath")
657
782
  })
658
- } catch (e: Exception) {
783
+ } catch (e: Throwable) {
659
784
  promise.resolve(Arguments.createMap().apply {
660
785
  putBoolean("success", false)
661
786
  putString("error", e.message ?: "MKDIR_ERROR")
@@ -683,7 +808,7 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
683
808
  putBoolean("success", true)
684
809
  putArray("entries", entries)
685
810
  })
686
- } catch (e: Exception) {
811
+ } catch (e: Throwable) {
687
812
  promise.resolve(Arguments.createMap().apply {
688
813
  putBoolean("success", false)
689
814
  putString("error", e.message ?: "LS_ERROR")
@@ -753,6 +878,7 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
753
878
  }
754
879
 
755
880
  val fieldName = if (options.hasKey("fieldName")) options.getString("fieldName") else "file"
881
+ val uploadId = if (options.hasKey("uploadId")) options.getString("uploadId") else UUID.randomUUID().toString()
756
882
  val headersMap = options.getMap("headers")
757
883
  val paramsMap = options.getMap("parameters")
758
884
 
@@ -778,6 +904,7 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
778
904
  connection.requestMethod = "POST"
779
905
  connection.setRequestProperty("Connection", "Keep-Alive")
780
906
  connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary")
907
+ connection.setChunkedStreamingMode(0)
781
908
 
782
909
  // Add custom headers
783
910
  headersMap?.toHashMap()?.forEach { (key, value) ->
@@ -819,6 +946,7 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
819
946
  lastProgress = progress
820
947
  val evt = Arguments.createMap().apply {
821
948
  putString("url", urlString)
949
+ putString("uploadId", uploadId)
822
950
  putInt("progress", progress)
823
951
  }
824
952
  emit("onUploadProgress", evt)
@@ -845,6 +973,7 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
845
973
  putBoolean("success", responseCode in 200..299)
846
974
  putInt("status", responseCode)
847
975
  putString("data", responseBody)
976
+ putString("uploadId", uploadId)
848
977
  if (responseCode !in 200..299) putString("error", "HTTP $responseCode")
849
978
  })
850
979
 
@@ -852,6 +981,7 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
852
981
  promise.resolve(Arguments.createMap().apply {
853
982
  putBoolean("success", false)
854
983
  putString("error", e.message ?: "UPLOAD_ERROR")
984
+ putString("uploadId", uploadId)
855
985
  })
856
986
  }
857
987
  }
@@ -897,7 +1027,7 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
897
1027
  putString("filePath", destFile.absolutePath)
898
1028
  })
899
1029
 
900
- } catch (e: Exception) {
1030
+ } catch (e: Throwable) {
901
1031
  promise.resolve(Arguments.createMap().apply {
902
1032
  putBoolean("success", false)
903
1033
  putString("error", e.message ?: "BASE64_SAVE_ERROR")
@@ -958,7 +1088,7 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
958
1088
  putString("dataUri", "data:$mimeType;base64,$base64String")
959
1089
  })
960
1090
 
961
- } catch (e: Exception) {
1091
+ } catch (e: Throwable) {
962
1092
  promise.resolve(Arguments.createMap().apply {
963
1093
  putBoolean("success", false)
964
1094
  putString("error", e.message ?: "URL_TO_BASE64_ERROR")