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.
- package/FileToolkit.podspec +22 -0
- package/LICENSE +20 -0
- package/README.md +522 -0
- package/android/build.gradle +61 -0
- package/android/src/main/AndroidManifest.xml +13 -0
- package/android/src/main/java/com/filetoolkit/FileToolkitModule.kt +1204 -0
- package/android/src/main/java/com/filetoolkit/FileToolkitPackage.kt +31 -0
- package/android/src/main/res/xml/file_provider_paths.xml +14 -0
- package/app.plugin.js +49 -0
- package/ios/FileToolkit.h +6 -0
- package/ios/FileToolkit.mm +1468 -0
- package/lib/commonjs/NativeFileToolkit.js +9 -0
- package/lib/commonjs/NativeFileToolkit.js.map +1 -0
- package/lib/commonjs/index.js +941 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/package.json +1 -0
- package/lib/module/NativeFileToolkit.js +5 -0
- package/lib/module/NativeFileToolkit.js.map +1 -0
- package/lib/module/index.js +905 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/typescript/commonjs/package.json +1 -0
- package/lib/typescript/commonjs/src/NativeFileToolkit.d.ts +29 -0
- package/lib/typescript/commonjs/src/NativeFileToolkit.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/index.d.ts +635 -0
- package/lib/typescript/commonjs/src/index.d.ts.map +1 -0
- package/lib/typescript/module/package.json +1 -0
- package/lib/typescript/module/src/NativeFileToolkit.d.ts +29 -0
- package/lib/typescript/module/src/NativeFileToolkit.d.ts.map +1 -0
- package/lib/typescript/module/src/index.d.ts +635 -0
- package/lib/typescript/module/src/index.d.ts.map +1 -0
- package/package.json +232 -0
- package/src/NativeFileToolkit.ts +29 -0
- 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
|
+
|