rn-file-toolkit 1.0.10 → 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) {
@@ -112,10 +129,12 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
112
129
  dir.mkdirs()
113
130
  File(dir, fileName)
114
131
  }
115
- else -> File(
116
- Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
117
- fileName
118
- )
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()
136
+ File(dir, fileName)
137
+ }
119
138
  }
120
139
  }
121
140
 
@@ -168,7 +187,13 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
168
187
  val baseDelay = retryMap?.takeIf { it.hasKey("delay") }?.getInt("delay") ?: 1000
169
188
 
170
189
  val state = DownloadState(url = urlString, fileName = fileName, isBackground = isBackground)
171
- 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
+ }
172
197
 
173
198
  if (isBackground) {
174
199
  // Use system DownloadManager — survives process death
@@ -183,11 +208,23 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
183
208
  if (value is String) addRequestHeader(key, value)
184
209
  }
185
210
 
186
- // Set destination
211
+ // Set destination — mirror the RNFileToolkit subdirectory used by getDestinationFile
187
212
  when (destination) {
188
- "cache" -> setDestinationInExternalFilesDir(reactContext, null, fileName) // No specific 'cache' directory in DownloadManager, use files
189
- "documents" -> setDestinationInExternalFilesDir(reactContext, Environment.DIRECTORY_DOCUMENTS, fileName)
190
- 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
+ }
191
228
  }
192
229
  }
193
230
  val bgId = dm.enqueue(request)
@@ -216,6 +253,8 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
216
253
  // ── Before retry: emit event + wait with exponential backoff ───────
217
254
  if (attempt > 0) {
218
255
  state.bytesDownloaded = 0L // fresh download on retry
256
+ getDestinationFile(fileName, destination).delete() // Fix #5: Delete corrupted partial file
257
+
219
258
  val delayMs = (baseDelay.toLong() * (1L shl (attempt - 1))).coerceAtMost(30_000L)
220
259
  // Bug #5 fix: emit retry event BEFORE the delay so JS callback fires immediately
221
260
  val retryEvt = Arguments.createMap().apply {
@@ -225,7 +264,16 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
225
264
  putString("error", lastError)
226
265
  }
227
266
  emit("onDownloadRetry", retryEvt)
228
- 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
+ }
229
277
  }
230
278
 
231
279
  try {
@@ -349,16 +397,21 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
349
397
  }
350
398
  }
351
399
 
352
- foregroundPromises.remove(downloadId)?.resolve(Arguments.createMap().apply {
353
- putBoolean("success", true)
354
- putString("downloadId", downloadId)
355
- putString("filePath", destFile.absolutePath)
356
- })
357
- emit("onDownloadComplete", Arguments.createMap().apply {
400
+ val resolvedPromise = foregroundPromises.remove(downloadId)
401
+ resolvedPromise?.resolve(Arguments.createMap().apply {
358
402
  putBoolean("success", true)
359
403
  putString("downloadId", downloadId)
360
404
  putString("filePath", destFile.absolutePath)
361
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
+ }
362
415
  break@retryLoop // ✅ success
363
416
 
364
417
  } catch (e: Exception) {
@@ -401,78 +454,75 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
401
454
  val dm = reactContext.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
402
455
  val query = DownloadManager.Query()
403
456
  val cursor = dm.query(query) ?: return
404
-
405
457
  val currentBgIds = bgDownloadIds.values.toSet()
406
-
407
- if (cursor.moveToFirst()) {
408
- val descIdx = cursor.getColumnIndex(DownloadManager.COLUMN_DESCRIPTION)
409
- val idIdx = cursor.getColumnIndex(DownloadManager.COLUMN_ID)
410
- val statusIdx = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)
411
- val totalIdx = cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
412
- val currentIdx = cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
413
-
414
- do {
415
- val bgId = cursor.getLong(idIdx)
416
- if (!currentBgIds.contains(bgId)) continue
417
-
418
- val description = cursor.getString(descIdx) ?: ""
419
- if (description.startsWith("rn-file-toolkit-id:")) {
420
- val downloadId = description.removePrefix("rn-file-toolkit-id:")
421
- val status = cursor.getInt(statusIdx)
422
- val total = cursor.getLong(totalIdx)
423
- val current = cursor.getLong(currentIdx)
424
-
425
- if (status == DownloadManager.STATUS_RUNNING || status == DownloadManager.STATUS_PENDING) {
426
- val progress = if (total > 0) (current * 100 / total).toInt() else 0
427
- val evt = Arguments.createMap().apply {
428
- putString("downloadId", downloadId)
429
- putInt("progress", progress)
430
- putDouble("bytesDownloaded", current.toDouble())
431
- 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)
432
483
  }
433
- emit("onDownloadProgress", evt)
434
484
  }
435
- }
436
- } while (cursor.moveToNext())
485
+ } while (c.moveToNext())
486
+ }
437
487
  }
438
- cursor.close()
439
488
  }
440
489
 
441
490
  private fun handleBackgroundDownloadComplete(bgId: Long) {
442
491
  val dm = reactContext.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
443
492
  val query = DownloadManager.Query().setFilterById(bgId)
444
493
  val cursor = dm.query(query) ?: return
445
- if (cursor.moveToFirst()) {
446
- val descIdx = cursor.getColumnIndex(DownloadManager.COLUMN_DESCRIPTION)
447
- val statusIdx = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)
448
- val uriIdx = cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)
449
- val reasonIdx = cursor.getColumnIndex(DownloadManager.COLUMN_REASON)
450
-
451
- val description = cursor.getString(descIdx) ?: ""
452
- if (description.startsWith("rn-file-toolkit-id:")) {
453
- val downloadId = description.removePrefix("rn-file-toolkit-id:")
454
- val status = cursor.getInt(statusIdx)
455
- bgDownloadIds.remove(downloadId) // cleanup
456
-
457
- if (status == DownloadManager.STATUS_SUCCESSFUL) {
458
- val localUri = cursor.getString(uriIdx)
459
- val filePath = localUri?.removePrefix("file://") ?: ""
460
- emit("onDownloadComplete", Arguments.createMap().apply {
461
- putBoolean("success", true)
462
- putString("downloadId", downloadId)
463
- putString("filePath", filePath)
464
- })
465
- } else {
466
- val reason = cursor.getInt(reasonIdx)
467
- emit("onDownloadError", Arguments.createMap().apply {
468
- putBoolean("success", false)
469
- putString("downloadId", downloadId)
470
- putString("error", "DownloadManager failed with reason/code: $reason")
471
- })
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
+ }
472
523
  }
473
524
  }
474
525
  }
475
- cursor.close()
476
526
  }
477
527
 
478
528
  // ─── executeDownload (Foreground) ─────────────────────────────────────────────────────────
@@ -534,9 +584,9 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
534
584
  try {
535
585
  val list = Arguments.createArray()
536
586
 
537
- // Scan all three directories: public downloads, cache, documents
587
+ // Scan only toolkit-owned subdirectories to avoid returning files from other apps
538
588
  val dirs = listOfNotNull(
539
- Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
589
+ File(reactContext.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?: reactContext.filesDir, "RNFileToolkit"),
540
590
  File(reactContext.cacheDir, "RNFileToolkit"),
541
591
  File(
542
592
  reactContext.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS) ?: reactContext.filesDir,
@@ -760,15 +810,10 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
760
810
 
761
811
  val renamed = src.renameTo(dst)
762
812
  if (!renamed) {
763
- if (src.isDirectory) {
764
- promise.resolve(Arguments.createMap().apply {
765
- putBoolean("success", false)
766
- putString("error", "Moving directories is not supported")
767
- })
768
- return
769
- }
770
- src.copyTo(dst, overwrite = true)
771
- 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()
772
817
  }
773
818
 
774
819
  promise.resolve(Arguments.createMap().apply {
@@ -838,32 +883,33 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
838
883
  val cursor = dm.query(query)
839
884
  val results = Arguments.createArray()
840
885
 
841
- if (cursor != null && cursor.moveToFirst()) {
842
- val descIdx = cursor.getColumnIndex(DownloadManager.COLUMN_DESCRIPTION)
843
- val idIdx = cursor.getColumnIndex(DownloadManager.COLUMN_ID)
844
- val statusIdx = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)
845
- val uriIdx = cursor.getColumnIndex(DownloadManager.COLUMN_URI)
846
- val totalIdx = cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
847
- val currentIdx = cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
848
-
849
- do {
850
- val description = cursor.getString(descIdx) ?: ""
851
- if (description.startsWith("rn-file-toolkit-id:")) {
852
- val downloadId = description.removePrefix("rn-file-toolkit-id:")
853
- val status = cursor.getInt(statusIdx)
854
- val total = cursor.getLong(totalIdx)
855
- val current = cursor.getLong(currentIdx)
856
- val progress = if (total > 0) (current * 100 / total).toInt() else 0
857
-
858
- results.pushMap(Arguments.createMap().apply {
859
- putString("downloadId", downloadId)
860
- putString("url", cursor.getString(uriIdx))
861
- putInt("status", status)
862
- putInt("progress", progress)
863
- })
864
- }
865
- } while (cursor.moveToNext())
866
- 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
+ }
867
913
  }
868
914
 
869
915
  promise.resolve(Arguments.createMap().apply {
@@ -916,13 +962,32 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
916
962
  connection.requestMethod = "POST"
917
963
  connection.setRequestProperty("Connection", "Keep-Alive")
918
964
  connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary")
919
- connection.setChunkedStreamingMode(0)
920
965
 
921
966
  // Add custom headers
922
967
  headersMap?.toHashMap()?.forEach { (key, value) ->
923
968
  if (value is String) connection.setRequestProperty(key, value)
924
969
  }
925
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
+
926
991
  val output = connection.outputStream
927
992
  val writer = output.bufferedWriter()
928
993
 
@@ -969,16 +1034,24 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
969
1034
  writer.write(lineEnd)
970
1035
  writer.write(twoHyphens + boundary + twoHyphens + lineEnd)
971
1036
  writer.flush()
972
- writer.close()
973
1037
  } finally {
1038
+ // Guarantee streams are closed even if an exception is thrown while writing.
1039
+ try { writer.close() } catch (_: Exception) {}
974
1040
  try { fileInput.close() } catch (_: Exception) {}
975
1041
  }
976
1042
 
977
- val responseCode = connection.responseCode
978
- val responseBody = if (responseCode in 200..299) {
979
- connection.inputStream.bufferedReader().use { it.readText() }
980
- } else {
981
- 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) {}
982
1055
  }
983
1056
 
984
1057
  promise.resolve(Arguments.createMap().apply {
@@ -1076,10 +1149,12 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
1076
1149
 
1077
1150
  connection.connect()
1078
1151
 
1079
- if (connection.responseCode !in 200..299) {
1152
+ val responseCode = connection.responseCode
1153
+ if (responseCode !in 200..299) {
1154
+ connection.disconnect()
1080
1155
  promise.resolve(Arguments.createMap().apply {
1081
1156
  putBoolean("success", false)
1082
- putString("error", "HTTP ${connection.responseCode}")
1157
+ putString("error", "HTTP $responseCode")
1083
1158
  })
1084
1159
  return@thread
1085
1160
  }
@@ -1087,8 +1162,28 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
1087
1162
  // Get MIME type from response
1088
1163
  val mimeType = connection.contentType?.split(";")?.get(0)?.trim() ?: "application/octet-stream"
1089
1164
 
1090
- // Read all bytes
1091
- 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
+ }
1092
1187
 
1093
1188
  // Encode to base64
1094
1189
  val base64String = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP)
@@ -1122,7 +1217,8 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
1122
1217
  return
1123
1218
  }
1124
1219
 
1125
- 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"
1126
1222
  val contentUri = FileProvider.getUriForFile(reactContext, authority, file)
1127
1223
 
1128
1224
  val shareIntent = Intent(Intent.ACTION_SEND).apply {
@@ -1170,7 +1266,8 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
1170
1266
  return
1171
1267
  }
1172
1268
 
1173
- 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"
1174
1271
  val contentUri = FileProvider.getUriForFile(reactContext, authority, file)
1175
1272
 
1176
1273
  val detectedMimeType = if (mimeType.isNotBlank()) mimeType else getMimeType(filePath)
@@ -1242,8 +1339,10 @@ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
1242
1339
  val canonicalEntry = entryFile.canonicalPath
1243
1340
  if (!canonicalEntry.startsWith(canonicalDest + File.separator) &&
1244
1341
  canonicalEntry != canonicalDest) {
1245
- entry = zis.nextEntry
1246
- 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}")
1247
1346
  }
1248
1347
 
1249
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