@ammarahmed/react-native-upload 6.33.0 → 7.0.0-alpha.0

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.
@@ -0,0 +1,787 @@
1
+ package com.appfolio.uploader
2
+
3
+ import android.content.Context
4
+ import android.util.Log
5
+ import com.facebook.react.bridge.Arguments
6
+ import com.facebook.react.bridge.ReactApplicationContext
7
+ import com.facebook.react.bridge.ReadableMap
8
+ import com.facebook.react.bridge.ReadableType
9
+ import com.facebook.react.bridge.UiThreadUtil
10
+ import com.facebook.react.bridge.WritableArray
11
+ import com.facebook.react.bridge.WritableMap
12
+ import com.facebook.react.jstasks.HeadlessJsTaskConfig
13
+ import com.facebook.react.jstasks.HeadlessJsTaskContext
14
+ import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter
15
+ import kotlinx.coroutines.CancellationException
16
+ import kotlinx.coroutines.CompletableDeferred
17
+ import kotlinx.coroutines.CoroutineScope
18
+ import kotlinx.coroutines.CoroutineStart
19
+ import kotlinx.coroutines.Dispatchers
20
+ import kotlinx.coroutines.Job
21
+ import kotlinx.coroutines.SupervisorJob
22
+ import kotlinx.coroutines.async
23
+ import kotlinx.coroutines.awaitAll
24
+ import kotlinx.coroutines.coroutineScope
25
+ import kotlinx.coroutines.delay
26
+ import kotlinx.coroutines.isActive
27
+ import kotlinx.coroutines.launch
28
+ import kotlinx.coroutines.sync.Semaphore
29
+ import kotlinx.coroutines.sync.withPermit
30
+ import kotlinx.coroutines.withContext
31
+ import okhttp3.Call
32
+ import okhttp3.MediaType
33
+ import okhttp3.MediaType.Companion.toMediaTypeOrNull
34
+ import okhttp3.OkHttpClient
35
+ import okhttp3.Request
36
+ import okhttp3.RequestBody
37
+ import okio.BufferedSink
38
+ import org.json.JSONArray
39
+ import org.json.JSONObject
40
+ import java.io.File
41
+ import java.io.IOException
42
+ import java.io.RandomAccessFile
43
+ import java.util.concurrent.ConcurrentHashMap
44
+ import java.util.concurrent.TimeUnit
45
+ import java.util.concurrent.atomic.AtomicBoolean
46
+ import java.util.concurrent.atomic.AtomicLong
47
+
48
+ class UploadAlreadyExistsException(message: String) : Exception(message)
49
+
50
+ data class UploadParams(
51
+ val uploadId: String,
52
+ val path: String,
53
+ val parts: List<String>,
54
+ val maxParallel: Int,
55
+ val notificationEnabled: Boolean,
56
+ val title: String,
57
+ val startMessage: String,
58
+ val resumeMessage: String,
59
+ val progressMessage: String,
60
+ val channelName: String,
61
+ val existingEtags: List<String>,
62
+ val resumable: Boolean = true
63
+ ) {
64
+ fun toJson(): JSONObject = JSONObject().apply {
65
+ put("uploadId", uploadId)
66
+ put("path", path)
67
+ put("parts", JSONArray(parts))
68
+ put("maxParallel", maxParallel)
69
+ put("notificationEnabled", notificationEnabled)
70
+ put("title", title)
71
+ put("startMessage", startMessage)
72
+ put("resumeMessage", resumeMessage)
73
+ put("progressMessage", progressMessage)
74
+ put("channelName", channelName)
75
+ put("resumable", resumable)
76
+ }
77
+
78
+ companion object {
79
+ fun fromJson(json: JSONObject, existingEtags: List<String>): UploadParams {
80
+ val partsJson = json.getJSONArray("parts")
81
+ return UploadParams(
82
+ uploadId = json.getString("uploadId"),
83
+ path = json.getString("path"),
84
+ parts = List(partsJson.length()) { partsJson.getString(it) },
85
+ maxParallel = json.getInt("maxParallel"),
86
+ notificationEnabled = json.getBoolean("notificationEnabled"),
87
+ title = json.getString("title"),
88
+ startMessage = json.getString("startMessage"),
89
+ resumeMessage = json.getString("resumeMessage"),
90
+ progressMessage = json.getString("progressMessage"),
91
+ channelName = json.getString("channelName"),
92
+ existingEtags = existingEtags,
93
+ resumable = json.optBoolean("resumable", true)
94
+ )
95
+ }
96
+
97
+ fun fromReadableMap(options: ReadableMap): UploadParams {
98
+ val uploadId = options.getString("uploadId")
99
+ ?: throw IllegalArgumentException("uploadId is required")
100
+ val rawPath = options.getString("path")
101
+ ?: throw IllegalArgumentException("path is required")
102
+ val path = if (rawPath.startsWith("file://")) rawPath.substring(7) else rawPath
103
+
104
+ val partsArray = options.getArray("parts")
105
+ ?: throw IllegalArgumentException("parts is required")
106
+ if (partsArray.size() == 0) {
107
+ throw IllegalArgumentException("parts array cannot be empty")
108
+ }
109
+ val parts = List(partsArray.size()) { i -> partsArray.getString(i)!! }
110
+
111
+ val maxParallel = if (options.hasKey("maxParallel") && options.getType("maxParallel") == ReadableType.Number) {
112
+ options.getInt("maxParallel")
113
+ } else {
114
+ 3
115
+ }
116
+
117
+ val notification = if (options.hasKey("notification") && options.getType("notification") == ReadableType.Map) {
118
+ options.getMap("notification")
119
+ } else null
120
+
121
+ fun text(key: String, default: String): String =
122
+ if (notification != null && notification.hasKey(key) && notification.getType(key) == ReadableType.String) {
123
+ notification.getString(key) ?: default
124
+ } else default
125
+
126
+ val enabled = notification?.hasKey("enabled") == true && notification.getBoolean("enabled")
127
+ val filename = text("filename", File(path).name)
128
+
129
+ val existing = if (options.hasKey("existingEtags") && options.getType("existingEtags") == ReadableType.Array) {
130
+ val array = options.getArray("existingEtags")!!
131
+ List(array.size()) { i -> array.getString(i) ?: "" }
132
+ } else emptyList()
133
+
134
+ return UploadParams(
135
+ uploadId = uploadId,
136
+ path = path,
137
+ parts = parts,
138
+ maxParallel = maxParallel,
139
+ notificationEnabled = enabled,
140
+ title = text("onProgressTitle", filename),
141
+ startMessage = text("onStartMessage", "Starting upload..."),
142
+ resumeMessage = text("onResumeMessage", "Resuming upload..."),
143
+ progressMessage = text("onProgressMessage", "Uploading... {progress}%"),
144
+ channelName = text("notificationChannelName", "Background Upload Channel"),
145
+ existingEtags = existing,
146
+ resumable = !(options.hasKey("resumable") && options.getType("resumable") == ReadableType.Boolean && !options.getBoolean("resumable"))
147
+ )
148
+ }
149
+ }
150
+ }
151
+
152
+ class MultipartUploader private constructor(private val appContext: Context) {
153
+ companion object {
154
+ private const val TAG = "MultipartUploader"
155
+ private const val MIN_CHUNK_SIZE = 5L * 1024 * 1024
156
+ private const val MAX_RETRIES = 3
157
+ private const val ACK_GRACE_MS = 30_000L
158
+ private const val HEADLESS_ACK_GRACE_MS = 120_000L
159
+ private const val KEEP_ALIVE_TASK = "RNMultipartUploadKeepAlive"
160
+ private const val STATE_FILE = "rn-multipart-uploads.json"
161
+
162
+ const val STATUS_RUNNING = "running"
163
+ const val STATUS_COMPLETED = "completed"
164
+ const val STATUS_FAILED = "failed"
165
+
166
+ @Volatile
167
+ private var instance: MultipartUploader? = null
168
+
169
+ fun getInstance(context: Context): MultipartUploader =
170
+ instance ?: synchronized(this) {
171
+ instance ?: MultipartUploader(context.applicationContext).also { instance = it }
172
+ }
173
+ }
174
+
175
+ private class HttpStatusException(val code: Int, message: String) : Exception(message) {
176
+ val isRetryable: Boolean
177
+ get() = code >= 500 || code == 408 || code == 429
178
+ }
179
+
180
+ private class FileRegionBody(
181
+ private val file: File,
182
+ private val offset: Long,
183
+ private val size: Long,
184
+ private val mediaType: MediaType?
185
+ ) : RequestBody() {
186
+ override fun contentType(): MediaType? = mediaType
187
+
188
+ override fun contentLength(): Long = size
189
+
190
+ override fun writeTo(sink: BufferedSink) {
191
+ RandomAccessFile(file, "r").use { raf ->
192
+ if (offset + size > raf.length()) {
193
+ throw IllegalArgumentException("Invalid chunk bounds: offset=$offset, size=$size, file=${raf.length()}")
194
+ }
195
+ raf.seek(offset)
196
+ val buffer = ByteArray(64 * 1024)
197
+ var remaining = size
198
+ while (remaining > 0) {
199
+ val read = raf.read(buffer, 0, Math.min(buffer.size.toLong(), remaining).toInt())
200
+ if (read < 0) {
201
+ throw IOException("Unexpected end of file while reading chunk")
202
+ }
203
+ sink.write(buffer, 0, read)
204
+ remaining -= read
205
+ }
206
+ }
207
+ }
208
+ }
209
+
210
+ private data class PartInfo(
211
+ val partNumber: Int,
212
+ val url: String,
213
+ val offset: Long,
214
+ val size: Long
215
+ )
216
+
217
+ private class Record(
218
+ val params: UploadParams,
219
+ val totalBytes: Long,
220
+ val chunkSize: Long,
221
+ @Volatile var etags: Array<String>,
222
+ @Volatile var status: String,
223
+ @Volatile var error: String?
224
+ ) {
225
+ fun sizeOf(index: Int): Long {
226
+ val offset = index * chunkSize
227
+ return if (index == params.parts.size - 1) totalBytes - offset else chunkSize
228
+ }
229
+
230
+ fun bytesUploaded(): Long {
231
+ var bytes = 0L
232
+ for (i in etags.indices) {
233
+ if (etags[i].isNotEmpty()) bytes += sizeOf(i)
234
+ }
235
+ return bytes
236
+ }
237
+ }
238
+
239
+ private class UploadJob(
240
+ val uploadId: String,
241
+ val totalBytes: Long,
242
+ val partsCount: Int,
243
+ val bytesUploaded: AtomicLong,
244
+ val completedChunks: AtomicLong,
245
+ val partETags: ConcurrentHashMap<Int, String>,
246
+ val isPaused: AtomicBoolean = AtomicBoolean(false),
247
+ val activeCalls: ConcurrentHashMap<Int, Call> = ConcurrentHashMap()
248
+ ) {
249
+ @Volatile
250
+ var job: Job? = null
251
+
252
+ @Volatile
253
+ var cancelledByUser = false
254
+
255
+ fun cancel() {
256
+ activeCalls.values.forEach { call ->
257
+ try { call.cancel() } catch (_: Exception) {}
258
+ }
259
+ job?.cancel()
260
+ }
261
+ }
262
+
263
+ private val engineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
264
+ private val ackScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
265
+ private val records = ConcurrentHashMap<String, Record>()
266
+ private val activeUploads = ConcurrentHashMap<String, UploadJob>()
267
+ private val awaitingAck = ConcurrentHashMap<String, Job>()
268
+ private val stateLock = Any()
269
+ private var restored = false
270
+
271
+ @Volatile
272
+ private var reactContext: ReactApplicationContext? = null
273
+ private val keepAliveLock = Any()
274
+ private var keepAliveTaskId: Int? = null
275
+
276
+ private val httpClient: OkHttpClient = OkHttpClient.Builder()
277
+ .connectTimeout(60, TimeUnit.SECONDS)
278
+ .writeTimeout(60, TimeUnit.SECONDS)
279
+ .readTimeout(60, TimeUnit.SECONDS)
280
+ .retryOnConnectionFailure(true)
281
+ .build()
282
+
283
+ fun attachReactContext(context: ReactApplicationContext) {
284
+ reactContext = context
285
+ restore()
286
+ engineScope.launch { resumeInterrupted() }
287
+ if (activeUploads.isNotEmpty() || awaitingAck.isNotEmpty()) {
288
+ acquireJsKeepAlive()
289
+ }
290
+ }
291
+
292
+ fun detachReactContext(context: ReactApplicationContext) {
293
+ synchronized(keepAliveLock) {
294
+ keepAliveTaskId = null
295
+ }
296
+ if (reactContext === context) {
297
+ reactContext = null
298
+ }
299
+ }
300
+
301
+ private fun isJsAlive(): Boolean = reactContext?.hasActiveReactInstance() == true
302
+
303
+ private fun emitEvent(eventName: String, params: WritableMap) {
304
+ val context = reactContext ?: return
305
+ if (!context.hasActiveReactInstance()) return
306
+ try {
307
+ context.getJSModule(RCTDeviceEventEmitter::class.java)?.emit(eventName, params)
308
+ } catch (e: Exception) {
309
+ Log.e(TAG, "Error emitting event $eventName: ${e.message}")
310
+ }
311
+ }
312
+
313
+ private fun acquireJsKeepAlive() {
314
+ UiThreadUtil.runOnUiThread {
315
+ synchronized(keepAliveLock) {
316
+ val context = reactContext
317
+ if (keepAliveTaskId != null || context == null || !context.hasActiveReactInstance()) return@runOnUiThread
318
+ val config = HeadlessJsTaskConfig(KEEP_ALIVE_TASK, Arguments.createMap(), 0, true)
319
+ keepAliveTaskId = HeadlessJsTaskContext.getInstance(context).startTask(config)
320
+ }
321
+ }
322
+ }
323
+
324
+ private fun releaseJsKeepAlive() {
325
+ UiThreadUtil.runOnUiThread {
326
+ synchronized(keepAliveLock) {
327
+ val context = reactContext
328
+ val taskId = keepAliveTaskId ?: return@runOnUiThread
329
+ keepAliveTaskId = null
330
+ if (context != null && context.hasActiveReactInstance()) {
331
+ HeadlessJsTaskContext.getInstance(context).finishTask(taskId)
332
+ }
333
+ }
334
+ }
335
+ }
336
+
337
+ private fun stopServiceIfIdle() {
338
+ if (activeUploads.isEmpty() && awaitingAck.isEmpty()) {
339
+ releaseJsKeepAlive()
340
+ MultipartUploadService.stop(appContext)
341
+ }
342
+ }
343
+
344
+ private fun holdServiceUntilAcknowledged(uploadId: String, graceMs: Long) {
345
+ awaitingAck.remove(uploadId)?.cancel()
346
+ awaitingAck[uploadId] = ackScope.launch {
347
+ delay(graceMs)
348
+ awaitingAck.remove(uploadId)
349
+ stopServiceIfIdle()
350
+ }
351
+ }
352
+
353
+ fun acknowledgeUpload(uploadId: String): Boolean {
354
+ var acknowledged = false
355
+ val record = records[uploadId]
356
+ if (record != null && record.status != STATUS_RUNNING) {
357
+ records.remove(uploadId)
358
+ persist()
359
+ acknowledged = true
360
+ }
361
+ val timer = awaitingAck.remove(uploadId)
362
+ if (timer != null) {
363
+ timer.cancel()
364
+ acknowledged = true
365
+ }
366
+ stopServiceIfIdle()
367
+ return acknowledged
368
+ }
369
+
370
+ fun pendingUploads(): WritableArray {
371
+ restore()
372
+ val list = Arguments.createArray()
373
+ records.values.forEach { record ->
374
+ val bytes = record.bytesUploaded()
375
+ val map = Arguments.createMap()
376
+ map.putString("uploadId", record.params.uploadId)
377
+ map.putString("status", record.status)
378
+ map.putDouble("progress", if (record.totalBytes > 0) bytes.toDouble() / record.totalBytes.toDouble() else 1.0)
379
+ map.putDouble("bytesUploaded", bytes.toDouble())
380
+ map.putDouble("totalBytes", record.totalBytes.toDouble())
381
+ map.putArray("etags", etagsArray(record.etags.toList()))
382
+ if (record.error != null) map.putString("error", record.error) else map.putNull("error")
383
+ list.pushMap(map)
384
+ }
385
+ return list
386
+ }
387
+
388
+ fun completionPayload(uploadId: String): WritableMap? {
389
+ val record = records[uploadId] ?: return null
390
+ val map = Arguments.createMap()
391
+ map.putString("uploadId", uploadId)
392
+ map.putString("status", record.status)
393
+ map.putArray("etags", etagsArray(record.etags.toList()))
394
+ if (record.error != null) map.putString("error", record.error) else map.putNull("error")
395
+ return map
396
+ }
397
+
398
+ private fun etagsArray(etags: List<String>): WritableArray {
399
+ val array = Arguments.createArray()
400
+ etags.forEach { array.pushString(it) }
401
+ return array
402
+ }
403
+
404
+ private fun persist() {
405
+ synchronized(stateLock) {
406
+ try {
407
+ val array = JSONArray()
408
+ records.values.filter { it.params.resumable }.forEach { record ->
409
+ array.put(JSONObject().apply {
410
+ put("params", record.params.toJson())
411
+ put("totalBytes", record.totalBytes)
412
+ put("chunkSize", record.chunkSize)
413
+ put("etags", JSONArray(record.etags.toList()))
414
+ put("status", record.status)
415
+ put("error", record.error ?: JSONObject.NULL)
416
+ })
417
+ }
418
+ val target = File(appContext.filesDir, STATE_FILE)
419
+ val temp = File(appContext.filesDir, "$STATE_FILE.tmp")
420
+ temp.writeText(array.toString())
421
+ if (!temp.renameTo(target)) {
422
+ target.writeText(array.toString())
423
+ temp.delete()
424
+ }
425
+ } catch (e: Exception) {
426
+ Log.e(TAG, "Failed to persist upload state: ${e.message}")
427
+ }
428
+ }
429
+ }
430
+
431
+ private fun restore() {
432
+ synchronized(stateLock) {
433
+ if (restored) return
434
+ restored = true
435
+ try {
436
+ val file = File(appContext.filesDir, STATE_FILE)
437
+ if (!file.exists()) return
438
+ val array = JSONArray(file.readText())
439
+ for (i in 0 until array.length()) {
440
+ val json = array.getJSONObject(i)
441
+ val etagsJson = json.getJSONArray("etags")
442
+ val etags = Array(etagsJson.length()) { etagsJson.getString(it) }
443
+ val params = UploadParams.fromJson(json.getJSONObject("params"), emptyList())
444
+ val error = if (json.isNull("error")) null else json.getString("error")
445
+ records[params.uploadId] = Record(
446
+ params,
447
+ json.getLong("totalBytes"),
448
+ json.getLong("chunkSize"),
449
+ etags,
450
+ json.getString("status"),
451
+ error
452
+ )
453
+ }
454
+ } catch (e: Exception) {
455
+ Log.e(TAG, "Failed to restore upload state: ${e.message}")
456
+ }
457
+ }
458
+ }
459
+
460
+ fun resumeInterrupted(): List<UploadParams> {
461
+ restore()
462
+ val resumed = mutableListOf<UploadParams>()
463
+ for (record in records.values.toList()) {
464
+ if (record.status == STATUS_RUNNING && !activeUploads.containsKey(record.params.uploadId)) {
465
+ val params = record.params.copy(existingEtags = record.etags.toList())
466
+ try {
467
+ launchUpload(params, null)
468
+ resumed.add(params)
469
+ } catch (e: UploadAlreadyExistsException) {
470
+ continue
471
+ } catch (e: Exception) {
472
+ Log.w(TAG, "Could not resume ${params.uploadId}: ${e.message}")
473
+ record.status = STATUS_FAILED
474
+ record.error = e.message
475
+ persist()
476
+ notifyFinished(record)
477
+ }
478
+ }
479
+ }
480
+ return resumed
481
+ }
482
+
483
+ suspend fun startMultipartUpload(params: UploadParams): WritableMap {
484
+ val deferred = CompletableDeferred<WritableMap>()
485
+ withContext(Dispatchers.IO) { launchUpload(params, deferred) }
486
+ return deferred.await()
487
+ }
488
+
489
+ private fun launchUpload(params: UploadParams, deferred: CompletableDeferred<WritableMap>?) {
490
+ restore()
491
+ val file = File(params.path)
492
+ if (!file.exists() || !file.isFile) {
493
+ throw IllegalArgumentException("File does not exist at path: ${params.path}")
494
+ }
495
+
496
+ val partsCount = params.parts.size
497
+ val fileSize = file.length()
498
+ if (fileSize <= 0) {
499
+ throw IllegalArgumentException("File is empty or invalid: ${params.path}")
500
+ }
501
+
502
+ val minRequiredSize = MIN_CHUNK_SIZE * (partsCount - 1)
503
+ if (partsCount > 1 && fileSize <= minRequiredSize) {
504
+ throw IllegalArgumentException(
505
+ "File size ($fileSize bytes) is too small for $partsCount parts. Minimum required size: " + (minRequiredSize + 1) + " bytes."
506
+ )
507
+ }
508
+
509
+ val chunkSize = Math.max(Math.ceil(fileSize.toDouble() / partsCount).toLong(), MIN_CHUNK_SIZE)
510
+
511
+ val initialEtags = ConcurrentHashMap<Int, String>()
512
+ val etagArray = Array(partsCount) { "" }
513
+ var initialUploadedBytes = 0L
514
+ var initialCompleted = 0L
515
+ val count = Math.min(params.existingEtags.size, partsCount)
516
+ for (i in 0 until count) {
517
+ val etag = params.existingEtags[i]
518
+ if (etag.isNotEmpty()) {
519
+ val size = if (i == partsCount - 1) fileSize - i * chunkSize else chunkSize
520
+ initialEtags[i + 1] = etag
521
+ etagArray[i] = etag
522
+ initialUploadedBytes += size
523
+ initialCompleted++
524
+ }
525
+ }
526
+
527
+ val uploadJob = UploadJob(
528
+ uploadId = params.uploadId,
529
+ totalBytes = fileSize,
530
+ partsCount = partsCount,
531
+ bytesUploaded = AtomicLong(initialUploadedBytes),
532
+ completedChunks = AtomicLong(initialCompleted),
533
+ partETags = initialEtags
534
+ )
535
+ if (activeUploads.putIfAbsent(params.uploadId, uploadJob) != null) {
536
+ throw UploadAlreadyExistsException("Upload already in progress for ${params.uploadId}")
537
+ }
538
+ awaitingAck.remove(params.uploadId)?.cancel()
539
+
540
+ val record = Record(params, fileSize, chunkSize, etagArray, STATUS_RUNNING, null)
541
+ records[params.uploadId] = record
542
+ persist()
543
+
544
+ acquireJsKeepAlive()
545
+ if (params.notificationEnabled) {
546
+ val startMsg = if (initialUploadedBytes > 0) params.resumeMessage else params.startMessage
547
+ MultipartUploadService.start(appContext, params.title, startMsg, params.channelName)
548
+ }
549
+
550
+ if (initialUploadedBytes > 0) {
551
+ emitEvent("RNFileUploader-multipart-progress", progressMap(uploadJob, (initialCompleted - 1).toInt(), null))
552
+ }
553
+
554
+ val parts = params.parts.mapIndexed { index, url ->
555
+ val offset = index * chunkSize
556
+ val size = if (index == partsCount - 1) fileSize - offset else chunkSize
557
+ if (size <= 0) {
558
+ activeUploads.remove(params.uploadId, uploadJob)
559
+ records.remove(params.uploadId)
560
+ persist()
561
+ throw IllegalArgumentException(
562
+ "Part ${index + 1} has invalid size ($size bytes). Offset: $offset, FileSize: $fileSize"
563
+ )
564
+ }
565
+ PartInfo(index + 1, url, offset, size)
566
+ }
567
+
568
+ val job = engineScope.launch(start = CoroutineStart.LAZY) {
569
+ try {
570
+ coroutineScope {
571
+ val semaphore = Semaphore(params.maxParallel)
572
+ parts.map { part ->
573
+ async {
574
+ semaphore.withPermit {
575
+ uploadPart(file, part, uploadJob, record)
576
+ }
577
+ }
578
+ }.awaitAll()
579
+ }
580
+
581
+ if (uploadJob.completedChunks.get() < partsCount) {
582
+ throw Exception("Upload incomplete: completed ${uploadJob.completedChunks.get()} of $partsCount chunks")
583
+ }
584
+
585
+ record.status = STATUS_COMPLETED
586
+ record.error = null
587
+ persist()
588
+
589
+ val result = Arguments.createMap()
590
+ result.putString("uploadId", params.uploadId)
591
+ result.putBoolean("success", true)
592
+ result.putArray("etags", etagsArray(record.etags.toList()))
593
+ deferred?.complete(result)
594
+ notifyFinished(record)
595
+ } catch (e: CancellationException) {
596
+ if (uploadJob.cancelledByUser) {
597
+ records.remove(params.uploadId)
598
+ persist()
599
+ val cancelParams = Arguments.createMap()
600
+ cancelParams.putString("uploadId", params.uploadId)
601
+ cancelParams.putString("error", "Upload cancelled")
602
+ emitEvent("RNFileUploader-multipart-cancelled", cancelParams)
603
+ } else {
604
+ record.status = STATUS_FAILED
605
+ record.error = e.message ?: "Upload cancelled"
606
+ persist()
607
+ notifyFinished(record)
608
+ }
609
+ deferred?.completeExceptionally(e)
610
+ } catch (e: Exception) {
611
+ Log.e(TAG, "Multipart upload failed for ${params.uploadId}: ${e.message}", e)
612
+ record.status = STATUS_FAILED
613
+ record.error = e.message ?: "Upload failed"
614
+ persist()
615
+ notifyFinished(record)
616
+ deferred?.completeExceptionally(e)
617
+ } finally {
618
+ activeUploads.remove(params.uploadId, uploadJob)
619
+ if (!uploadJob.cancelledByUser) {
620
+ holdServiceUntilAcknowledged(
621
+ params.uploadId,
622
+ if (isJsAlive()) ACK_GRACE_MS else HEADLESS_ACK_GRACE_MS
623
+ )
624
+ }
625
+ stopServiceIfIdle()
626
+ }
627
+ }
628
+ uploadJob.job = job
629
+ job.start()
630
+ }
631
+
632
+ private fun notifyFinished(record: Record) {
633
+ if (isJsAlive()) {
634
+ val params = Arguments.createMap()
635
+ params.putString("uploadId", record.params.uploadId)
636
+ params.putArray("etags", etagsArray(record.etags.toList()))
637
+ if (record.status == STATUS_COMPLETED) {
638
+ params.putBoolean("success", true)
639
+ emitEvent("RNFileUploader-multipart-completed", params)
640
+ } else {
641
+ params.putString("error", record.error ?: "Upload failed")
642
+ emitEvent("RNFileUploader-multipart-error", params)
643
+ }
644
+ } else {
645
+ MultipartCompletionService.start(appContext, record.params.uploadId)
646
+ }
647
+ }
648
+
649
+ private fun progressMap(uploadJob: UploadJob, chunkIndex: Int, etag: String?): WritableMap {
650
+ val progress = if (uploadJob.totalBytes > 0) {
651
+ uploadJob.bytesUploaded.get().toDouble() / uploadJob.totalBytes.toDouble()
652
+ } else 1.0
653
+ val map = Arguments.createMap()
654
+ map.putString("uploadId", uploadJob.uploadId)
655
+ map.putDouble("progress", progress)
656
+ map.putDouble("bytesUploaded", uploadJob.bytesUploaded.get().toDouble())
657
+ map.putDouble("totalBytes", uploadJob.totalBytes.toDouble())
658
+ map.putInt("chunkIndex", chunkIndex)
659
+ if (etag != null) map.putString("etag", etag)
660
+ val etags = Arguments.createArray()
661
+ for (i in 0 until uploadJob.partsCount) {
662
+ etags.pushString(uploadJob.partETags[i + 1] ?: "")
663
+ }
664
+ map.putArray("etags", etags)
665
+ return map
666
+ }
667
+
668
+ private suspend fun uploadPart(
669
+ file: File,
670
+ part: PartInfo,
671
+ uploadJob: UploadJob,
672
+ record: Record
673
+ ): Boolean = withContext(Dispatchers.IO) {
674
+ if (uploadJob.partETags.containsKey(part.partNumber)) {
675
+ return@withContext true
676
+ }
677
+
678
+ var retries = 0
679
+ var lastError: Exception? = null
680
+
681
+ while (retries < MAX_RETRIES) {
682
+ while (uploadJob.isPaused.get()) {
683
+ delay(500)
684
+ }
685
+
686
+ if (!isActive) {
687
+ throw CancellationException("Upload cancelled")
688
+ }
689
+
690
+ try {
691
+ if (!file.exists()) {
692
+ uploadJob.cancel()
693
+ throw CancellationException("Upload cancelled: file deleted")
694
+ }
695
+
696
+ val mediaType = "application/octet-stream".toMediaTypeOrNull()
697
+ val request = Request.Builder()
698
+ .url(part.url)
699
+ .put(FileRegionBody(file, part.offset, part.size, mediaType))
700
+ .build()
701
+
702
+ val call = httpClient.newCall(request)
703
+ uploadJob.activeCalls[part.partNumber] = call
704
+
705
+ val response = try {
706
+ call.execute()
707
+ } finally {
708
+ uploadJob.activeCalls.remove(part.partNumber)
709
+ }
710
+
711
+ if (!response.isSuccessful) {
712
+ val errorBody = response.body?.string() ?: ""
713
+ throw HttpStatusException(response.code, "HTTP ${response.code}: $errorBody")
714
+ }
715
+
716
+ val etag = response.header("ETag")?.trim('"')
717
+ ?: response.header("etag")?.trim('"')
718
+ ?: ""
719
+
720
+ if (etag.isEmpty()) {
721
+ throw Exception("No ETag in response for part ${part.partNumber}")
722
+ }
723
+
724
+ synchronized(uploadJob) {
725
+ if (!uploadJob.partETags.containsKey(part.partNumber)) {
726
+ uploadJob.partETags[part.partNumber] = etag
727
+ uploadJob.completedChunks.incrementAndGet()
728
+ uploadJob.bytesUploaded.addAndGet(part.size)
729
+ record.etags = Array(uploadJob.partsCount) { i -> uploadJob.partETags[i + 1] ?: "" }
730
+ persist()
731
+ }
732
+ }
733
+
734
+ emitEvent("RNFileUploader-multipart-progress", progressMap(uploadJob, part.partNumber - 1, etag))
735
+
736
+ if (record.params.notificationEnabled) {
737
+ val percent = (uploadJob.bytesUploaded.get().toDouble() / uploadJob.totalBytes.toDouble() * 100).toInt()
738
+ MultipartUploadService.updateProgress(
739
+ appContext,
740
+ record.params.title,
741
+ percent,
742
+ record.params.progressMessage.replace("{progress}", percent.toString())
743
+ )
744
+ }
745
+
746
+ return@withContext true
747
+ } catch (e: CancellationException) {
748
+ throw e
749
+ } catch (e: Exception) {
750
+ if (e is java.io.FileNotFoundException || !file.exists()) {
751
+ uploadJob.cancel()
752
+ throw CancellationException("Upload cancelled: file deleted")
753
+ }
754
+ if (e is HttpStatusException && !e.isRetryable) {
755
+ throw e
756
+ }
757
+ lastError = e
758
+ retries++
759
+ Log.w(TAG, "Part ${part.partNumber} failed (attempt $retries): ${e.message}")
760
+ if (retries < MAX_RETRIES) {
761
+ delay(1000L * retries)
762
+ }
763
+ }
764
+ }
765
+
766
+ throw lastError ?: Exception("Part ${part.partNumber} failed after $MAX_RETRIES retries")
767
+ }
768
+
769
+ fun cancelUpload(uploadId: String): Boolean {
770
+ val uploadJob = activeUploads[uploadId] ?: return false
771
+ uploadJob.cancelledByUser = true
772
+ uploadJob.cancel()
773
+ return true
774
+ }
775
+
776
+ fun pauseUpload(uploadId: String): Boolean {
777
+ val uploadJob = activeUploads[uploadId] ?: return false
778
+ uploadJob.isPaused.set(true)
779
+ return true
780
+ }
781
+
782
+ fun resumeUpload(uploadId: String): Boolean {
783
+ val uploadJob = activeUploads[uploadId] ?: return false
784
+ uploadJob.isPaused.set(false)
785
+ return true
786
+ }
787
+ }