uploados 0.1.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.
Files changed (66) hide show
  1. package/android/build.gradle +25 -0
  2. package/android/src/main/AndroidManifest.xml +15 -0
  3. package/android/src/main/java/expo/modules/uploados/UploadosModule.kt +121 -0
  4. package/android/src/main/java/expo/modules/uploados/upload/CompressionPipeline.kt +192 -0
  5. package/android/src/main/java/expo/modules/uploados/upload/FileStager.kt +125 -0
  6. package/android/src/main/java/expo/modules/uploados/upload/ProgressRequestBody.kt +36 -0
  7. package/android/src/main/java/expo/modules/uploados/upload/UploadManager.kt +857 -0
  8. package/android/src/main/java/expo/modules/uploados/upload/UploadModels.kt +209 -0
  9. package/android/src/main/java/expo/modules/uploados/upload/UploadNotificationHelper.kt +93 -0
  10. package/android/src/main/java/expo/modules/uploados/upload/UploadTaskStore.kt +224 -0
  11. package/android/src/main/java/expo/modules/uploados/upload/UploadWorker.kt +31 -0
  12. package/build/Uploados.types.d.ts +226 -0
  13. package/build/Uploados.types.d.ts.map +1 -0
  14. package/build/Uploados.types.js +2 -0
  15. package/build/Uploados.types.js.map +1 -0
  16. package/build/UploadosModule.d.ts +13 -0
  17. package/build/UploadosModule.d.ts.map +1 -0
  18. package/build/UploadosModule.js +3 -0
  19. package/build/UploadosModule.js.map +1 -0
  20. package/build/UploadosModule.web.d.ts +13 -0
  21. package/build/UploadosModule.web.d.ts.map +1 -0
  22. package/build/UploadosModule.web.js +33 -0
  23. package/build/UploadosModule.web.js.map +1 -0
  24. package/build/createUploader.d.ts +3 -0
  25. package/build/createUploader.d.ts.map +1 -0
  26. package/build/createUploader.js +108 -0
  27. package/build/createUploader.js.map +1 -0
  28. package/build/index.d.ts +5 -0
  29. package/build/index.d.ts.map +1 -0
  30. package/build/index.js +5 -0
  31. package/build/index.js.map +1 -0
  32. package/build/normalizeUploadOptions.d.ts +9 -0
  33. package/build/normalizeUploadOptions.d.ts.map +1 -0
  34. package/build/normalizeUploadOptions.js +81 -0
  35. package/build/normalizeUploadOptions.js.map +1 -0
  36. package/build/providers/defineUploadProvider.d.ts +26 -0
  37. package/build/providers/defineUploadProvider.d.ts.map +1 -0
  38. package/build/providers/defineUploadProvider.js +39 -0
  39. package/build/providers/defineUploadProvider.js.map +1 -0
  40. package/build/providers/multipartPlan.d.ts +10 -0
  41. package/build/providers/multipartPlan.d.ts.map +1 -0
  42. package/build/providers/multipartPlan.js +28 -0
  43. package/build/providers/multipartPlan.js.map +1 -0
  44. package/eslint.config.cjs +5 -0
  45. package/expo-module.config.json +10 -0
  46. package/ios/Upload/CompressionPipeline.swift +183 -0
  47. package/ios/Upload/FileStager.swift +67 -0
  48. package/ios/Upload/UploadManager.swift +813 -0
  49. package/ios/Upload/UploadModels.swift +305 -0
  50. package/ios/Upload/UploadSessionDelegate.swift +82 -0
  51. package/ios/Upload/UploadTaskStore.swift +92 -0
  52. package/ios/Upload/UploadosAppDelegate.swift +14 -0
  53. package/ios/Uploados.podspec +23 -0
  54. package/ios/UploadosModule.swift +87 -0
  55. package/jest.config.js +15 -0
  56. package/package.json +54 -0
  57. package/readme.md +169 -0
  58. package/src/Uploados.types.ts +260 -0
  59. package/src/UploadosModule.ts +18 -0
  60. package/src/UploadosModule.web.ts +49 -0
  61. package/src/createUploader.ts +146 -0
  62. package/src/index.ts +4 -0
  63. package/src/normalizeUploadOptions.ts +132 -0
  64. package/src/providers/defineUploadProvider.ts +75 -0
  65. package/src/providers/multipartPlan.ts +43 -0
  66. package/tsconfig.json +42 -0
@@ -0,0 +1,857 @@
1
+ package expo.modules.uploados.upload
2
+
3
+ import android.content.Context
4
+ import android.net.Uri
5
+ import androidx.work.BackoffPolicy
6
+ import androidx.work.Constraints
7
+ import androidx.work.ExistingWorkPolicy
8
+ import androidx.work.NetworkType
9
+ import androidx.work.OneTimeWorkRequestBuilder
10
+ import androidx.work.WorkManager
11
+ import androidx.work.workDataOf
12
+ import okhttp3.Call
13
+ import okhttp3.MediaType.Companion.toMediaTypeOrNull
14
+ import okhttp3.OkHttpClient
15
+ import okhttp3.Request
16
+ import org.json.JSONObject
17
+ import java.io.File
18
+ import java.io.IOException
19
+ import java.net.SocketTimeoutException
20
+ import java.net.UnknownHostException
21
+ import java.util.UUID
22
+ import java.util.concurrent.ConcurrentHashMap
23
+ import java.util.concurrent.ExecutorService
24
+ import java.util.concurrent.Executors
25
+ import java.util.concurrent.TimeUnit
26
+ import kotlin.math.max
27
+ import kotlin.math.min
28
+
29
+ class UploadManager private constructor(private val appContext: Context) {
30
+ private val store = UploadTaskStore.getInstance(appContext)
31
+ private val client = OkHttpClient.Builder()
32
+ .connectTimeout(60, TimeUnit.SECONDS)
33
+ .readTimeout(60, TimeUnit.SECONDS)
34
+ .writeTimeout(60, TimeUnit.SECONDS)
35
+ .build()
36
+ private val executor: ExecutorService = Executors.newCachedThreadPool()
37
+ private val activeCalls = ConcurrentHashMap<String, Call>()
38
+ private val inFlight = ConcurrentHashMap.newKeySet<String>()
39
+ private var eventEmitter: ((Map<String, Any?>) -> Unit)? = null
40
+ private val lastProgressEmit = ConcurrentHashMap<String, Long>()
41
+ private val progressThrottleMs = 250L
42
+ private val baseRetryDelayMs = 1_000L
43
+ private val maxRetryDelayMs = 30_000L
44
+
45
+ fun setEventEmitter(emitter: (Map<String, Any?>) -> Unit) {
46
+ eventEmitter = emitter
47
+ }
48
+
49
+ fun createUpload(options: CreateUploadOptions): UploadTaskRecord {
50
+ val now = System.currentTimeMillis().toDouble()
51
+ val taskId = UUID.randomUUID().toString()
52
+ var record = UploadTaskRecord(
53
+ id = taskId,
54
+ localUri = options.localUri,
55
+ compressionEnabled = options.compressionEnabled,
56
+ compressionPreset = if (options.compressionEnabled) options.compressionPreset.raw else null,
57
+ uploadUrl = options.uploadUrl,
58
+ method = options.method,
59
+ state = UploadState.CREATED,
60
+ progress = UploadProgress(0, 0, 0.0),
61
+ background = options.background,
62
+ networkPolicy = options.networkPolicy,
63
+ attempt = 0,
64
+ maxAttempts = options.maxAttempts,
65
+ nextRetryAt = null,
66
+ createdAt = now,
67
+ updatedAt = now,
68
+ headers = options.headers,
69
+ error = null,
70
+ )
71
+ store.upsert(record)
72
+ emit(mapOf("type" to "created", "task" to record.toMap()))
73
+
74
+ record = store.update(taskId) { task ->
75
+ task.state = UploadState.COMPRESSING
76
+ } ?: record.copy(state = UploadState.COMPRESSING)
77
+ emit(mapOf("type" to "compressing", "taskId" to taskId, "task" to record.toMap()))
78
+
79
+ val fileSize = try {
80
+ val prepared = prepareUploadFile(
81
+ taskId = taskId,
82
+ localUri = options.localUri,
83
+ stagedFilePath = null,
84
+ )
85
+ if (prepared.stagedPath != null) {
86
+ record = store.update(taskId) { task ->
87
+ task.stagedFilePath = prepared.stagedPath
88
+ } ?: record.copy(stagedFilePath = prepared.stagedPath)
89
+ }
90
+
91
+ val size = if (options.compressionEnabled) {
92
+ val existingOptimized = record.optimizedFilePath?.let { File(it) }?.takeIf { it.exists() && it.isFile }
93
+ if (existingOptimized != null) {
94
+ existingOptimized.length()
95
+ } else {
96
+ val result = CompressionPipeline.compress(
97
+ context = appContext,
98
+ sourceFile = prepared.file,
99
+ taskId = taskId,
100
+ preset = options.compressionPreset,
101
+ )
102
+ record = store.update(taskId) { task ->
103
+ task.optimizedFilePath = result.outputFile.absolutePath
104
+ task.originalSize = result.stats.originalSize
105
+ task.optimizedSize = result.stats.optimizedSize
106
+ } ?: record.copy(
107
+ optimizedFilePath = result.outputFile.absolutePath,
108
+ originalSize = result.stats.originalSize,
109
+ optimizedSize = result.stats.optimizedSize,
110
+ )
111
+ emit(
112
+ mapOf(
113
+ "type" to "compressed",
114
+ "taskId" to taskId,
115
+ "task" to record.toMap(),
116
+ "stats" to result.stats.toMap(),
117
+ ),
118
+ )
119
+ result.stats.optimizedSize
120
+ }
121
+ } else {
122
+ val resolvedSize = prepared.file.length()
123
+ if (resolvedSize > 0) {
124
+ record = store.update(taskId) { task ->
125
+ task.originalSize = resolvedSize
126
+ } ?: record.copy(originalSize = resolvedSize)
127
+ }
128
+ resolvedSize
129
+ }
130
+ if (size <= 0) {
131
+ throw UploadManagerException(
132
+ UploadErrorCode.FILE_NOT_FOUND,
133
+ "File not found at ${options.localUri}.",
134
+ )
135
+ }
136
+ size
137
+ } catch (e: UploadManagerException) {
138
+ markFailed(
139
+ taskId = taskId,
140
+ code = e.errorCode,
141
+ message = e.message,
142
+ retryable = false,
143
+ phase = "compressing",
144
+ httpStatus = null,
145
+ providerCode = null,
146
+ providerMessage = null,
147
+ nativeCause = null,
148
+ )
149
+ throw e
150
+ } catch (e: Exception) {
151
+ val message = e.message ?: "File not found at ${options.localUri}."
152
+ markFailed(
153
+ taskId = taskId,
154
+ code = if (options.compressionEnabled) UploadErrorCode.COMPRESSION_FAILED else UploadErrorCode.FILE_NOT_FOUND,
155
+ message = message,
156
+ retryable = false,
157
+ phase = "compressing",
158
+ httpStatus = null,
159
+ providerCode = null,
160
+ providerMessage = null,
161
+ nativeCause = e.javaClass.simpleName,
162
+ )
163
+ throw UploadManagerException(
164
+ if (options.compressionEnabled) UploadErrorCode.COMPRESSION_FAILED else UploadErrorCode.FILE_NOT_FOUND,
165
+ message,
166
+ )
167
+ }
168
+
169
+ record = store.update(taskId) { task ->
170
+ task.state = UploadState.QUEUED
171
+ task.progress = UploadProgress(0, fileSize, 0.0)
172
+ } ?: record.copy(
173
+ state = UploadState.QUEUED,
174
+ progress = UploadProgress(0, fileSize, 0.0),
175
+ )
176
+ emit(mapOf("type" to "queued", "taskId" to taskId))
177
+
178
+ scheduleUpload(record)
179
+ return store.task(taskId) ?: record
180
+ }
181
+
182
+ fun cancelUpload(taskId: String): UploadTaskRecord {
183
+ val record = store.task(taskId)
184
+ ?: throw UploadManagerException(UploadErrorCode.UNKNOWN, "Upload task not found: $taskId.")
185
+
186
+ activeCalls.remove(taskId)?.cancel()
187
+ WorkManager.getInstance(appContext).cancelUniqueWork(workName(taskId))
188
+ inFlight.remove(taskId)
189
+
190
+ val error = UploadErrorPayload(
191
+ code = UploadErrorCode.CANCELLED,
192
+ message = "Upload cancelled.",
193
+ retryable = false,
194
+ taskId = taskId,
195
+ )
196
+ val updated = store.update(taskId) { task ->
197
+ task.state = UploadState.CANCELLED
198
+ task.error = error
199
+ task.nextRetryAt = null
200
+ } ?: record.copy(state = UploadState.CANCELLED, error = error)
201
+
202
+ cleanupDerivedFilesIfNeeded(updated)
203
+ val finalTask = store.update(taskId) { task ->
204
+ task.stagedFilePath = null
205
+ task.optimizedFilePath = null
206
+ } ?: updated
207
+ UploadNotificationHelper.cancel(appContext, taskId)
208
+ emit(mapOf("type" to "cancelled", "taskId" to taskId))
209
+ return finalTask
210
+ }
211
+
212
+ fun retryUpload(taskId: String): UploadTaskRecord {
213
+ val record = store.task(taskId)
214
+ ?: throw UploadManagerException(UploadErrorCode.UNKNOWN, "Upload task not found: $taskId.")
215
+ if (record.state != UploadState.FAILED) {
216
+ throw UploadManagerException(
217
+ UploadErrorCode.UNKNOWN,
218
+ "Upload task $taskId cannot be retried from state ${record.state.raw}.",
219
+ )
220
+ }
221
+
222
+ activeCalls.remove(taskId)?.cancel()
223
+ WorkManager.getInstance(appContext).cancelUniqueWork(workName(taskId))
224
+ inFlight.remove(taskId)
225
+
226
+ var updated = store.update(taskId) { task ->
227
+ task.state = UploadState.COMPRESSING
228
+ task.error = null
229
+ task.nextRetryAt = null
230
+ } ?: record.copy(
231
+ state = UploadState.COMPRESSING,
232
+ error = null,
233
+ nextRetryAt = null,
234
+ )
235
+ emit(mapOf("type" to "compressing", "taskId" to taskId, "task" to updated.toMap()))
236
+
237
+ val fileSize = try {
238
+ val file = resolveUploadFile(record)
239
+ val resolvedSize = file.length()
240
+ if (resolvedSize <= 0) {
241
+ throw UploadManagerException(
242
+ UploadErrorCode.FILE_NOT_FOUND,
243
+ "File not found at ${record.localUri}.",
244
+ )
245
+ }
246
+ resolvedSize
247
+ } catch (e: UploadManagerException) {
248
+ markFailed(
249
+ taskId = taskId,
250
+ code = e.errorCode,
251
+ message = e.message,
252
+ retryable = false,
253
+ phase = "compressing",
254
+ httpStatus = null,
255
+ providerCode = null,
256
+ providerMessage = null,
257
+ nativeCause = null,
258
+ )
259
+ throw e
260
+ } catch (e: Exception) {
261
+ val message = e.message ?: "File not found."
262
+ markFailed(
263
+ taskId = taskId,
264
+ code = UploadErrorCode.FILE_NOT_FOUND,
265
+ message = message,
266
+ retryable = false,
267
+ phase = "compressing",
268
+ httpStatus = null,
269
+ providerCode = null,
270
+ providerMessage = null,
271
+ nativeCause = e.javaClass.simpleName,
272
+ )
273
+ throw UploadManagerException(UploadErrorCode.FILE_NOT_FOUND, message)
274
+ }
275
+ updated = store.update(taskId) { task ->
276
+ task.state = UploadState.QUEUED
277
+ task.progress = UploadProgress(0, fileSize, 0.0)
278
+ task.attempt = 0
279
+ task.error = null
280
+ task.nextRetryAt = null
281
+ } ?: updated.copy(
282
+ state = UploadState.QUEUED,
283
+ progress = UploadProgress(0, fileSize, 0.0),
284
+ attempt = 0,
285
+ error = null,
286
+ nextRetryAt = null,
287
+ )
288
+
289
+ emit(mapOf("type" to "queued", "taskId" to taskId))
290
+ scheduleUpload(updated, existingWorkPolicy = ExistingWorkPolicy.REPLACE)
291
+ return store.task(taskId) ?: updated
292
+ }
293
+
294
+ fun restoreQueue(): List<UploadTaskRecord> {
295
+ val pending = store.restorableTasks()
296
+ for (record in pending) {
297
+ try {
298
+ resolveUploadFile(record)
299
+ val delayMs = if (record.state == UploadState.RETRYING && record.nextRetryAt != null) {
300
+ (record.nextRetryAt!! - System.currentTimeMillis()).toLong().coerceAtLeast(0L)
301
+ } else {
302
+ 0L
303
+ }
304
+ scheduleUpload(record, delayMs = delayMs, existingWorkPolicy = ExistingWorkPolicy.KEEP)
305
+ } catch (e: Exception) {
306
+ markFailed(
307
+ taskId = record.id,
308
+ code = UploadErrorCode.FILE_NOT_FOUND,
309
+ message = e.message ?: "File not found.",
310
+ retryable = false,
311
+ phase = "compressing",
312
+ httpStatus = null,
313
+ providerCode = null,
314
+ providerMessage = null,
315
+ nativeCause = null,
316
+ )
317
+ }
318
+ }
319
+ return store.allTasks()
320
+ }
321
+
322
+ fun executeUpload(taskId: String): UploadExecutionResult {
323
+ if (!inFlight.add(taskId)) {
324
+ return UploadExecutionResult.FINISHED
325
+ }
326
+ try {
327
+ val record = store.task(taskId)
328
+ ?: throw UploadManagerException(UploadErrorCode.UNKNOWN, "Upload task not found: $taskId.")
329
+ if (record.state == UploadState.CANCELLED || record.state == UploadState.COMPLETED) {
330
+ return UploadExecutionResult.FINISHED
331
+ }
332
+
333
+ val file = resolveUploadFile(record)
334
+ val fileSize = file.length()
335
+ val connecting = store.update(taskId) { task ->
336
+ val nextAttempt = task.attempt + 1
337
+ task.state = UploadState.CONNECTING
338
+ task.attempt = nextAttempt
339
+ task.progress.totalBytes = fileSize
340
+ task.nextRetryAt = null
341
+ if (nextAttempt > 1) {
342
+ task.progress.bytesUploaded = 0
343
+ task.progress.percentage = 0.0
344
+ }
345
+ }
346
+ connecting?.let {
347
+ emit(mapOf("type" to "connecting", "taskId" to taskId, "task" to it.toMap()))
348
+ }
349
+
350
+ val request = buildRequest(record, file) { bytesWritten, totalBytes ->
351
+ handleProgress(taskId, bytesWritten, totalBytes)
352
+ }
353
+ val call = client.newCall(request)
354
+ activeCalls[taskId] = call
355
+ val response = call.execute()
356
+ activeCalls.remove(taskId)
357
+
358
+ if (call.isCanceled()) {
359
+ throw UploadCancelledException()
360
+ }
361
+
362
+ response.use {
363
+ if (!it.isSuccessful) {
364
+ return handleHttpFailure(taskId, it.code, it.body?.string())
365
+ }
366
+ }
367
+ handleCompletion(taskId)
368
+ return UploadExecutionResult.FINISHED
369
+ } catch (_: UploadCancelledException) {
370
+ activeCalls.remove(taskId)
371
+ return UploadExecutionResult.FINISHED
372
+ } catch (e: Exception) {
373
+ activeCalls.remove(taskId)
374
+ if (e is IOException && e.message?.contains("Canceled", ignoreCase = true) == true) {
375
+ return UploadExecutionResult.FINISHED
376
+ }
377
+ return handleFailure(taskId, e, null)
378
+ } finally {
379
+ inFlight.remove(taskId)
380
+ }
381
+ }
382
+
383
+ private fun scheduleUpload(
384
+ record: UploadTaskRecord,
385
+ delayMs: Long = 0L,
386
+ existingWorkPolicy: ExistingWorkPolicy = ExistingWorkPolicy.KEEP,
387
+ ) {
388
+ if (record.background) {
389
+ val constraints = Constraints.Builder()
390
+ .setRequiredNetworkType(NetworkType.CONNECTED)
391
+ .build()
392
+ val workBuilder = OneTimeWorkRequestBuilder<UploadWorker>()
393
+ .setInputData(workDataOf(WORK_INPUT_TASK_ID to record.id))
394
+ .setConstraints(constraints)
395
+ .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.SECONDS)
396
+ .addTag(WORK_TAG)
397
+ .addTag(record.id)
398
+ if (delayMs > 0) {
399
+ workBuilder.setInitialDelay(delayMs, TimeUnit.MILLISECONDS)
400
+ }
401
+ WorkManager.getInstance(appContext).enqueueUniqueWork(
402
+ workName(record.id),
403
+ existingWorkPolicy,
404
+ workBuilder.build(),
405
+ )
406
+ return
407
+ }
408
+
409
+ executor.execute {
410
+ try {
411
+ if (delayMs > 0) {
412
+ Thread.sleep(delayMs)
413
+ }
414
+ executeUpload(record.id)
415
+ } catch (_: InterruptedException) {
416
+ Thread.currentThread().interrupt()
417
+ } catch (_: Exception) {
418
+ // Errors are surfaced via events.
419
+ }
420
+ }
421
+ }
422
+
423
+ private fun buildRequest(
424
+ record: UploadTaskRecord,
425
+ file: File,
426
+ onProgress: (Long, Long) -> Unit,
427
+ ): Request {
428
+ val mediaType = record.headers["Content-Type"]?.toMediaTypeOrNull()
429
+ val body = ProgressRequestBody(file, mediaType, onProgress)
430
+ val requestBuilder = Request.Builder()
431
+ .url(record.uploadUrl)
432
+ .method(record.method, body)
433
+
434
+ record.headers.forEach { (key, value) ->
435
+ requestBuilder.header(key, value)
436
+ }
437
+
438
+ return requestBuilder.build()
439
+ }
440
+
441
+ private fun handleProgress(taskId: String, bytesUploaded: Long, totalBytes: Long) {
442
+ val now = System.currentTimeMillis()
443
+ val percentage = if (totalBytes > 0) {
444
+ min(100.0, max(0.0, (bytesUploaded.toDouble() / totalBytes.toDouble()) * 100.0))
445
+ } else {
446
+ 0.0
447
+ }
448
+ val previous = store.task(taskId)
449
+ val isVerifying = percentage >= 100.0
450
+
451
+ val updated = store.update(taskId) { task ->
452
+ task.state = if (isVerifying) UploadState.VERIFYING else UploadState.UPLOADING
453
+ task.progress = UploadProgress(bytesUploaded, totalBytes, percentage)
454
+ } ?: return
455
+ if (updated.background) {
456
+ UploadNotificationHelper.showProgress(appContext, updated)
457
+ }
458
+
459
+ val lastEmit = lastProgressEmit[taskId] ?: 0L
460
+ if (now - lastEmit < progressThrottleMs && percentage < 100.0) {
461
+ return
462
+ }
463
+ lastProgressEmit[taskId] = now
464
+ if (isVerifying && previous?.state != UploadState.VERIFYING) {
465
+ emit(mapOf("type" to "verifying", "taskId" to taskId))
466
+ }
467
+ emit(
468
+ mapOf(
469
+ "type" to "progress",
470
+ "taskId" to taskId,
471
+ "progress" to updated.progress.toMap(),
472
+ ),
473
+ )
474
+ }
475
+
476
+ private fun handleCompletion(taskId: String) {
477
+ val updated = store.update(taskId) { task ->
478
+ task.state = UploadState.COMPLETED
479
+ task.progress.percentage = 100.0
480
+ task.progress.bytesUploaded = task.progress.totalBytes
481
+ task.error = null
482
+ task.nextRetryAt = null
483
+ } ?: return
484
+ lastProgressEmit.remove(taskId)
485
+ cleanupDerivedFilesIfNeeded(updated)
486
+ val finalTask = store.update(taskId) { task ->
487
+ task.stagedFilePath = null
488
+ task.optimizedFilePath = null
489
+ } ?: updated
490
+ UploadNotificationHelper.cancel(appContext, taskId)
491
+
492
+ emit(
493
+ mapOf(
494
+ "type" to "completed",
495
+ "taskId" to taskId,
496
+ "task" to finalTask.toMap(),
497
+ ),
498
+ )
499
+ }
500
+
501
+ private fun handleFailure(
502
+ taskId: String,
503
+ error: Exception,
504
+ statusCode: Int?,
505
+ ): UploadExecutionResult {
506
+ if (error is UploadCancelledException) {
507
+ return UploadExecutionResult.FINISHED
508
+ }
509
+ val retryable = isRetryable(error, statusCode)
510
+ return failOrRetry(
511
+ taskId = taskId,
512
+ code = UploadErrorCode.NETWORK_ERROR,
513
+ message = error.message ?: "Upload failed.",
514
+ retryable = retryable,
515
+ phase = failurePhase(taskId),
516
+ httpStatus = statusCode,
517
+ providerCode = null,
518
+ providerMessage = null,
519
+ nativeCause = error.javaClass.simpleName,
520
+ )
521
+ }
522
+
523
+ private fun failurePhase(taskId: String): String {
524
+ return when (store.task(taskId)?.state) {
525
+ UploadState.COMPRESSING -> "compressing"
526
+ UploadState.CONNECTING -> "connecting"
527
+ UploadState.VERIFYING -> "verifying"
528
+ UploadState.RETRYING -> "retrying"
529
+ else -> "uploading"
530
+ }
531
+ }
532
+
533
+ private fun handleHttpFailure(
534
+ taskId: String,
535
+ statusCode: Int,
536
+ responseBody: String?,
537
+ ): UploadExecutionResult {
538
+ val code = if (statusCode == 401 || statusCode == 403) {
539
+ UploadErrorCode.AUTH_ERROR
540
+ } else {
541
+ UploadErrorCode.PROVIDER_ERROR
542
+ }
543
+ val providerError = parseProviderError(responseBody)
544
+ return failOrRetry(
545
+ taskId = taskId,
546
+ code = code,
547
+ message = providerError.message ?: "Upload failed with HTTP status $statusCode.",
548
+ retryable = statusCode >= 500,
549
+ phase = "verifying",
550
+ httpStatus = statusCode,
551
+ providerCode = providerError.code,
552
+ providerMessage = providerError.message,
553
+ nativeCause = null,
554
+ )
555
+ }
556
+
557
+ private fun markFailed(
558
+ taskId: String,
559
+ code: UploadErrorCode,
560
+ message: String,
561
+ retryable: Boolean,
562
+ phase: String?,
563
+ httpStatus: Int?,
564
+ providerCode: String?,
565
+ providerMessage: String?,
566
+ nativeCause: String?,
567
+ ) {
568
+ val error = UploadErrorPayload(
569
+ code = code,
570
+ message = message,
571
+ retryable = retryable,
572
+ taskId = taskId,
573
+ phase = phase,
574
+ httpStatus = httpStatus,
575
+ providerCode = providerCode,
576
+ providerMessage = providerMessage,
577
+ nativeCause = nativeCause,
578
+ )
579
+ val updated = store.update(taskId) { task ->
580
+ task.state = UploadState.FAILED
581
+ task.error = error
582
+ task.nextRetryAt = null
583
+ } ?: return
584
+ lastProgressEmit.remove(taskId)
585
+ cleanupDerivedFilesIfNeeded(updated)
586
+ val finalTask = store.update(taskId) { task ->
587
+ task.stagedFilePath = null
588
+ task.optimizedFilePath = null
589
+ } ?: updated
590
+ UploadNotificationHelper.cancel(appContext, taskId)
591
+
592
+ emit(
593
+ mapOf(
594
+ "type" to "failed",
595
+ "taskId" to taskId,
596
+ "error" to error.toMap(),
597
+ "task" to finalTask.toMap(),
598
+ ),
599
+ )
600
+ }
601
+
602
+ private fun failOrRetry(
603
+ taskId: String,
604
+ code: UploadErrorCode,
605
+ message: String,
606
+ retryable: Boolean,
607
+ phase: String?,
608
+ httpStatus: Int?,
609
+ providerCode: String?,
610
+ providerMessage: String?,
611
+ nativeCause: String?,
612
+ ): UploadExecutionResult {
613
+ val record = store.task(taskId) ?: return UploadExecutionResult.FINISHED
614
+ if (retryable && record.networkPolicy != UploadNetworkPolicy.FAIL_FAST && record.attempt < record.maxAttempts) {
615
+ scheduleRetry(
616
+ record = record,
617
+ code = code,
618
+ message = message,
619
+ phase = phase,
620
+ httpStatus = httpStatus,
621
+ providerCode = providerCode,
622
+ providerMessage = providerMessage,
623
+ nativeCause = nativeCause,
624
+ )
625
+ return UploadExecutionResult.RETRY
626
+ }
627
+
628
+ markFailed(
629
+ taskId = taskId,
630
+ code = code,
631
+ message = message,
632
+ retryable = retryable,
633
+ phase = phase,
634
+ httpStatus = httpStatus,
635
+ providerCode = providerCode,
636
+ providerMessage = providerMessage,
637
+ nativeCause = nativeCause,
638
+ )
639
+ return UploadExecutionResult.FINISHED
640
+ }
641
+
642
+ private fun scheduleRetry(
643
+ record: UploadTaskRecord,
644
+ code: UploadErrorCode,
645
+ message: String,
646
+ phase: String?,
647
+ httpStatus: Int?,
648
+ providerCode: String?,
649
+ providerMessage: String?,
650
+ nativeCause: String?,
651
+ ) {
652
+ val delayMs = if (record.background) {
653
+ retryDelayMs(record.attempt).coerceAtLeast(10_000L)
654
+ } else {
655
+ retryDelayMs(record.attempt)
656
+ }
657
+ val nextRetryAt = (System.currentTimeMillis() + delayMs).toDouble()
658
+ val error = UploadErrorPayload(
659
+ code = code,
660
+ message = message,
661
+ retryable = true,
662
+ taskId = record.id,
663
+ phase = phase,
664
+ httpStatus = httpStatus,
665
+ providerCode = providerCode,
666
+ providerMessage = providerMessage,
667
+ nativeCause = nativeCause,
668
+ )
669
+ val updated = store.update(record.id) { task ->
670
+ task.state = UploadState.RETRYING
671
+ task.error = error
672
+ task.nextRetryAt = nextRetryAt
673
+ } ?: return
674
+ lastProgressEmit.remove(record.id)
675
+ emit(
676
+ mapOf(
677
+ "type" to "retrying",
678
+ "taskId" to record.id,
679
+ "attempt" to updated.attempt + 1,
680
+ "delayMs" to delayMs,
681
+ "error" to error.toMap(),
682
+ ),
683
+ )
684
+ if (!record.background) {
685
+ scheduleUpload(updated, delayMs = delayMs, existingWorkPolicy = ExistingWorkPolicy.REPLACE)
686
+ }
687
+ }
688
+
689
+ private fun retryDelayMs(attempt: Int): Long {
690
+ var delay = baseRetryDelayMs
691
+ repeat((attempt - 1).coerceAtLeast(0)) {
692
+ delay = (delay * 2).coerceAtMost(maxRetryDelayMs)
693
+ }
694
+ val jitter = (delay * 0.2 * Math.random()).toLong()
695
+ return delay + jitter
696
+ }
697
+
698
+ private fun isRetryable(error: Exception, statusCode: Int?): Boolean {
699
+ if (error.message?.contains("Canceled", ignoreCase = true) == true) {
700
+ return false
701
+ }
702
+ if (error is SocketTimeoutException || error is UnknownHostException) {
703
+ return true
704
+ }
705
+ if (error is IOException) {
706
+ val message = error.message?.lowercase() ?: ""
707
+ if (
708
+ message.contains("timeout") ||
709
+ message.contains("connection") ||
710
+ message.contains("network") ||
711
+ message.contains("host")
712
+ ) {
713
+ return true
714
+ }
715
+ }
716
+ if (statusCode != null && statusCode >= 500) {
717
+ return true
718
+ }
719
+ return false
720
+ }
721
+
722
+ private fun parseProviderError(responseBody: String?): ProviderError {
723
+ if (responseBody.isNullOrBlank()) {
724
+ return ProviderError(null, null)
725
+ }
726
+ runCatching {
727
+ val json = JSONObject(responseBody)
728
+ return ProviderError(
729
+ code = json.optString("Code").takeIf { it.isNotEmpty() }
730
+ ?: json.optString("code").takeIf { it.isNotEmpty() },
731
+ message = json.optString("Message").takeIf { it.isNotEmpty() }
732
+ ?: json.optString("message").takeIf { it.isNotEmpty() },
733
+ )
734
+ }
735
+ return ProviderError(
736
+ code = xmlValue(responseBody, "Code"),
737
+ message = xmlValue(responseBody, "Message"),
738
+ )
739
+ }
740
+
741
+ private fun xmlValue(body: String, name: String): String? {
742
+ val open = "<$name>"
743
+ val close = "</$name>"
744
+ val start = body.indexOf(open)
745
+ if (start < 0) {
746
+ return null
747
+ }
748
+ val valueStart = start + open.length
749
+ val end = body.indexOf(close, valueStart)
750
+ if (end < 0) {
751
+ return null
752
+ }
753
+ return body.substring(valueStart, end)
754
+ }
755
+
756
+ private data class PreparedUploadFile(
757
+ val file: File,
758
+ val stagedPath: String?,
759
+ )
760
+
761
+ private fun prepareUploadFile(
762
+ taskId: String,
763
+ localUri: String,
764
+ stagedFilePath: String?,
765
+ ): PreparedUploadFile {
766
+ stagedFilePath?.let { staged ->
767
+ val file = File(staged)
768
+ if (file.exists() && file.isFile) {
769
+ return PreparedUploadFile(file, staged)
770
+ }
771
+ }
772
+
773
+ if (!FileStager.needsStaging(appContext, localUri)) {
774
+ val file = resolveStableFile(localUri)
775
+ return PreparedUploadFile(file, null)
776
+ }
777
+
778
+ val staged = FileStager.stageFile(appContext, localUri, taskId)
779
+ store.update(taskId) { task ->
780
+ task.stagedFilePath = staged.absolutePath
781
+ }
782
+ return PreparedUploadFile(staged, staged.absolutePath)
783
+ }
784
+
785
+ private fun resolveStableFile(uri: String): File {
786
+ when {
787
+ uri.startsWith("file://") -> {
788
+ val path = Uri.parse(uri).path
789
+ ?: throw UploadManagerException(UploadErrorCode.FILE_NOT_FOUND, "File not found at $uri.")
790
+ val file = File(path)
791
+ if (!file.exists() || !file.isFile) {
792
+ throw UploadManagerException(UploadErrorCode.FILE_NOT_FOUND, "File not found at $uri.")
793
+ }
794
+ return file
795
+ }
796
+ else -> {
797
+ val file = if (uri.startsWith("/")) File(uri) else File(uri)
798
+ if (!file.exists() || !file.isFile) {
799
+ throw UploadManagerException(UploadErrorCode.FILE_NOT_FOUND, "File not found at $uri.")
800
+ }
801
+ return file
802
+ }
803
+ }
804
+ }
805
+
806
+ private fun resolveUploadFile(record: UploadTaskRecord): File {
807
+ record.optimizedFilePath?.let { optimized ->
808
+ val file = File(optimized)
809
+ if (file.exists() && file.isFile) {
810
+ return file
811
+ }
812
+ }
813
+ return prepareUploadFile(
814
+ taskId = record.id,
815
+ localUri = record.localUri,
816
+ stagedFilePath = record.stagedFilePath,
817
+ ).file
818
+ }
819
+
820
+ private fun cleanupDerivedFilesIfNeeded(record: UploadTaskRecord) {
821
+ cleanupStagedFileIfNeeded(record)
822
+ record.optimizedFilePath?.let { path ->
823
+ CompressionPipeline.cleanup(appContext, path)
824
+ }
825
+ }
826
+
827
+ private fun cleanupStagedFileIfNeeded(record: UploadTaskRecord) {
828
+ record.stagedFilePath?.let { path ->
829
+ FileStager.cleanup(appContext, path)
830
+ }
831
+ }
832
+
833
+ private fun emit(event: Map<String, Any?>) {
834
+ eventEmitter?.invoke(event)
835
+ }
836
+
837
+ private fun workName(taskId: String): String = "uploados-upload-$taskId"
838
+
839
+ companion object {
840
+ const val WORK_INPUT_TASK_ID = "taskId"
841
+ const val WORK_TAG = "uploados-upload"
842
+
843
+ @Volatile
844
+ private var instance: UploadManager? = null
845
+
846
+ fun getInstance(context: Context): UploadManager {
847
+ return instance ?: synchronized(this) {
848
+ instance ?: UploadManager(context.applicationContext).also { instance = it }
849
+ }
850
+ }
851
+ }
852
+ }
853
+
854
+ private data class ProviderError(
855
+ val code: String?,
856
+ val message: String?,
857
+ )