rn-file-toolkit 1.0.9 → 1.0.11

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 CHANGED
@@ -12,7 +12,7 @@
12
12
 
13
13
  ---
14
14
 
15
- **rn-file-toolkit** is the modern replacement for legacy file libraries. Download, upload, manage queues, extract archives, and interact with the filesystem—all powered by pure native implementations (Kotlin + Swift) with **zero third-party dependencies**.
15
+ **rn-file-toolkit** is the modern replacement for legacy file libraries. Download, upload, manage queues, extract archives, and interact with the filesystem—all powered by pure native implementations (Kotlin + Objective-C++) with **zero third-party dependencies**.
16
16
 
17
17
  ⭐ **Star this repo if you find it useful to help others discover it!**
18
18
 
@@ -1,8 +1,10 @@
1
- <manifest xmlns:android="http://schemas.android.com/apk/res/android">
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android"
2
+ xmlns:tools="http://schemas.android.com/tools">
2
3
  <application>
3
4
  <provider
4
5
  android:name="androidx.core.content.FileProvider"
5
- android:authorities="${applicationId}.fileprovider"
6
+ android:authorities="${applicationId}.rn_file_toolkit.provider"
7
+ tools:replace="android:authorities"
6
8
  android:exported="false"
7
9
  android:grantUriPermissions="true">
8
10
  <meta-data
@@ -59,8 +59,9 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
59
59
  override fun run() {
60
60
  if (bgDownloadIds.isNotEmpty()) {
61
61
  pollBackgroundDownloads()
62
+ bgPollHandler.postDelayed(this, 1500)
62
63
  }
63
- bgPollHandler.postDelayed(this, 1500)
64
+ // Stop polling when no background downloads remain
64
65
  }
65
66
  }
66
67
 
@@ -82,6 +83,22 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
82
83
  }
83
84
  }
84
85
 
86
+ override fun invalidate() {
87
+ super.invalidate()
88
+ try {
89
+ reactContext.unregisterReceiver(downloadReceiver)
90
+ } catch (_: Exception) {} // Receiver may not be registered
91
+ bgPollHandler.removeCallbacks(bgPollRunnable)
92
+ }
93
+
94
+ override fun addListener(eventName: String?) {
95
+ // No-op - Required by React Native NativeEventEmitter
96
+ }
97
+
98
+ override fun removeListeners(count: Double) {
99
+ // No-op - Required by React Native NativeEventEmitter
100
+ }
101
+
85
102
  // ─── Helpers ───────────────────────────────────────────────────────────────
86
103
 
87
104
  private fun emit(event: String, map: com.facebook.react.bridge.WritableMap) {
@@ -99,17 +116,25 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
99
116
 
100
117
  private fun getDestinationFile(fileName: String, destination: String?): File {
101
118
  return when (destination) {
102
- "cache" -> File(reactContext.cacheDir, fileName)
119
+ "cache" -> {
120
+ val dir = File(reactContext.cacheDir, "RNFileToolkit")
121
+ dir.mkdirs()
122
+ File(dir, fileName)
123
+ }
103
124
  "documents" -> {
104
125
  // getExternalFilesDir can return null if external storage is unavailable
105
- val dir = reactContext.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS)
126
+ val baseDir = reactContext.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS)
106
127
  ?: reactContext.filesDir
128
+ val dir = File(baseDir, "RNFileToolkit")
129
+ dir.mkdirs()
130
+ File(dir, fileName)
131
+ }
132
+ else -> {
133
+ // Use app-specific downloads directory to avoid scoped storage issues on Android 10+
134
+ val dir = File(reactContext.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?: reactContext.filesDir, "RNFileToolkit")
135
+ dir.mkdirs()
107
136
  File(dir, fileName)
108
137
  }
109
- else -> File(
110
- Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
111
- fileName
112
- )
113
138
  }
114
139
  }
115
140
 
@@ -162,7 +187,13 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
162
187
  val baseDelay = retryMap?.takeIf { it.hasKey("delay") }?.getInt("delay") ?: 1000
163
188
 
164
189
  val state = DownloadState(url = urlString, fileName = fileName, isBackground = isBackground)
165
- activeDownloads[downloadId] = state
190
+ // Only add foreground downloads to activeDownloads the state object is used
191
+ // exclusively by the foreground pause/cancel logic. Background downloads are
192
+ // managed through bgDownloadIds / DownloadManager; adding them here created a
193
+ // race window where cancelDownload could see the state but not yet the bgId.
194
+ if (!isBackground) {
195
+ activeDownloads[downloadId] = state
196
+ }
166
197
 
167
198
  if (isBackground) {
168
199
  // Use system DownloadManager — survives process death
@@ -177,11 +208,23 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
177
208
  if (value is String) addRequestHeader(key, value)
178
209
  }
179
210
 
180
- // Set destination
211
+ // Set destination — mirror the RNFileToolkit subdirectory used by getDestinationFile
181
212
  when (destination) {
182
- "cache" -> setDestinationInExternalFilesDir(reactContext, null, fileName) // No specific 'cache' directory in DownloadManager, use files
183
- "documents" -> setDestinationInExternalFilesDir(reactContext, Environment.DIRECTORY_DOCUMENTS, fileName)
184
- else -> setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName)
213
+ "cache" -> {
214
+ val dir = File(reactContext.getExternalFilesDir(null) ?: reactContext.filesDir, "RNFileToolkit")
215
+ dir.mkdirs()
216
+ setDestinationUri(android.net.Uri.fromFile(File(dir, fileName)))
217
+ }
218
+ "documents" -> {
219
+ val dir = File(reactContext.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS) ?: reactContext.filesDir, "RNFileToolkit")
220
+ dir.mkdirs()
221
+ setDestinationUri(android.net.Uri.fromFile(File(dir, fileName)))
222
+ }
223
+ else -> {
224
+ val dir = File(reactContext.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?: reactContext.filesDir, "RNFileToolkit")
225
+ dir.mkdirs()
226
+ setDestinationUri(android.net.Uri.fromFile(File(dir, fileName)))
227
+ }
185
228
  }
186
229
  }
187
230
  val bgId = dm.enqueue(request)
@@ -210,6 +253,8 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
210
253
  // ── Before retry: emit event + wait with exponential backoff ───────
211
254
  if (attempt > 0) {
212
255
  state.bytesDownloaded = 0L // fresh download on retry
256
+ getDestinationFile(fileName, destination).delete() // Fix #5: Delete corrupted partial file
257
+
213
258
  val delayMs = (baseDelay.toLong() * (1L shl (attempt - 1))).coerceAtMost(30_000L)
214
259
  // Bug #5 fix: emit retry event BEFORE the delay so JS callback fires immediately
215
260
  val retryEvt = Arguments.createMap().apply {
@@ -219,7 +264,16 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
219
264
  putString("error", lastError)
220
265
  }
221
266
  emit("onDownloadRetry", retryEvt)
222
- Thread.sleep(delayMs)
267
+ // Interruptible retry delay — respects cancel and responds to pause
268
+ val deadline = System.currentTimeMillis() + delayMs
269
+ while (System.currentTimeMillis() < deadline) {
270
+ if (state.cancelled) break
271
+ Thread.sleep(minOf(deadline - System.currentTimeMillis(), 100L).coerceAtLeast(0L))
272
+ }
273
+ // Wait while paused (checked after the retry delay, also respects cancel)
274
+ while (state.paused && !state.cancelled) {
275
+ Thread.sleep(100L)
276
+ }
223
277
  }
224
278
 
225
279
  try {
@@ -343,16 +397,21 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
343
397
  }
344
398
  }
345
399
 
346
- foregroundPromises.remove(downloadId)?.resolve(Arguments.createMap().apply {
347
- putBoolean("success", true)
348
- putString("downloadId", downloadId)
349
- putString("filePath", destFile.absolutePath)
350
- })
351
- emit("onDownloadComplete", Arguments.createMap().apply {
400
+ val resolvedPromise = foregroundPromises.remove(downloadId)
401
+ resolvedPromise?.resolve(Arguments.createMap().apply {
352
402
  putBoolean("success", true)
353
403
  putString("downloadId", downloadId)
354
404
  putString("filePath", destFile.absolutePath)
355
405
  })
406
+ // Only emit the event when there is no foreground promise (background-style usage),
407
+ // matching iOS behaviour where onDownloadComplete fires only for background downloads.
408
+ if (resolvedPromise == null) {
409
+ emit("onDownloadComplete", Arguments.createMap().apply {
410
+ putBoolean("success", true)
411
+ putString("downloadId", downloadId)
412
+ putString("filePath", destFile.absolutePath)
413
+ })
414
+ }
356
415
  break@retryLoop // ✅ success
357
416
 
358
417
  } catch (e: Exception) {
@@ -395,78 +454,75 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
395
454
  val dm = reactContext.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
396
455
  val query = DownloadManager.Query()
397
456
  val cursor = dm.query(query) ?: return
398
-
399
457
  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())
458
+ cursor.use { c ->
459
+ if (c.moveToFirst()) {
460
+ val descIdx = c.getColumnIndex(DownloadManager.COLUMN_DESCRIPTION)
461
+ val idIdx = c.getColumnIndex(DownloadManager.COLUMN_ID)
462
+ val statusIdx = c.getColumnIndex(DownloadManager.COLUMN_STATUS)
463
+ val totalIdx = c.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
464
+ val currentIdx = c.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
465
+ do {
466
+ val bgId = c.getLong(idIdx)
467
+ if (!currentBgIds.contains(bgId)) continue
468
+ val description = c.getString(descIdx) ?: ""
469
+ if (description.startsWith("rn-file-toolkit-id:")) {
470
+ val downloadId = description.removePrefix("rn-file-toolkit-id:")
471
+ val status = c.getInt(statusIdx)
472
+ val total = c.getLong(totalIdx)
473
+ val current = c.getLong(currentIdx)
474
+ if (status == DownloadManager.STATUS_RUNNING || status == DownloadManager.STATUS_PENDING) {
475
+ val progress = if (total > 0) (current * 100 / total).toInt() else 0
476
+ val evt = Arguments.createMap().apply {
477
+ putString("downloadId", downloadId)
478
+ putInt("progress", progress)
479
+ putDouble("bytesDownloaded", current.toDouble())
480
+ putDouble("totalBytes", total.toDouble())
481
+ }
482
+ emit("onDownloadProgress", evt)
426
483
  }
427
- emit("onDownloadProgress", evt)
428
484
  }
429
- }
430
- } while (cursor.moveToNext())
485
+ } while (c.moveToNext())
486
+ }
431
487
  }
432
- cursor.close()
433
488
  }
434
489
 
435
490
  private fun handleBackgroundDownloadComplete(bgId: Long) {
436
491
  val dm = reactContext.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
437
492
  val query = DownloadManager.Query().setFilterById(bgId)
438
493
  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
- })
494
+ cursor.use {
495
+ if (it.moveToFirst()) {
496
+ val descIdx = it.getColumnIndex(DownloadManager.COLUMN_DESCRIPTION)
497
+ val statusIdx = it.getColumnIndex(DownloadManager.COLUMN_STATUS)
498
+ val uriIdx = it.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)
499
+ val reasonIdx = it.getColumnIndex(DownloadManager.COLUMN_REASON)
500
+
501
+ val description = it.getString(descIdx) ?: ""
502
+ if (description.startsWith("rn-file-toolkit-id:")) {
503
+ val downloadId = description.removePrefix("rn-file-toolkit-id:")
504
+ val status = it.getInt(statusIdx)
505
+ bgDownloadIds.remove(downloadId) // cleanup
506
+
507
+ if (status == DownloadManager.STATUS_SUCCESSFUL) {
508
+ val localUri = it.getString(uriIdx)
509
+ val filePath = localUri?.removePrefix("file://") ?: ""
510
+ emit("onDownloadComplete", Arguments.createMap().apply {
511
+ putBoolean("success", true)
512
+ putString("downloadId", downloadId)
513
+ putString("filePath", filePath)
514
+ })
515
+ } else {
516
+ val reason = it.getInt(reasonIdx)
517
+ emit("onDownloadError", Arguments.createMap().apply {
518
+ putBoolean("success", false)
519
+ putString("downloadId", downloadId)
520
+ putString("error", "DownloadManager failed with reason/code: $reason")
521
+ })
522
+ }
466
523
  }
467
524
  }
468
525
  }
469
- cursor.close()
470
526
  }
471
527
 
472
528
  // ─── executeDownload (Foreground) ─────────────────────────────────────────────────────────
@@ -528,11 +584,14 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
528
584
  try {
529
585
  val list = Arguments.createArray()
530
586
 
531
- // Scan all three directories: public downloads, cache, documents
587
+ // Scan only toolkit-owned subdirectories to avoid returning files from other apps
532
588
  val dirs = listOfNotNull(
533
- Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
534
- reactContext.cacheDir,
535
- reactContext.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS) ?: reactContext.filesDir
589
+ File(reactContext.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?: reactContext.filesDir, "RNFileToolkit"),
590
+ File(reactContext.cacheDir, "RNFileToolkit"),
591
+ File(
592
+ reactContext.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS) ?: reactContext.filesDir,
593
+ "RNFileToolkit"
594
+ )
536
595
  )
537
596
 
538
597
  for (dir in dirs) {
@@ -573,13 +632,16 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
573
632
 
574
633
  override fun clearCache(promise: Promise) {
575
634
  try {
576
- // Only clear app-private directories — never touch public Downloads
635
+ // Only clear the toolkit-owned subdirectory — never touch the app's own files or public Downloads
577
636
  val dirs = listOfNotNull(
578
- reactContext.cacheDir,
579
- reactContext.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS) ?: reactContext.filesDir
637
+ File(reactContext.cacheDir, "RNFileToolkit"),
638
+ File(
639
+ reactContext.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS) ?: reactContext.filesDir,
640
+ "RNFileToolkit"
641
+ )
580
642
  )
581
643
  for (dir in dirs) {
582
- dir.listFiles()?.forEach { it.delete() }
644
+ dir.listFiles()?.forEach { it.deleteRecursively() }
583
645
  }
584
646
  promise.resolve(Arguments.createMap().apply { putBoolean("success", true) })
585
647
  } catch (e: Exception) {
@@ -748,15 +810,10 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
748
810
 
749
811
  val renamed = src.renameTo(dst)
750
812
  if (!renamed) {
751
- if (src.isDirectory) {
752
- promise.resolve(Arguments.createMap().apply {
753
- putBoolean("success", false)
754
- putString("error", "Moving directories is not supported")
755
- })
756
- return
757
- }
758
- src.copyTo(dst, overwrite = true)
759
- src.delete()
813
+ // renameTo fails across filesystems for both files and directories.
814
+ // Fall back to recursive copy + delete, which works in all cases.
815
+ src.copyRecursively(dst, overwrite = true)
816
+ src.deleteRecursively()
760
817
  }
761
818
 
762
819
  promise.resolve(Arguments.createMap().apply {
@@ -826,32 +883,33 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
826
883
  val cursor = dm.query(query)
827
884
  val results = Arguments.createArray()
828
885
 
829
- if (cursor != null && cursor.moveToFirst()) {
830
- val descIdx = cursor.getColumnIndex(DownloadManager.COLUMN_DESCRIPTION)
831
- val idIdx = cursor.getColumnIndex(DownloadManager.COLUMN_ID)
832
- val statusIdx = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)
833
- val uriIdx = cursor.getColumnIndex(DownloadManager.COLUMN_URI)
834
- val totalIdx = cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
835
- val currentIdx = cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
836
-
837
- do {
838
- val description = cursor.getString(descIdx) ?: ""
839
- if (description.startsWith("rn-file-toolkit-id:")) {
840
- val downloadId = description.removePrefix("rn-file-toolkit-id:")
841
- val status = cursor.getInt(statusIdx)
842
- val total = cursor.getLong(totalIdx)
843
- val current = cursor.getLong(currentIdx)
844
- val progress = if (total > 0) (current * 100 / total).toInt() else 0
845
-
846
- results.pushMap(Arguments.createMap().apply {
847
- putString("downloadId", downloadId)
848
- putString("url", cursor.getString(uriIdx))
849
- putInt("status", status)
850
- putInt("progress", progress)
851
- })
852
- }
853
- } while (cursor.moveToNext())
854
- cursor.close()
886
+ cursor?.use { c ->
887
+ if (c.moveToFirst()) {
888
+ val descIdx = c.getColumnIndex(DownloadManager.COLUMN_DESCRIPTION)
889
+ val idIdx = c.getColumnIndex(DownloadManager.COLUMN_ID)
890
+ val statusIdx = c.getColumnIndex(DownloadManager.COLUMN_STATUS)
891
+ val uriIdx = c.getColumnIndex(DownloadManager.COLUMN_URI)
892
+ val totalIdx = c.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
893
+ val currentIdx = c.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
894
+
895
+ do {
896
+ val description = c.getString(descIdx) ?: ""
897
+ if (description.startsWith("rn-file-toolkit-id:")) {
898
+ val downloadId = description.removePrefix("rn-file-toolkit-id:")
899
+ val status = c.getInt(statusIdx)
900
+ val total = c.getLong(totalIdx)
901
+ val current = c.getLong(currentIdx)
902
+ val progress = if (total > 0) (current * 100 / total).toInt() else 0
903
+
904
+ results.pushMap(Arguments.createMap().apply {
905
+ putString("downloadId", downloadId)
906
+ putString("url", c.getString(uriIdx))
907
+ putInt("status", status)
908
+ putInt("progress", progress)
909
+ })
910
+ }
911
+ } while (c.moveToNext())
912
+ }
855
913
  }
856
914
 
857
915
  promise.resolve(Arguments.createMap().apply {
@@ -904,13 +962,32 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
904
962
  connection.requestMethod = "POST"
905
963
  connection.setRequestProperty("Connection", "Keep-Alive")
906
964
  connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary")
907
- connection.setChunkedStreamingMode(0)
908
965
 
909
966
  // Add custom headers
910
967
  headersMap?.toHashMap()?.forEach { (key, value) ->
911
968
  if (value is String) connection.setRequestProperty(key, value)
912
969
  }
913
970
 
971
+ // Pre-calculate the exact body size so we can use fixed-length streaming.
972
+ // Chunked transfer encoding (the old approach) is rejected by many upload
973
+ // endpoints (e.g. S3 presigned URLs, Google Cloud Storage) with 411/400.
974
+ val charset = Charsets.UTF_8
975
+ var bodySize = 0L
976
+ paramsMap?.toHashMap()?.forEach { (key, value) ->
977
+ bodySize += (twoHyphens + boundary + lineEnd).toByteArray(charset).size
978
+ bodySize += ("Content-Disposition: form-data; name=\"$key\"" + lineEnd).toByteArray(charset).size
979
+ bodySize += ("Content-Type: text/plain; charset=UTF-8" + lineEnd + lineEnd).toByteArray(charset).size
980
+ bodySize += (value.toString() + lineEnd).toByteArray(charset).size
981
+ }
982
+ bodySize += (twoHyphens + boundary + lineEnd).toByteArray(charset).size
983
+ bodySize += ("Content-Disposition: form-data; name=\"$fieldName\"; filename=\"${file.name}\"" + lineEnd).toByteArray(charset).size
984
+ bodySize += ("Content-Type: application/octet-stream" + lineEnd + lineEnd).toByteArray(charset).size
985
+ bodySize += file.length()
986
+ bodySize += lineEnd.toByteArray(charset).size
987
+ bodySize += (twoHyphens + boundary + twoHyphens + lineEnd).toByteArray(charset).size
988
+
989
+ connection.setFixedLengthStreamingMode(bodySize)
990
+
914
991
  val output = connection.outputStream
915
992
  val writer = output.bufferedWriter()
916
993
 
@@ -957,16 +1034,24 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
957
1034
  writer.write(lineEnd)
958
1035
  writer.write(twoHyphens + boundary + twoHyphens + lineEnd)
959
1036
  writer.flush()
960
- writer.close()
961
1037
  } finally {
1038
+ // Guarantee streams are closed even if an exception is thrown while writing.
1039
+ try { writer.close() } catch (_: Exception) {}
962
1040
  try { fileInput.close() } catch (_: Exception) {}
963
1041
  }
964
1042
 
965
- val responseCode = connection.responseCode
966
- val responseBody = if (responseCode in 200..299) {
967
- connection.inputStream.bufferedReader().use { it.readText() }
968
- } else {
969
- connection.errorStream?.bufferedReader()?.use { it.readText() } ?: ""
1043
+ val responseCode: Int
1044
+ val responseBody: String
1045
+ try {
1046
+ responseCode = connection.responseCode
1047
+ responseBody = if (responseCode in 200..299) {
1048
+ connection.inputStream.bufferedReader().use { it.readText() }
1049
+ } else {
1050
+ connection.errorStream?.bufferedReader()?.use { it.readText() } ?: ""
1051
+ }
1052
+ } finally {
1053
+ // Guarantee the connection is released even if reading the response throws.
1054
+ try { connection.disconnect() } catch (_: Exception) {}
970
1055
  }
971
1056
 
972
1057
  promise.resolve(Arguments.createMap().apply {
@@ -1064,10 +1149,12 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
1064
1149
 
1065
1150
  connection.connect()
1066
1151
 
1067
- if (connection.responseCode !in 200..299) {
1152
+ val responseCode = connection.responseCode
1153
+ if (responseCode !in 200..299) {
1154
+ connection.disconnect()
1068
1155
  promise.resolve(Arguments.createMap().apply {
1069
1156
  putBoolean("success", false)
1070
- putString("error", "HTTP ${connection.responseCode}")
1157
+ putString("error", "HTTP $responseCode")
1071
1158
  })
1072
1159
  return@thread
1073
1160
  }
@@ -1075,8 +1162,28 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
1075
1162
  // Get MIME type from response
1076
1163
  val mimeType = connection.contentType?.split(";")?.get(0)?.trim() ?: "application/octet-stream"
1077
1164
 
1078
- // Read all bytes
1079
- val bytes = connection.inputStream.use { it.readBytes() }
1165
+ // Read all bytes — cap at 50 MB to match readFile limit and prevent OOM.
1166
+ val maxBytes = 50L * 1024 * 1024
1167
+ val contentLength = connection.contentLength.toLong()
1168
+ if (contentLength > maxBytes) {
1169
+ connection.disconnect()
1170
+ promise.resolve(Arguments.createMap().apply {
1171
+ putBoolean("success", false)
1172
+ putString("error", "Response exceeds 50 MB limit for urlToBase64")
1173
+ })
1174
+ return@thread
1175
+ }
1176
+ val bytes = connection.inputStream.use { input ->
1177
+ val buf = input.readBytes()
1178
+ if (buf.size > maxBytes) {
1179
+ promise.resolve(Arguments.createMap().apply {
1180
+ putBoolean("success", false)
1181
+ putString("error", "Response exceeds 50 MB limit for urlToBase64")
1182
+ })
1183
+ return@thread
1184
+ }
1185
+ buf
1186
+ }
1080
1187
 
1081
1188
  // Encode to base64
1082
1189
  val base64String = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP)
@@ -1110,7 +1217,8 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
1110
1217
  return
1111
1218
  }
1112
1219
 
1113
- val authority = "${reactContext.packageName}.fileprovider"
1220
+ // Use the unique authority registered in AndroidManifest.xml (Fix #17)
1221
+ val authority = "${reactContext.packageName}.rn_file_toolkit.provider"
1114
1222
  val contentUri = FileProvider.getUriForFile(reactContext, authority, file)
1115
1223
 
1116
1224
  val shareIntent = Intent(Intent.ACTION_SEND).apply {
@@ -1158,7 +1266,8 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
1158
1266
  return
1159
1267
  }
1160
1268
 
1161
- val authority = "${reactContext.packageName}.fileprovider"
1269
+ // Use the unique authority registered in AndroidManifest.xml (Fix #17)
1270
+ val authority = "${reactContext.packageName}.rn_file_toolkit.provider"
1162
1271
  val contentUri = FileProvider.getUriForFile(reactContext, authority, file)
1163
1272
 
1164
1273
  val detectedMimeType = if (mimeType.isNotBlank()) mimeType else getMimeType(filePath)
@@ -1230,8 +1339,10 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
1230
1339
  val canonicalEntry = entryFile.canonicalPath
1231
1340
  if (!canonicalEntry.startsWith(canonicalDest + File.separator) &&
1232
1341
  canonicalEntry != canonicalDest) {
1233
- entry = zis.nextEntry
1234
- continue
1342
+ // Refuse to extract entries that escape the destination directory
1343
+ // (zip-slip attack). Return an error instead of silently skipping
1344
+ // to match iOS behaviour and prevent partial extractions.
1345
+ throw SecurityException("ZIP entry has invalid path: ${entry.name}")
1235
1346
  }
1236
1347
 
1237
1348
  if (entry.isDirectory) {
package/app.plugin.js CHANGED
@@ -13,18 +13,37 @@ const pkg = require('./package.json');
13
13
  const withFileToolkitPermissions = (config) => {
14
14
  // 1. Android: Add internet and storage permissions
15
15
  config = withAndroidManifest(config, (modConfig) => {
16
+ const manifest = modConfig.modResults.manifest;
17
+
18
+ // Ensure uses-permission array exists
19
+ if (!manifest['uses-permission']) {
20
+ manifest['uses-permission'] = [];
21
+ }
22
+
23
+ // INTERNET — no maxSdkVersion needed
16
24
  AndroidConfig.Permissions.addPermission(
17
25
  modConfig.modResults,
18
26
  'android.permission.INTERNET'
19
27
  );
20
- AndroidConfig.Permissions.addPermission(
21
- modConfig.modResults,
22
- 'android.permission.WRITE_EXTERNAL_STORAGE'
23
- );
24
- AndroidConfig.Permissions.addPermission(
25
- modConfig.modResults,
26
- 'android.permission.READ_EXTERNAL_STORAGE'
27
- );
28
+
29
+ // Storage permissions — only needed on API ≤ 32 (deprecated on Android 13+)
30
+ const addPermissionWithMaxSdk = (name, maxSdk) => {
31
+ const exists = manifest['uses-permission'].some(
32
+ (p) => p.$?.['android:name'] === name
33
+ );
34
+ if (!exists) {
35
+ manifest['uses-permission'].push({
36
+ $: {
37
+ 'android:name': name,
38
+ 'android:maxSdkVersion': String(maxSdk),
39
+ },
40
+ });
41
+ }
42
+ };
43
+
44
+ addPermissionWithMaxSdk('android.permission.WRITE_EXTERNAL_STORAGE', 32);
45
+ addPermissionWithMaxSdk('android.permission.READ_EXTERNAL_STORAGE', 32);
46
+
28
47
  return modConfig;
29
48
  });
30
49
 
@@ -36,6 +55,13 @@ const withFileToolkitPermissions = (config) => {
36
55
  if (!modConfig.modResults.UIBackgroundModes.includes('fetch')) {
37
56
  modConfig.modResults.UIBackgroundModes.push('fetch');
38
57
  }
58
+
59
+ // Photo Library write permission for saveToMediaStore
60
+ if (!modConfig.modResults.NSPhotoLibraryAddUsageDescription) {
61
+ modConfig.modResults.NSPhotoLibraryAddUsageDescription =
62
+ 'Allow $(PRODUCT_NAME) to save downloaded media to your Photo Library';
63
+ }
64
+
39
65
  return modConfig;
40
66
  });
41
67