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 +1 -1
- package/android/src/main/AndroidManifest.xml +4 -2
- package/android/src/main/java/com/filetoolkit/FileToolkitModule.kt +227 -128
- package/app.plugin.js +34 -8
- package/ios/FileToolkit.mm +346 -155
- package/lib/commonjs/NativeFileToolkit.js.map +1 -1
- package/lib/commonjs/index.js +109 -30
- package/lib/commonjs/index.js.map +1 -1
- package/lib/module/NativeFileToolkit.js.map +1 -1
- package/lib/module/index.js +109 -31
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/commonjs/src/NativeFileToolkit.d.ts +2 -0
- package/lib/typescript/commonjs/src/NativeFileToolkit.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/index.d.ts +89 -8
- package/lib/typescript/commonjs/src/index.d.ts.map +1 -1
- package/lib/typescript/module/src/NativeFileToolkit.d.ts +2 -0
- package/lib/typescript/module/src/NativeFileToolkit.d.ts.map +1 -1
- package/lib/typescript/module/src/index.d.ts +89 -8
- package/lib/typescript/module/src/index.d.ts.map +1 -1
- package/package.json +1 -2
- package/react-native.config.js +8 -0
- package/src/NativeFileToolkit.ts +4 -0
- package/src/index.tsx +189 -35
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 +
|
|
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}.
|
|
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
|
-
|
|
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 ->
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
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" ->
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
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)
|
|
353
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
|
587
|
+
// Scan only toolkit-owned subdirectories to avoid returning files from other apps
|
|
538
588
|
val dirs = listOfNotNull(
|
|
539
|
-
|
|
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
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
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
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
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
|
|
978
|
-
val responseBody
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
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
|
-
|
|
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 $
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1246
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|