rn-file-toolkit 1.0.1

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.
Files changed (34) hide show
  1. package/FileToolkit.podspec +22 -0
  2. package/LICENSE +20 -0
  3. package/README.md +522 -0
  4. package/android/build.gradle +61 -0
  5. package/android/src/main/AndroidManifest.xml +13 -0
  6. package/android/src/main/java/com/filetoolkit/FileToolkitModule.kt +1204 -0
  7. package/android/src/main/java/com/filetoolkit/FileToolkitPackage.kt +31 -0
  8. package/android/src/main/res/xml/file_provider_paths.xml +14 -0
  9. package/app.plugin.js +49 -0
  10. package/ios/FileToolkit.h +6 -0
  11. package/ios/FileToolkit.mm +1468 -0
  12. package/lib/commonjs/NativeFileToolkit.js +9 -0
  13. package/lib/commonjs/NativeFileToolkit.js.map +1 -0
  14. package/lib/commonjs/index.js +941 -0
  15. package/lib/commonjs/index.js.map +1 -0
  16. package/lib/commonjs/package.json +1 -0
  17. package/lib/module/NativeFileToolkit.js +5 -0
  18. package/lib/module/NativeFileToolkit.js.map +1 -0
  19. package/lib/module/index.js +905 -0
  20. package/lib/module/index.js.map +1 -0
  21. package/lib/module/package.json +1 -0
  22. package/lib/typescript/commonjs/package.json +1 -0
  23. package/lib/typescript/commonjs/src/NativeFileToolkit.d.ts +29 -0
  24. package/lib/typescript/commonjs/src/NativeFileToolkit.d.ts.map +1 -0
  25. package/lib/typescript/commonjs/src/index.d.ts +635 -0
  26. package/lib/typescript/commonjs/src/index.d.ts.map +1 -0
  27. package/lib/typescript/module/package.json +1 -0
  28. package/lib/typescript/module/src/NativeFileToolkit.d.ts +29 -0
  29. package/lib/typescript/module/src/NativeFileToolkit.d.ts.map +1 -0
  30. package/lib/typescript/module/src/index.d.ts +635 -0
  31. package/lib/typescript/module/src/index.d.ts.map +1 -0
  32. package/package.json +232 -0
  33. package/src/NativeFileToolkit.ts +29 -0
  34. package/src/index.tsx +1293 -0
@@ -0,0 +1,1204 @@
1
+ package com.filetoolkit
2
+
3
+ import android.app.DownloadManager
4
+ import android.content.Context
5
+ import android.content.Intent
6
+ import android.net.Uri
7
+ import android.os.Environment
8
+ import androidx.core.content.FileProvider
9
+ import com.facebook.react.bridge.ReactApplicationContext
10
+ import com.facebook.react.bridge.ReadableMap
11
+ import com.facebook.react.bridge.Promise
12
+ import com.facebook.react.bridge.Arguments
13
+ import com.facebook.react.modules.core.DeviceEventManagerModule
14
+ import java.io.File
15
+ import java.io.FileInputStream
16
+ import java.io.FileOutputStream
17
+ import java.io.RandomAccessFile
18
+ import java.net.HttpURLConnection
19
+ import java.net.URL
20
+ import java.security.MessageDigest
21
+ import java.util.UUID
22
+ import java.util.concurrent.ConcurrentHashMap
23
+ import java.util.zip.ZipEntry
24
+ import java.util.zip.ZipInputStream
25
+ import java.util.zip.ZipOutputStream
26
+ import kotlin.concurrent.thread
27
+ import android.webkit.MimeTypeMap
28
+
29
+ // ─── Per-download state ───────────────────────────────────────────────────────
30
+
31
+ private data class DownloadState(
32
+ val url: String,
33
+ val fileName: String,
34
+ val isBackground: Boolean,
35
+ @Volatile var paused: Boolean = false,
36
+ @Volatile var cancelled: Boolean = false,
37
+ /** Bytes already written (for Range resume) */
38
+ @Volatile var bytesDownloaded: Long = 0L
39
+ )
40
+
41
+ class FileToolkitModule(private val reactContext: ReactApplicationContext) :
42
+ NativeFileToolkitSpec(reactContext) {
43
+
44
+ // downloadId → state
45
+ private val activeDownloads = ConcurrentHashMap<String, DownloadState>()
46
+ // downloadId → background DownloadManager ID
47
+ private val bgDownloadIds = ConcurrentHashMap<String, Long>()
48
+ // Stored promises for foreground downloads — resolved on completion (not early)
49
+ private val foregroundPromises = ConcurrentHashMap<String, Promise>()
50
+
51
+ // ─── Helpers ───────────────────────────────────────────────────────────────
52
+
53
+ private fun emit(event: String, map: com.facebook.react.bridge.WritableMap) {
54
+ reactContext
55
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
56
+ .emit(event, map)
57
+ }
58
+
59
+ private fun resolveFileName(url: String, hint: String?): String {
60
+ if (!hint.isNullOrBlank()) return hint
61
+ var name = url.substringAfterLast("/")
62
+ if (name.contains("?")) name = name.substringBefore("?")
63
+ return name.ifBlank { "downloaded_file" }
64
+ }
65
+
66
+ private fun getDestinationFile(fileName: String, destination: String?): File {
67
+ return when (destination) {
68
+ "cache" -> File(reactContext.cacheDir, fileName)
69
+ "documents" -> {
70
+ // getExternalFilesDir can return null if external storage is unavailable
71
+ val dir = reactContext.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS)
72
+ ?: reactContext.filesDir
73
+ File(dir, fileName)
74
+ }
75
+ else -> File(
76
+ Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
77
+ fileName
78
+ )
79
+ }
80
+ }
81
+
82
+ private fun calculateChecksum(file: File, algorithm: String): String {
83
+ val javaAlgo = when (algorithm.uppercase()) {
84
+ "SHA1" -> "SHA-1"
85
+ "SHA256" -> "SHA-256"
86
+ else -> algorithm
87
+ }
88
+ val digest = MessageDigest.getInstance(javaAlgo)
89
+ val fis = FileInputStream(file)
90
+ try {
91
+ val buffer = ByteArray(8192)
92
+ var count: Int
93
+ while (fis.read(buffer).also { count = it } != -1) {
94
+ digest.update(buffer, 0, count)
95
+ }
96
+ } finally {
97
+ fis.close()
98
+ }
99
+ val bytes = digest.digest()
100
+ return bytes.joinToString("") { "%02x".format(it) }
101
+ }
102
+
103
+ // ─── download ──────────────────────────────────────────────────────────────
104
+
105
+ override fun download(options: ReadableMap, promise: Promise) {
106
+ val urlString = options.getString("url")
107
+ if (urlString.isNullOrBlank()) {
108
+ promise.resolve(Arguments.createMap().apply {
109
+ putBoolean("success", false); putString("error", "URL is missing")
110
+ })
111
+ return
112
+ }
113
+
114
+ val isBackground = options.takeIf { it.hasKey("background") }?.getBoolean("background") ?: false
115
+ val rawFileName = if (options.hasKey("fileName")) options.getString("fileName") else null
116
+ val fileName = resolveFileName(urlString, rawFileName)
117
+ val downloadId = UUID.randomUUID().toString()
118
+
119
+ val headersMap = options.getMap("headers")
120
+ val destination = options.takeIf { it.hasKey("destination") }?.getString("destination")
121
+ val notificationTitle = options.takeIf { it.hasKey("notificationTitle") }?.getString("notificationTitle")
122
+ val notificationDesc = options.takeIf { it.hasKey("notificationDescription") }?.getString("notificationDescription")
123
+ val checksumMap = options.takeIf { it.hasKey("checksum") }?.getMap("checksum")
124
+
125
+ // ─── Retry config ──────────────────────────────────────────────────────
126
+ val retryMap = options.takeIf { it.hasKey("retry") }?.getMap("retry")
127
+ val maxAttempts = retryMap?.takeIf { it.hasKey("attempts") }?.getInt("attempts") ?: 0
128
+ val baseDelay = retryMap?.takeIf { it.hasKey("delay") }?.getInt("delay") ?: 1000
129
+
130
+ val state = DownloadState(url = urlString, fileName = fileName, isBackground = isBackground)
131
+ activeDownloads[downloadId] = state
132
+
133
+ if (isBackground) {
134
+ // Use system DownloadManager — survives process death
135
+ val dm = reactContext.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
136
+ val request = DownloadManager.Request(Uri.parse(urlString)).apply {
137
+ setTitle(notificationTitle ?: fileName)
138
+ setDescription(notificationDesc ?: "rn-file-toolkit-id:$downloadId")
139
+ setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
140
+
141
+ // Add headers
142
+ headersMap?.toHashMap()?.forEach { (key, value) ->
143
+ if (value is String) addRequestHeader(key, value)
144
+ }
145
+
146
+ // Set destination
147
+ when (destination) {
148
+ "cache" -> setDestinationInExternalFilesDir(reactContext, null, fileName) // No specific 'cache' directory in DownloadManager, use files
149
+ "documents" -> setDestinationInExternalFilesDir(reactContext, Environment.DIRECTORY_DOCUMENTS, fileName)
150
+ else -> setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName)
151
+ }
152
+ }
153
+ val bgId = dm.enqueue(request)
154
+ bgDownloadIds[downloadId] = bgId
155
+ activeDownloads.remove(downloadId)
156
+
157
+ promise.resolve(Arguments.createMap().apply {
158
+ putBoolean("success", true)
159
+ putString("downloadId", downloadId)
160
+ })
161
+ return
162
+ }
163
+
164
+ // Foreground: store promise — resolve on completion (matches iOS behaviour)
165
+ foregroundPromises[downloadId] = promise
166
+
167
+ thread {
168
+ var attempt = 0
169
+ var lastError = "NETWORK_ERROR"
170
+
171
+ retryLoop@ while (true) {
172
+ // ── Before retry: emit event + wait with exponential backoff ───────
173
+ if (attempt > 0) {
174
+ state.bytesDownloaded = 0L // fresh download on retry
175
+ val delayMs = (baseDelay.toLong() * (1L shl (attempt - 1))).coerceAtMost(30_000L)
176
+ // Bug #5 fix: emit retry event BEFORE the delay so JS callback fires immediately
177
+ val retryEvt = Arguments.createMap().apply {
178
+ putString("downloadId", downloadId)
179
+ putString("url", urlString)
180
+ putInt("attempt", attempt)
181
+ putString("error", lastError)
182
+ }
183
+ emit("onDownloadRetry", retryEvt)
184
+ Thread.sleep(delayMs)
185
+ }
186
+
187
+ try {
188
+ val resumeFrom = state.bytesDownloaded
189
+ val destFile = getDestinationFile(fileName, destination)
190
+
191
+ val url = URL(urlString)
192
+ val connection = url.openConnection() as HttpURLConnection
193
+
194
+ headersMap?.toHashMap()?.forEach { (key, value) ->
195
+ if (value is String) connection.setRequestProperty(key, value)
196
+ }
197
+
198
+ if (resumeFrom > 0) {
199
+ connection.setRequestProperty("Range", "bytes=$resumeFrom-")
200
+ }
201
+ connection.connect()
202
+
203
+ val responseCode = connection.responseCode
204
+ if (responseCode != HttpURLConnection.HTTP_OK && responseCode != HttpURLConnection.HTTP_PARTIAL) {
205
+ // Server error — do NOT retry (4xx/5xx are not transient)
206
+ connection.disconnect()
207
+ activeDownloads.remove(downloadId)
208
+ foregroundPromises.remove(downloadId)?.resolve(Arguments.createMap().apply {
209
+ putBoolean("success", false)
210
+ putString("downloadId", downloadId)
211
+ putString("error", "SERVER_ERROR: $responseCode")
212
+ })
213
+ emit("onDownloadError", Arguments.createMap().apply {
214
+ putBoolean("success", false)
215
+ putString("downloadId", downloadId)
216
+ putString("error", "SERVER_ERROR: $responseCode")
217
+ })
218
+ return@thread
219
+ }
220
+
221
+ val contentLength = connection.getHeaderField("Content-Length")?.toLongOrNull() ?: -1L
222
+ val totalExpected = if (resumeFrom > 0) resumeFrom + contentLength else contentLength
223
+
224
+ // Bug #1 fix: always close streams via try-finally to prevent handle leaks between retries
225
+ val input = connection.inputStream
226
+ val output: FileOutputStream = if (resumeFrom > 0) {
227
+ FileOutputStream(destFile, true) // append
228
+ } else {
229
+ FileOutputStream(destFile, false)
230
+ }
231
+
232
+ val buffer = ByteArray(8192)
233
+ var total = resumeFrom
234
+ var count: Int
235
+ var lastProgress = -1
236
+
237
+ try {
238
+ while (input.read(buffer).also { count = it } != -1) {
239
+ while (state.paused && !state.cancelled) {
240
+ state.bytesDownloaded = total
241
+ Thread.sleep(200)
242
+ }
243
+ if (state.cancelled) {
244
+ output.flush(); output.close(); input.close()
245
+ destFile.delete()
246
+ activeDownloads.remove(downloadId)
247
+ foregroundPromises.remove(downloadId)?.resolve(
248
+ Arguments.createMap().apply {
249
+ putBoolean("success", false)
250
+ putString("downloadId", downloadId)
251
+ putString("error", "CANCELLED")
252
+ }
253
+ )
254
+ return@thread
255
+ }
256
+
257
+ output.write(buffer, 0, count)
258
+ total += count
259
+
260
+ if (totalExpected > 0) {
261
+ val progress = (total * 100 / totalExpected).toInt()
262
+ if (progress > lastProgress) {
263
+ lastProgress = progress
264
+ val evt = Arguments.createMap().apply {
265
+ putString("url", urlString)
266
+ putString("downloadId", downloadId)
267
+ putInt("progress", progress)
268
+ putDouble("bytesDownloaded", total.toDouble())
269
+ putDouble("totalBytes", totalExpected.toDouble())
270
+ }
271
+ emit("onDownloadProgress", evt)
272
+ }
273
+ }
274
+ }
275
+ output.flush()
276
+ } finally {
277
+ // Bug #1 fix: guaranteed close regardless of exception
278
+ try { output.close() } catch (_: Exception) {}
279
+ try { input.close() } catch (_: Exception) {}
280
+ connection.disconnect()
281
+ }
282
+
283
+ activeDownloads.remove(downloadId)
284
+
285
+ // ── Checksum verification ─────────────────────────────────────────
286
+ if (checksumMap != null) {
287
+ val expectedHash = checksumMap.getString("hash")
288
+ val algorithm = checksumMap.getString("algorithm")?.uppercase() ?: "MD5"
289
+ if (expectedHash != null) {
290
+ val actualHash = calculateChecksum(destFile, algorithm)
291
+ if (!actualHash.equals(expectedHash, ignoreCase = true)) {
292
+ destFile.delete()
293
+ foregroundPromises.remove(downloadId)?.resolve(Arguments.createMap().apply {
294
+ putBoolean("success", false)
295
+ putString("downloadId", downloadId)
296
+ putString("error", "CHECKSUM_MISMATCH: expected $expectedHash, got $actualHash")
297
+ })
298
+ emit("onDownloadError", Arguments.createMap().apply {
299
+ putBoolean("success", false)
300
+ putString("downloadId", downloadId)
301
+ putString("error", "CHECKSUM_MISMATCH: expected $expectedHash, got $actualHash")
302
+ })
303
+ return@thread
304
+ }
305
+ }
306
+ }
307
+
308
+ foregroundPromises.remove(downloadId)?.resolve(Arguments.createMap().apply {
309
+ putBoolean("success", true)
310
+ putString("downloadId", downloadId)
311
+ putString("filePath", destFile.absolutePath)
312
+ })
313
+ emit("onDownloadComplete", Arguments.createMap().apply {
314
+ putBoolean("success", true)
315
+ putString("downloadId", downloadId)
316
+ putString("filePath", destFile.absolutePath)
317
+ })
318
+ break@retryLoop // ✅ success
319
+
320
+ } catch (e: Exception) {
321
+ lastError = e.message ?: "NETWORK_ERROR" // Bug #3 fix: save error for next retry event
322
+ if (state.cancelled) {
323
+ activeDownloads.remove(downloadId)
324
+ foregroundPromises.remove(downloadId)?.resolve(
325
+ Arguments.createMap().apply {
326
+ putBoolean("success", false)
327
+ putString("downloadId", downloadId)
328
+ putString("error", "CANCELLED")
329
+ }
330
+ )
331
+ return@thread
332
+ }
333
+ // Network error — retry if attempts remain
334
+ if (attempt < maxAttempts) {
335
+ attempt++
336
+ // loop continues → will sleep + retry
337
+ } else {
338
+ activeDownloads.remove(downloadId)
339
+ foregroundPromises.remove(downloadId)?.resolve(Arguments.createMap().apply {
340
+ putBoolean("success", false)
341
+ putString("downloadId", downloadId)
342
+ putString("error", lastError)
343
+ })
344
+ emit("onDownloadError", Arguments.createMap().apply {
345
+ putBoolean("success", false)
346
+ putString("downloadId", downloadId)
347
+ putString("error", lastError)
348
+ })
349
+ return@thread
350
+ }
351
+ }
352
+ }
353
+ }
354
+ }
355
+
356
+ // ─── pauseDownload ─────────────────────────────────────────────────────────
357
+
358
+ override fun pauseDownload(downloadId: String, promise: Promise) {
359
+ val state = activeDownloads[downloadId]
360
+ if (state == null) {
361
+ promise.resolve(Arguments.createMap().apply {
362
+ putBoolean("success", false); putString("error", "Download not found")
363
+ })
364
+ return
365
+ }
366
+ state.paused = true
367
+ promise.resolve(Arguments.createMap().apply { putBoolean("success", true) })
368
+ }
369
+
370
+ // ─── resumeDownload ────────────────────────────────────────────────────────
371
+
372
+ override fun resumeDownload(downloadId: String, promise: Promise) {
373
+ val state = activeDownloads[downloadId]
374
+ if (state == null) {
375
+ promise.resolve(Arguments.createMap().apply {
376
+ putBoolean("success", false); putString("error", "Download not found or already finished")
377
+ })
378
+ return
379
+ }
380
+ state.paused = false
381
+ promise.resolve(Arguments.createMap().apply { putBoolean("success", true) })
382
+ }
383
+
384
+ // ─── cancelDownload ────────────────────────────────────────────────────────
385
+
386
+ override fun cancelDownload(downloadId: String, promise: Promise) {
387
+ val state = activeDownloads[downloadId]
388
+ if (state != null) {
389
+ state.cancelled = true
390
+ activeDownloads.remove(downloadId)
391
+ }
392
+ // Resolve foreground promise if download thread hasn't yet (atomic — safe from races)
393
+ foregroundPromises.remove(downloadId)?.resolve(
394
+ Arguments.createMap().apply {
395
+ putBoolean("success", false)
396
+ putString("downloadId", downloadId)
397
+ putString("error", "CANCELLED")
398
+ }
399
+ )
400
+ // Also cancel background DownloadManager downloads
401
+ val bgId = bgDownloadIds.remove(downloadId)
402
+ if (bgId != null) {
403
+ val dm = reactContext.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
404
+ dm.remove(bgId)
405
+ }
406
+ promise.resolve(Arguments.createMap().apply { putBoolean("success", true) })
407
+ }
408
+
409
+ // ─── getCachedFiles ────────────────────────────────────────────────────────
410
+
411
+ override fun getCachedFiles(promise: Promise) {
412
+ try {
413
+ val list = Arguments.createArray()
414
+
415
+ // Scan all three directories: public downloads, cache, documents
416
+ val dirs = listOfNotNull(
417
+ Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
418
+ reactContext.cacheDir,
419
+ reactContext.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS) ?: reactContext.filesDir
420
+ )
421
+
422
+ for (dir in dirs) {
423
+ dir.listFiles()?.forEach { f ->
424
+ if (!f.isFile) return@forEach
425
+ list.pushMap(Arguments.createMap().apply {
426
+ putString("fileName", f.name)
427
+ putString("filePath", f.absolutePath)
428
+ putDouble("size", f.length().toDouble())
429
+ putDouble("modifiedAt", f.lastModified().toDouble())
430
+ })
431
+ }
432
+ }
433
+
434
+ promise.resolve(Arguments.createMap().apply {
435
+ putBoolean("success", true)
436
+ putArray("files", list)
437
+ })
438
+ } catch (e: Exception) {
439
+ promise.resolve(Arguments.createMap().apply {
440
+ putBoolean("success", false); putString("error", e.message ?: "ERROR")
441
+ })
442
+ }
443
+ }
444
+
445
+ // ─── deleteFile ────────────────────────────────────────────────────────────
446
+
447
+ override fun deleteFile(filePath: String, promise: Promise) {
448
+ val file = File(filePath)
449
+ val deleted = file.delete()
450
+ promise.resolve(Arguments.createMap().apply {
451
+ putBoolean("success", deleted)
452
+ if (!deleted) putString("error", "File not found or could not be deleted")
453
+ })
454
+ }
455
+
456
+ // ─── clearCache ────────────────────────────────────────────────────────────
457
+
458
+ override fun clearCache(promise: Promise) {
459
+ try {
460
+ // Only clear app-private directories — never touch public Downloads
461
+ val dirs = listOfNotNull(
462
+ reactContext.cacheDir,
463
+ reactContext.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS) ?: reactContext.filesDir
464
+ )
465
+ for (dir in dirs) {
466
+ dir.listFiles()?.forEach { it.delete() }
467
+ }
468
+ promise.resolve(Arguments.createMap().apply { putBoolean("success", true) })
469
+ } catch (e: Exception) {
470
+ promise.resolve(Arguments.createMap().apply {
471
+ putBoolean("success", false); putString("error", e.message ?: "ERROR")
472
+ })
473
+ }
474
+ }
475
+
476
+ // ─── exists ────────────────────────────────────────────────────────────────
477
+
478
+ override fun exists(filePath: String, promise: Promise) {
479
+ try {
480
+ val file = File(filePath)
481
+ promise.resolve(Arguments.createMap().apply {
482
+ putBoolean("success", true)
483
+ putBoolean("exists", file.exists())
484
+ })
485
+ } catch (e: Exception) {
486
+ promise.resolve(Arguments.createMap().apply {
487
+ putBoolean("success", false)
488
+ putString("error", e.message ?: "EXISTS_ERROR")
489
+ })
490
+ }
491
+ }
492
+
493
+ // ─── stat ──────────────────────────────────────────────────────────────────
494
+
495
+ override fun stat(filePath: String, promise: Promise) {
496
+ try {
497
+ val file = File(filePath)
498
+ if (!file.exists()) {
499
+ promise.resolve(Arguments.createMap().apply {
500
+ putBoolean("success", false)
501
+ putString("error", "Path does not exist")
502
+ })
503
+ return
504
+ }
505
+
506
+ promise.resolve(Arguments.createMap().apply {
507
+ putBoolean("success", true)
508
+ putMap("stat", Arguments.createMap().apply {
509
+ putString("path", file.absolutePath)
510
+ putString("name", file.name)
511
+ putBoolean("isDir", file.isDirectory)
512
+ putDouble("size", if (file.isFile) file.length().toDouble() else 0.0)
513
+ putDouble("modified", file.lastModified().toDouble())
514
+ })
515
+ })
516
+ } catch (e: Exception) {
517
+ promise.resolve(Arguments.createMap().apply {
518
+ putBoolean("success", false)
519
+ putString("error", e.message ?: "STAT_ERROR")
520
+ })
521
+ }
522
+ }
523
+
524
+ // ─── readFile ──────────────────────────────────────────────────────────────
525
+
526
+ override fun readFile(filePath: String, encoding: String, promise: Promise) {
527
+ try {
528
+ val file = File(filePath)
529
+ if (!file.exists() || file.isDirectory) {
530
+ promise.resolve(Arguments.createMap().apply {
531
+ putBoolean("success", false)
532
+ putString("error", "File not found: $filePath")
533
+ })
534
+ return
535
+ }
536
+
537
+ val data = if (encoding.equals("base64", ignoreCase = true)) {
538
+ android.util.Base64.encodeToString(file.readBytes(), android.util.Base64.NO_WRAP)
539
+ } else {
540
+ file.readText(Charsets.UTF_8)
541
+ }
542
+
543
+ promise.resolve(Arguments.createMap().apply {
544
+ putBoolean("success", true)
545
+ putString("data", data)
546
+ })
547
+ } catch (e: Exception) {
548
+ promise.resolve(Arguments.createMap().apply {
549
+ putBoolean("success", false)
550
+ putString("error", e.message ?: "READ_FILE_ERROR")
551
+ })
552
+ }
553
+ }
554
+
555
+ // ─── writeFile ─────────────────────────────────────────────────────────────
556
+
557
+ override fun writeFile(filePath: String, data: String, encoding: String, promise: Promise) {
558
+ try {
559
+ val file = File(filePath)
560
+ file.parentFile?.mkdirs()
561
+
562
+ if (encoding.equals("base64", ignoreCase = true)) {
563
+ val bytes = android.util.Base64.decode(data, android.util.Base64.DEFAULT)
564
+ file.writeBytes(bytes)
565
+ } else {
566
+ file.writeText(data, Charsets.UTF_8)
567
+ }
568
+
569
+ promise.resolve(Arguments.createMap().apply {
570
+ putBoolean("success", true)
571
+ })
572
+ } catch (e: Exception) {
573
+ promise.resolve(Arguments.createMap().apply {
574
+ putBoolean("success", false)
575
+ putString("error", e.message ?: "WRITE_FILE_ERROR")
576
+ })
577
+ }
578
+ }
579
+
580
+ // ─── copyFile ──────────────────────────────────────────────────────────────
581
+
582
+ override fun copyFile(fromPath: String, toPath: String, promise: Promise) {
583
+ try {
584
+ val src = File(fromPath)
585
+ if (!src.exists() || src.isDirectory) {
586
+ promise.resolve(Arguments.createMap().apply {
587
+ putBoolean("success", false)
588
+ putString("error", "Source file not found: $fromPath")
589
+ })
590
+ return
591
+ }
592
+
593
+ val dst = File(toPath)
594
+ dst.parentFile?.mkdirs()
595
+ src.copyTo(dst, overwrite = true)
596
+
597
+ promise.resolve(Arguments.createMap().apply {
598
+ putBoolean("success", true)
599
+ })
600
+ } catch (e: Exception) {
601
+ promise.resolve(Arguments.createMap().apply {
602
+ putBoolean("success", false)
603
+ putString("error", e.message ?: "COPY_FILE_ERROR")
604
+ })
605
+ }
606
+ }
607
+
608
+ // ─── moveFile ──────────────────────────────────────────────────────────────
609
+
610
+ override fun moveFile(fromPath: String, toPath: String, promise: Promise) {
611
+ try {
612
+ val src = File(fromPath)
613
+ if (!src.exists()) {
614
+ promise.resolve(Arguments.createMap().apply {
615
+ putBoolean("success", false)
616
+ putString("error", "Source path not found: $fromPath")
617
+ })
618
+ return
619
+ }
620
+
621
+ val dst = File(toPath)
622
+ dst.parentFile?.mkdirs()
623
+
624
+ val renamed = src.renameTo(dst)
625
+ if (!renamed) {
626
+ if (src.isDirectory) {
627
+ promise.resolve(Arguments.createMap().apply {
628
+ putBoolean("success", false)
629
+ putString("error", "Moving directories is not supported")
630
+ })
631
+ return
632
+ }
633
+ src.copyTo(dst, overwrite = true)
634
+ src.delete()
635
+ }
636
+
637
+ promise.resolve(Arguments.createMap().apply {
638
+ putBoolean("success", true)
639
+ })
640
+ } catch (e: Exception) {
641
+ promise.resolve(Arguments.createMap().apply {
642
+ putBoolean("success", false)
643
+ putString("error", e.message ?: "MOVE_FILE_ERROR")
644
+ })
645
+ }
646
+ }
647
+
648
+ // ─── mkdir ─────────────────────────────────────────────────────────────────
649
+
650
+ override fun mkdir(dirPath: String, promise: Promise) {
651
+ try {
652
+ val dir = File(dirPath)
653
+ val ok = if (dir.exists()) dir.isDirectory else dir.mkdirs()
654
+ promise.resolve(Arguments.createMap().apply {
655
+ putBoolean("success", ok)
656
+ if (!ok) putString("error", "Could not create directory: $dirPath")
657
+ })
658
+ } catch (e: Exception) {
659
+ promise.resolve(Arguments.createMap().apply {
660
+ putBoolean("success", false)
661
+ putString("error", e.message ?: "MKDIR_ERROR")
662
+ })
663
+ }
664
+ }
665
+
666
+ // ─── ls ────────────────────────────────────────────────────────────────────
667
+
668
+ override fun ls(dirPath: String, promise: Promise) {
669
+ try {
670
+ val dir = File(dirPath)
671
+ if (!dir.exists() || !dir.isDirectory) {
672
+ promise.resolve(Arguments.createMap().apply {
673
+ putBoolean("success", false)
674
+ putString("error", "Directory not found: $dirPath")
675
+ })
676
+ return
677
+ }
678
+
679
+ val entries = Arguments.createArray()
680
+ dir.list()?.forEach { name -> entries.pushString(name) }
681
+
682
+ promise.resolve(Arguments.createMap().apply {
683
+ putBoolean("success", true)
684
+ putArray("entries", entries)
685
+ })
686
+ } catch (e: Exception) {
687
+ promise.resolve(Arguments.createMap().apply {
688
+ putBoolean("success", false)
689
+ putString("error", e.message ?: "LS_ERROR")
690
+ })
691
+ }
692
+ }
693
+
694
+ // ─── getBackgroundDownloads ──────────────────────────────────────────────────
695
+
696
+ override fun getBackgroundDownloads(promise: Promise) {
697
+ try {
698
+ val dm = reactContext.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
699
+ val query = DownloadManager.Query()
700
+ // We check for all states that could still be in progress or completed but not yet handled
701
+ val cursor = dm.query(query)
702
+ val results = Arguments.createArray()
703
+
704
+ if (cursor != null && cursor.moveToFirst()) {
705
+ val descIdx = cursor.getColumnIndex(DownloadManager.COLUMN_DESCRIPTION)
706
+ val idIdx = cursor.getColumnIndex(DownloadManager.COLUMN_ID)
707
+ val statusIdx = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)
708
+ val uriIdx = cursor.getColumnIndex(DownloadManager.COLUMN_URI)
709
+ val totalIdx = cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
710
+ val currentIdx = cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
711
+
712
+ do {
713
+ val description = cursor.getString(descIdx) ?: ""
714
+ if (description.startsWith("rn-file-toolkit-id:")) {
715
+ val downloadId = description.removePrefix("rn-file-toolkit-id:")
716
+ val status = cursor.getInt(statusIdx)
717
+ val total = cursor.getLong(totalIdx)
718
+ val current = cursor.getLong(currentIdx)
719
+ val progress = if (total > 0) (current * 100 / total).toInt() else 0
720
+
721
+ results.pushMap(Arguments.createMap().apply {
722
+ putString("downloadId", downloadId)
723
+ putString("url", cursor.getString(uriIdx))
724
+ putInt("status", status)
725
+ putInt("progress", progress)
726
+ })
727
+ }
728
+ } while (cursor.moveToNext())
729
+ cursor.close()
730
+ }
731
+
732
+ promise.resolve(Arguments.createMap().apply {
733
+ putBoolean("success", true)
734
+ putArray("downloads", results)
735
+ })
736
+ } catch (e: Exception) {
737
+ promise.resolve(Arguments.createMap().apply {
738
+ putBoolean("success", false); putString("error", e.message ?: "ERROR")
739
+ })
740
+ }
741
+ }
742
+
743
+ // ─── upload ──────────────────────────────────────────────────────────────────
744
+
745
+ override fun upload(options: ReadableMap, promise: Promise) {
746
+ val urlString = options.getString("url")
747
+ val filePath = options.getString("filePath")
748
+ if (urlString.isNullOrBlank() || filePath.isNullOrBlank()) {
749
+ promise.resolve(Arguments.createMap().apply {
750
+ putBoolean("success", false); putString("error", "URL or filePath is missing")
751
+ })
752
+ return
753
+ }
754
+
755
+ val fieldName = if (options.hasKey("fieldName")) options.getString("fieldName") else "file"
756
+ val headersMap = options.getMap("headers")
757
+ val paramsMap = options.getMap("parameters")
758
+
759
+ thread {
760
+ try {
761
+ val file = File(filePath)
762
+ if (!file.exists()) {
763
+ promise.resolve(Arguments.createMap().apply {
764
+ putBoolean("success", false); putString("error", "File not found")
765
+ })
766
+ return@thread
767
+ }
768
+
769
+ val boundary = "Boundary-${UUID.randomUUID()}"
770
+ val lineEnd = "\r\n"
771
+ val twoHyphens = "--"
772
+
773
+ val url = URL(urlString)
774
+ val connection = url.openConnection() as HttpURLConnection
775
+ connection.doInput = true
776
+ connection.doOutput = true
777
+ connection.useCaches = false
778
+ connection.requestMethod = "POST"
779
+ connection.setRequestProperty("Connection", "Keep-Alive")
780
+ connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary")
781
+
782
+ // Add custom headers
783
+ headersMap?.toHashMap()?.forEach { (key, value) ->
784
+ if (value is String) connection.setRequestProperty(key, value)
785
+ }
786
+
787
+ val output = connection.outputStream
788
+ val writer = output.bufferedWriter()
789
+
790
+ // Add form parameters
791
+ paramsMap?.toHashMap()?.forEach { (key, value) ->
792
+ writer.write(twoHyphens + boundary + lineEnd)
793
+ writer.write("Content-Disposition: form-data; name=\"$key\"" + lineEnd)
794
+ writer.write("Content-Type: text/plain; charset=UTF-8" + lineEnd + lineEnd)
795
+ writer.write(value.toString() + lineEnd)
796
+ }
797
+
798
+ // Add file
799
+ writer.write(twoHyphens + boundary + lineEnd)
800
+ writer.write("Content-Disposition: form-data; name=\"$fieldName\"; filename=\"${file.name}\"" + lineEnd)
801
+ writer.write("Content-Type: application/octet-stream" + lineEnd + lineEnd)
802
+ writer.flush()
803
+
804
+ val fileInput = FileInputStream(file)
805
+ val buffer = ByteArray(8192)
806
+ var bytesRead: Int
807
+ var totalUploaded = 0L
808
+ val fileSize = file.length()
809
+ var lastProgress = -1
810
+
811
+ try {
812
+ while (fileInput.read(buffer).also { bytesRead = it } != -1) {
813
+ output.write(buffer, 0, bytesRead)
814
+ totalUploaded += bytesRead
815
+
816
+ if (fileSize > 0) {
817
+ val progress = (totalUploaded * 100 / fileSize).toInt()
818
+ if (progress > lastProgress) {
819
+ lastProgress = progress
820
+ val evt = Arguments.createMap().apply {
821
+ putString("url", urlString)
822
+ putInt("progress", progress)
823
+ }
824
+ emit("onUploadProgress", evt)
825
+ }
826
+ }
827
+ }
828
+ output.flush()
829
+ writer.write(lineEnd)
830
+ writer.write(twoHyphens + boundary + twoHyphens + lineEnd)
831
+ writer.flush()
832
+ writer.close()
833
+ } finally {
834
+ try { fileInput.close() } catch (_: Exception) {}
835
+ }
836
+
837
+ val responseCode = connection.responseCode
838
+ val responseBody = if (responseCode in 200..299) {
839
+ connection.inputStream.bufferedReader().use { it.readText() }
840
+ } else {
841
+ connection.errorStream?.bufferedReader()?.use { it.readText() } ?: ""
842
+ }
843
+
844
+ promise.resolve(Arguments.createMap().apply {
845
+ putBoolean("success", responseCode in 200..299)
846
+ putInt("status", responseCode)
847
+ putString("data", responseBody)
848
+ if (responseCode !in 200..299) putString("error", "HTTP $responseCode")
849
+ })
850
+
851
+ } catch (e: Exception) {
852
+ promise.resolve(Arguments.createMap().apply {
853
+ putBoolean("success", false)
854
+ putString("error", e.message ?: "UPLOAD_ERROR")
855
+ })
856
+ }
857
+ }
858
+ }
859
+
860
+ // ─── saveBase64AsFile ──────────────────────────────────────────────────────
861
+
862
+ override fun saveBase64AsFile(options: ReadableMap, promise: Promise) {
863
+ try {
864
+ val base64String = options.getString("base64Data")
865
+ if (base64String.isNullOrBlank()) {
866
+ promise.resolve(Arguments.createMap().apply {
867
+ putBoolean("success", false)
868
+ putString("error", "base64Data is required")
869
+ })
870
+ return
871
+ }
872
+
873
+ val rawFileName = if (options.hasKey("fileName")) options.getString("fileName") else null
874
+ val fileName = rawFileName ?: "base64_file_${System.currentTimeMillis()}"
875
+ val destination = options.takeIf { it.hasKey("destination") }?.getString("destination")
876
+
877
+ // Decode base64
878
+ val decodedBytes = try {
879
+ android.util.Base64.decode(base64String, android.util.Base64.DEFAULT)
880
+ } catch (e: IllegalArgumentException) {
881
+ promise.resolve(Arguments.createMap().apply {
882
+ putBoolean("success", false)
883
+ putString("error", "Invalid base64 string: ${e.message}")
884
+ })
885
+ return
886
+ }
887
+
888
+ val destFile = getDestinationFile(fileName, destination)
889
+
890
+ // Write to file
891
+ FileOutputStream(destFile).use { fos ->
892
+ fos.write(decodedBytes)
893
+ }
894
+
895
+ promise.resolve(Arguments.createMap().apply {
896
+ putBoolean("success", true)
897
+ putString("filePath", destFile.absolutePath)
898
+ })
899
+
900
+ } catch (e: Exception) {
901
+ promise.resolve(Arguments.createMap().apply {
902
+ putBoolean("success", false)
903
+ putString("error", e.message ?: "BASE64_SAVE_ERROR")
904
+ })
905
+ }
906
+ }
907
+
908
+ // ─── urlToBase64 ───────────────────────────────────────────────────────────
909
+
910
+ override fun urlToBase64(options: ReadableMap, promise: Promise) {
911
+ thread {
912
+ try {
913
+ val urlString = options.getString("url")
914
+ if (urlString.isNullOrBlank()) {
915
+ promise.resolve(Arguments.createMap().apply {
916
+ putBoolean("success", false)
917
+ putString("error", "URL is required")
918
+ })
919
+ return@thread
920
+ }
921
+
922
+ val headersMap = options.getMap("headers")
923
+ val url = URL(urlString)
924
+ val connection = url.openConnection() as HttpURLConnection
925
+
926
+ connection.requestMethod = "GET"
927
+ connection.connectTimeout = 30000
928
+ connection.readTimeout = 30000
929
+
930
+ // Add custom headers if provided
931
+ headersMap?.toHashMap()?.forEach { (key, value) ->
932
+ connection.setRequestProperty(key, value.toString())
933
+ }
934
+
935
+ connection.connect()
936
+
937
+ if (connection.responseCode !in 200..299) {
938
+ promise.resolve(Arguments.createMap().apply {
939
+ putBoolean("success", false)
940
+ putString("error", "HTTP ${connection.responseCode}")
941
+ })
942
+ return@thread
943
+ }
944
+
945
+ // Get MIME type from response
946
+ val mimeType = connection.contentType?.split(";")?.get(0)?.trim() ?: "application/octet-stream"
947
+
948
+ // Read all bytes
949
+ val bytes = connection.inputStream.use { it.readBytes() }
950
+
951
+ // Encode to base64
952
+ val base64String = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP)
953
+
954
+ promise.resolve(Arguments.createMap().apply {
955
+ putBoolean("success", true)
956
+ putString("base64", base64String)
957
+ putString("mimeType", mimeType)
958
+ putString("dataUri", "data:$mimeType;base64,$base64String")
959
+ })
960
+
961
+ } catch (e: Exception) {
962
+ promise.resolve(Arguments.createMap().apply {
963
+ putBoolean("success", false)
964
+ putString("error", e.message ?: "URL_TO_BASE64_ERROR")
965
+ })
966
+ }
967
+ }
968
+ }
969
+
970
+ // ─── shareFile ─────────────────────────────────────────────────────────────
971
+
972
+ override fun shareFile(filePath: String, options: ReadableMap, promise: Promise) {
973
+ try {
974
+ val file = File(filePath)
975
+ if (!file.exists()) {
976
+ promise.resolve(Arguments.createMap().apply {
977
+ putBoolean("success", false)
978
+ putString("error", "File not found: $filePath")
979
+ })
980
+ return
981
+ }
982
+
983
+ val authority = "${reactContext.packageName}.fileprovider"
984
+ val contentUri = FileProvider.getUriForFile(reactContext, authority, file)
985
+
986
+ val shareIntent = Intent(Intent.ACTION_SEND).apply {
987
+ type = getMimeType(filePath)
988
+ putExtra(Intent.EXTRA_STREAM, contentUri)
989
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
990
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
991
+
992
+ // Optional title and subject from options
993
+ if (options.hasKey("title")) {
994
+ putExtra(Intent.EXTRA_TITLE, options.getString("title"))
995
+ }
996
+ if (options.hasKey("subject")) {
997
+ putExtra(Intent.EXTRA_SUBJECT, options.getString("subject"))
998
+ }
999
+ }
1000
+
1001
+ val chooserIntent = Intent.createChooser(shareIntent, "Share File")
1002
+ chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
1003
+
1004
+ reactContext.startActivity(chooserIntent)
1005
+
1006
+ promise.resolve(Arguments.createMap().apply {
1007
+ putBoolean("success", true)
1008
+ })
1009
+
1010
+ } catch (e: Exception) {
1011
+ promise.resolve(Arguments.createMap().apply {
1012
+ putBoolean("success", false)
1013
+ putString("error", e.message ?: "SHARE_ERROR")
1014
+ })
1015
+ }
1016
+ }
1017
+
1018
+ // ─── openFile ──────────────────────────────────────────────────────────────
1019
+
1020
+ override fun openFile(filePath: String, mimeType: String, promise: Promise) {
1021
+ try {
1022
+ val file = File(filePath)
1023
+ if (!file.exists()) {
1024
+ promise.resolve(Arguments.createMap().apply {
1025
+ putBoolean("success", false)
1026
+ putString("error", "File not found: $filePath")
1027
+ })
1028
+ return
1029
+ }
1030
+
1031
+ val authority = "${reactContext.packageName}.fileprovider"
1032
+ val contentUri = FileProvider.getUriForFile(reactContext, authority, file)
1033
+
1034
+ val detectedMimeType = if (mimeType.isNotBlank()) mimeType else getMimeType(filePath)
1035
+
1036
+ val openIntent = Intent(Intent.ACTION_VIEW).apply {
1037
+ setDataAndType(contentUri, detectedMimeType)
1038
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
1039
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
1040
+ }
1041
+
1042
+ // Check if there's an app to handle this file type
1043
+ val packageManager = reactContext.packageManager
1044
+ if (openIntent.resolveActivity(packageManager) != null) {
1045
+ reactContext.startActivity(openIntent)
1046
+ promise.resolve(Arguments.createMap().apply {
1047
+ putBoolean("success", true)
1048
+ })
1049
+ } else {
1050
+ promise.resolve(Arguments.createMap().apply {
1051
+ putBoolean("success", false)
1052
+ putString("error", "No app found to open this file type: $detectedMimeType")
1053
+ })
1054
+ }
1055
+
1056
+ } catch (e: Exception) {
1057
+ promise.resolve(Arguments.createMap().apply {
1058
+ putBoolean("success", false)
1059
+ putString("error", e.message ?: "OPEN_FILE_ERROR")
1060
+ })
1061
+ }
1062
+ }
1063
+
1064
+ // ─── Helper: Get MIME type ─────────────────────────────────────────────────
1065
+
1066
+ private fun getMimeType(filePath: String): String {
1067
+ val extension = filePath.substringAfterLast('.', "")
1068
+ return if (extension.isNotBlank()) {
1069
+ MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension.lowercase()) ?: "application/octet-stream"
1070
+ } else {
1071
+ "application/octet-stream"
1072
+ }
1073
+ }
1074
+
1075
+ // ─── Unzip ─────────────────────────────────────────────────────────────────
1076
+
1077
+ override fun unzip(sourcePath: String, destDir: String, promise: Promise) {
1078
+ thread {
1079
+ try {
1080
+ val sourceFile = File(sourcePath)
1081
+ if (!sourceFile.exists()) {
1082
+ promise.resolve(Arguments.createMap().apply {
1083
+ putBoolean("success", false)
1084
+ putString("error", "Source zip file does not exist: $sourcePath")
1085
+ })
1086
+ return@thread
1087
+ }
1088
+
1089
+ val destDirFile = File(destDir)
1090
+ destDirFile.mkdirs()
1091
+
1092
+ val extractedFiles = Arguments.createArray()
1093
+
1094
+ ZipInputStream(FileInputStream(sourceFile).buffered()).use { zis ->
1095
+ var entry: ZipEntry? = zis.nextEntry
1096
+ while (entry != null) {
1097
+ // Prevent zip-slip attacks
1098
+ val entryFile = File(destDirFile, entry.name)
1099
+ val canonicalDest = destDirFile.canonicalPath
1100
+ val canonicalEntry = entryFile.canonicalPath
1101
+ if (!canonicalEntry.startsWith(canonicalDest + File.separator) &&
1102
+ canonicalEntry != canonicalDest) {
1103
+ entry = zis.nextEntry
1104
+ continue
1105
+ }
1106
+
1107
+ if (entry.isDirectory) {
1108
+ entryFile.mkdirs()
1109
+ } else {
1110
+ entryFile.parentFile?.mkdirs()
1111
+ FileOutputStream(entryFile).buffered().use { fos ->
1112
+ zis.copyTo(fos)
1113
+ }
1114
+ extractedFiles.pushString(entryFile.absolutePath)
1115
+ }
1116
+ zis.closeEntry()
1117
+ entry = zis.nextEntry
1118
+ }
1119
+ }
1120
+
1121
+ promise.resolve(Arguments.createMap().apply {
1122
+ putBoolean("success", true)
1123
+ putString("destDir", destDirFile.absolutePath)
1124
+ putArray("files", extractedFiles)
1125
+ })
1126
+ } catch (e: Exception) {
1127
+ promise.resolve(Arguments.createMap().apply {
1128
+ putBoolean("success", false)
1129
+ putString("error", e.message ?: "UNZIP_ERROR")
1130
+ })
1131
+ }
1132
+ }
1133
+ }
1134
+
1135
+ // ─── Zip ───────────────────────────────────────────────────────────────────
1136
+
1137
+ override fun zip(sourcePath: String, destPath: String, promise: Promise) {
1138
+ thread {
1139
+ try {
1140
+ val sourceFile = File(sourcePath)
1141
+ if (!sourceFile.exists()) {
1142
+ promise.resolve(Arguments.createMap().apply {
1143
+ putBoolean("success", false)
1144
+ putString("error", "Source path does not exist: $sourcePath")
1145
+ })
1146
+ return@thread
1147
+ }
1148
+
1149
+ val destFile = File(destPath)
1150
+ destFile.parentFile?.mkdirs()
1151
+ destFile.delete()
1152
+
1153
+ ZipOutputStream(FileOutputStream(destFile).buffered()).use { zos ->
1154
+ if (sourceFile.isDirectory) {
1155
+ zipDirectory(sourceFile, sourceFile.name, zos)
1156
+ } else {
1157
+ zipFile(sourceFile, sourceFile.name, zos)
1158
+ }
1159
+ }
1160
+
1161
+ promise.resolve(Arguments.createMap().apply {
1162
+ putBoolean("success", true)
1163
+ putString("zipPath", destFile.absolutePath)
1164
+ })
1165
+ } catch (e: Exception) {
1166
+ promise.resolve(Arguments.createMap().apply {
1167
+ putBoolean("success", false)
1168
+ putString("error", e.message ?: "ZIP_ERROR")
1169
+ })
1170
+ }
1171
+ }
1172
+ }
1173
+
1174
+ private fun zipDirectory(dir: File, baseName: String, zos: ZipOutputStream) {
1175
+ val files = dir.listFiles() ?: return
1176
+ if (files.isEmpty()) {
1177
+ // Add empty directory entry
1178
+ zos.putNextEntry(ZipEntry("$baseName/"))
1179
+ zos.closeEntry()
1180
+ return
1181
+ }
1182
+ for (file in files) {
1183
+ val entryName = "$baseName/${file.name}"
1184
+ if (file.isDirectory) {
1185
+ zipDirectory(file, entryName, zos)
1186
+ } else {
1187
+ zipFile(file, entryName, zos)
1188
+ }
1189
+ }
1190
+ }
1191
+
1192
+ private fun zipFile(file: File, entryName: String, zos: ZipOutputStream) {
1193
+ zos.putNextEntry(ZipEntry(entryName))
1194
+ FileInputStream(file).buffered().use { fis ->
1195
+ fis.copyTo(zos)
1196
+ }
1197
+ zos.closeEntry()
1198
+ }
1199
+
1200
+ companion object {
1201
+ const val NAME = NativeFileToolkitSpec.NAME
1202
+ }
1203
+ }
1204
+