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 +1 -1
- package/android/src/main/AndroidManifest.xml +4 -2
- package/android/src/main/java/com/filetoolkit/FileToolkitModule.kt +247 -136
- package/app.plugin.js +34 -8
- package/ios/FileToolkit.mm +362 -162
- package/lib/commonjs/NativeFileToolkit.js.map +1 -1
- package/lib/commonjs/index.js +126 -33
- package/lib/commonjs/index.js.map +1 -1
- package/lib/module/NativeFileToolkit.js.map +1 -1
- package/lib/module/index.js +126 -34
- 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 +206 -38
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) {
|
|
@@ -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" ->
|
|
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
|
|
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
|
|
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" ->
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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)
|
|
347
|
-
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
|
587
|
+
// Scan only toolkit-owned subdirectories to avoid returning files from other apps
|
|
532
588
|
val dirs = listOfNotNull(
|
|
533
|
-
|
|
534
|
-
reactContext.cacheDir,
|
|
535
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
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
|
|
966
|
-
val responseBody
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
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
|
-
|
|
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 $
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1234
|
-
|
|
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
|
-
|
|
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
|
|