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,813 @@
1
+ import ExpoModulesCore
2
+ import Foundation
3
+
4
+ final class UploadManager: NSObject {
5
+ static let shared = UploadManager()
6
+
7
+ static let backgroundSessionIdentifier = "expo.modules.uploados.background"
8
+
9
+ private let store = UploadTaskStore.shared
10
+ private let sessionDelegate = UploadSessionDelegate()
11
+ private var eventEmitter: (([String: Any]) -> Void)?
12
+ private var foregroundWaitSession: URLSession!
13
+ private var foregroundFailFastSession: URLSession!
14
+ private var backgroundSession: URLSession!
15
+ private var backgroundCompletionHandlers: [String: () -> Void] = [:]
16
+ private var lastProgressEmit: [String: TimeInterval] = [:]
17
+ private let progressThrottleMs: TimeInterval = 250
18
+ private let baseRetryDelayMs: TimeInterval = 1000
19
+ private let maxRetryDelayMs: TimeInterval = 30_000
20
+
21
+ private override init() {
22
+ super.init()
23
+ sessionDelegate.manager = self
24
+ let foregroundWaitConfig = URLSessionConfiguration.default
25
+ foregroundWaitConfig.waitsForConnectivity = true
26
+ foregroundWaitSession = URLSession(
27
+ configuration: foregroundWaitConfig,
28
+ delegate: sessionDelegate,
29
+ delegateQueue: nil
30
+ )
31
+ let foregroundFailFastConfig = URLSessionConfiguration.default
32
+ foregroundFailFastConfig.waitsForConnectivity = false
33
+ foregroundFailFastSession = URLSession(
34
+ configuration: foregroundFailFastConfig,
35
+ delegate: sessionDelegate,
36
+ delegateQueue: nil
37
+ )
38
+ let backgroundConfig = URLSessionConfiguration.background(
39
+ withIdentifier: Self.backgroundSessionIdentifier
40
+ )
41
+ backgroundConfig.waitsForConnectivity = true
42
+ backgroundSession = URLSession(
43
+ configuration: backgroundConfig,
44
+ delegate: sessionDelegate,
45
+ delegateQueue: nil
46
+ )
47
+ }
48
+
49
+ func setEventEmitter(_ emitter: @escaping ([String: Any]) -> Void) {
50
+ eventEmitter = emitter
51
+ }
52
+
53
+ func createUpload(options: CreateUploadOptions) throws -> UploadTaskRecord {
54
+ let now = Date().timeIntervalSince1970 * 1000
55
+ let taskId = UUID().uuidString
56
+ var record = UploadTaskRecord(
57
+ id: taskId,
58
+ localUri: options.localUri,
59
+ compressionEnabled: options.compressionEnabled,
60
+ compressionPreset: options.compressionEnabled ? options.compressionPreset.rawValue : nil,
61
+ uploadUrl: options.uploadUrl,
62
+ method: options.method,
63
+ state: .created,
64
+ progress: UploadProgress(bytesUploaded: 0, totalBytes: 0, percentage: 0),
65
+ background: options.background,
66
+ networkPolicy: options.networkPolicy,
67
+ attempt: 0,
68
+ maxAttempts: options.maxAttempts,
69
+ nextRetryAt: nil,
70
+ createdAt: now,
71
+ updatedAt: now,
72
+ headers: options.headers,
73
+ sessionTaskIdentifier: nil,
74
+ error: nil
75
+ )
76
+ store.upsert(record)
77
+ emit(event: ["type": "created", "task": record.toDictionary()])
78
+
79
+ record = store.update(id: taskId) { task in
80
+ task.state = .compressing
81
+ } ?? record
82
+ emit(event: ["type": "compressing", "taskId": taskId, "task": record.toDictionary()])
83
+
84
+ let fileURL: URL
85
+ let fileSize: Int
86
+ do {
87
+ let prepared = try prepareUploadFile(for: taskId, localUri: options.localUri, stagedFilePath: nil)
88
+ if let stagedPath = prepared.stagedPath {
89
+ record = store.update(id: taskId) { task in
90
+ task.stagedFilePath = stagedPath
91
+ } ?? record
92
+ }
93
+
94
+ if options.compressionEnabled {
95
+ if let optimizedPath = record.optimizedFilePath,
96
+ FileManager.default.fileExists(atPath: optimizedPath) {
97
+ fileURL = URL(fileURLWithPath: optimizedPath)
98
+ let attributes = try FileManager.default.attributesOfItem(atPath: optimizedPath)
99
+ fileSize = (attributes[.size] as? NSNumber)?.intValue ?? 0
100
+ } else {
101
+ let result = try CompressionPipeline.compress(
102
+ sourceURL: prepared.url,
103
+ taskId: taskId,
104
+ preset: options.compressionPreset
105
+ )
106
+ record = store.update(id: taskId) { task in
107
+ task.optimizedFilePath = result.outputURL.path
108
+ task.originalSize = result.stats.originalSize
109
+ task.optimizedSize = result.stats.optimizedSize
110
+ } ?? record
111
+ emit(event: [
112
+ "type": "compressed",
113
+ "taskId": taskId,
114
+ "task": record.toDictionary(),
115
+ "stats": result.stats.toDictionary(),
116
+ ])
117
+ fileURL = result.outputURL
118
+ fileSize = result.stats.optimizedSize
119
+ }
120
+ } else {
121
+ fileURL = prepared.url
122
+ let attributes = try FileManager.default.attributesOfItem(atPath: fileURL.path)
123
+ fileSize = (attributes[.size] as? NSNumber)?.intValue ?? 0
124
+ if fileSize > 0 {
125
+ record = store.update(id: taskId) { task in
126
+ task.originalSize = fileSize
127
+ } ?? record
128
+ }
129
+ }
130
+ guard fileSize > 0 else {
131
+ throw UploadManagerError.fileNotFound(options.localUri)
132
+ }
133
+ } catch let error as CompressionPipelineError {
134
+ markFailed(
135
+ taskId: taskId,
136
+ code: .COMPRESSION_FAILED,
137
+ message: error.localizedDescription,
138
+ retryable: false,
139
+ phase: "compressing",
140
+ httpStatus: nil,
141
+ providerCode: nil,
142
+ providerMessage: nil,
143
+ nativeCause: nil
144
+ )
145
+ throw error
146
+ } catch {
147
+ markFailed(
148
+ taskId: taskId,
149
+ code: .FILE_NOT_FOUND,
150
+ message: error.localizedDescription,
151
+ retryable: false,
152
+ phase: "compressing",
153
+ httpStatus: nil,
154
+ providerCode: nil,
155
+ providerMessage: nil,
156
+ nativeCause: nil
157
+ )
158
+ throw error
159
+ }
160
+
161
+ record = store.update(id: taskId) { task in
162
+ task.state = .queued
163
+ task.progress = UploadProgress(bytesUploaded: 0, totalBytes: fileSize, percentage: 0)
164
+ } ?? record
165
+ emit(event: ["type": "queued", "taskId": taskId])
166
+
167
+ try startUploadOrMarkFailed(for: record, fileURL: fileURL)
168
+ return store.task(id: taskId) ?? record
169
+ }
170
+
171
+ func cancelUpload(taskId: String) throws -> UploadTaskRecord {
172
+ guard var record = store.task(id: taskId) else {
173
+ throw UploadManagerError.taskNotFound(taskId)
174
+ }
175
+ if let sessionTaskId = record.sessionTaskIdentifier {
176
+ session(for: record).getAllTasks { tasks in
177
+ tasks.first(where: { $0.taskIdentifier == sessionTaskId })?.cancel()
178
+ }
179
+ }
180
+ record.state = .cancelled
181
+ record.error = UploadErrorPayload(
182
+ code: .CANCELLED,
183
+ message: "Upload cancelled.",
184
+ retryable: false,
185
+ taskId: taskId,
186
+ phase: nil,
187
+ httpStatus: nil,
188
+ providerCode: nil,
189
+ providerMessage: nil,
190
+ nativeCause: nil
191
+ )
192
+ record.nextRetryAt = nil
193
+ record.sessionTaskIdentifier = nil
194
+ record.updatedAt = Date().timeIntervalSince1970 * 1000
195
+ cleanupDerivedFilesIfNeeded(for: record)
196
+ record.stagedFilePath = nil
197
+ record.optimizedFilePath = nil
198
+ store.upsert(record)
199
+ emit(event: ["type": "cancelled", "taskId": taskId])
200
+ return store.task(id: taskId) ?? record
201
+ }
202
+
203
+ func retryUpload(taskId: String) throws -> UploadTaskRecord {
204
+ guard var record = store.task(id: taskId) else {
205
+ throw UploadManagerError.taskNotFound(taskId)
206
+ }
207
+ guard record.state == .failed else {
208
+ throw UploadManagerError.invalidRetryState(taskId, record.state.rawValue)
209
+ }
210
+
211
+ record = store.update(id: taskId) { task in
212
+ task.state = .compressing
213
+ task.error = nil
214
+ task.nextRetryAt = nil
215
+ task.sessionTaskIdentifier = nil
216
+ } ?? record
217
+ emit(event: ["type": "compressing", "taskId": taskId, "task": record.toDictionary()])
218
+
219
+ let fileURL: URL
220
+ let fileSize: Int
221
+ do {
222
+ fileURL = try resolveUploadFileURL(for: record)
223
+ let attributes = try FileManager.default.attributesOfItem(atPath: fileURL.path)
224
+ fileSize = (attributes[.size] as? NSNumber)?.intValue ?? record.optimizedSize ?? record.progress.totalBytes
225
+ guard fileSize > 0 else {
226
+ throw UploadManagerError.fileNotFound(record.localUri)
227
+ }
228
+ } catch {
229
+ markFailed(
230
+ taskId: taskId,
231
+ code: .FILE_NOT_FOUND,
232
+ message: error.localizedDescription,
233
+ retryable: false,
234
+ phase: "compressing",
235
+ httpStatus: nil,
236
+ providerCode: nil,
237
+ providerMessage: nil,
238
+ nativeCause: nil
239
+ )
240
+ throw error
241
+ }
242
+
243
+ record = store.update(id: taskId) { task in
244
+ task.state = .queued
245
+ task.progress = UploadProgress(
246
+ bytesUploaded: 0,
247
+ totalBytes: fileSize,
248
+ percentage: 0
249
+ )
250
+ task.attempt = 0
251
+ task.error = nil
252
+ task.nextRetryAt = nil
253
+ task.sessionTaskIdentifier = nil
254
+ } ?? record
255
+ emit(event: ["type": "queued", "taskId": taskId])
256
+
257
+ try startUploadOrMarkFailed(for: record, fileURL: fileURL)
258
+ return store.task(id: taskId) ?? record
259
+ }
260
+
261
+ func restoreQueue() -> [UploadTaskRecord] {
262
+ let pending = store.restorableTasks()
263
+ let activeBackgroundTaskIds = reconcileBackgroundSessionTasks()
264
+ for record in pending {
265
+ if record.background && activeBackgroundTaskIds.contains(record.id) {
266
+ continue
267
+ }
268
+ do {
269
+ let fileURL = try resolveUploadFileURL(for: record)
270
+ if record.state == .retrying, let nextRetryAt = record.nextRetryAt {
271
+ let delayMs = max(0, nextRetryAt - Date().timeIntervalSince1970 * 1000)
272
+ scheduleStart(for: record.id, delayMs: delayMs)
273
+ } else {
274
+ try startUpload(for: record, fileURL: fileURL)
275
+ }
276
+ } catch {
277
+ markFailed(
278
+ taskId: record.id,
279
+ code: .FILE_NOT_FOUND,
280
+ message: error.localizedDescription,
281
+ retryable: false,
282
+ phase: "compressing",
283
+ httpStatus: nil,
284
+ providerCode: nil,
285
+ providerMessage: nil,
286
+ nativeCause: nil
287
+ )
288
+ }
289
+ }
290
+ return store.allTasks()
291
+ }
292
+
293
+ func registerBackgroundCompletionHandler(
294
+ for identifier: String,
295
+ completionHandler: @escaping () -> Void
296
+ ) {
297
+ backgroundCompletionHandlers[identifier] = completionHandler
298
+ }
299
+
300
+ func invokeBackgroundCompletionHandler(for identifier: String?) {
301
+ guard let identifier,
302
+ let handler = backgroundCompletionHandlers.removeValue(forKey: identifier) else {
303
+ return
304
+ }
305
+ DispatchQueue.main.async {
306
+ handler()
307
+ }
308
+ }
309
+
310
+ func handleProgress(taskId: String, bytesUploaded: Int, totalBytes: Int) {
311
+ let now = Date().timeIntervalSince1970 * 1000
312
+ let lastEmit = lastProgressEmit[taskId] ?? 0
313
+ let percentage = totalBytes > 0
314
+ ? min(100, max(0, (Double(bytesUploaded) / Double(totalBytes)) * 100))
315
+ : 0
316
+ let previous = store.task(id: taskId)
317
+ let isVerifying = percentage >= 100
318
+
319
+ guard let updated = store.update(id: taskId, mutate: { task in
320
+ task.state = isVerifying ? .verifying : .uploading
321
+ task.progress = UploadProgress(
322
+ bytesUploaded: bytesUploaded,
323
+ totalBytes: totalBytes,
324
+ percentage: percentage
325
+ )
326
+ }) else {
327
+ return
328
+ }
329
+
330
+ if now - lastEmit < progressThrottleMs && percentage < 100 {
331
+ return
332
+ }
333
+ lastProgressEmit[taskId] = now
334
+ if isVerifying && previous?.state != .verifying {
335
+ emit(event: ["type": "verifying", "taskId": taskId])
336
+ }
337
+ emit(event: [
338
+ "type": "progress",
339
+ "taskId": taskId,
340
+ "progress": [
341
+ "bytesUploaded": updated.progress.bytesUploaded,
342
+ "totalBytes": updated.progress.totalBytes,
343
+ "percentage": updated.progress.percentage,
344
+ ],
345
+ ])
346
+ }
347
+
348
+ func handleCompletion(taskId: String) {
349
+ guard let updated = store.update(id: taskId, mutate: { task in
350
+ task.state = .completed
351
+ task.progress.percentage = 100
352
+ task.progress.bytesUploaded = task.progress.totalBytes
353
+ task.error = nil
354
+ task.nextRetryAt = nil
355
+ task.sessionTaskIdentifier = nil
356
+ }) else {
357
+ return
358
+ }
359
+ lastProgressEmit.removeValue(forKey: taskId)
360
+ cleanupDerivedFilesIfNeeded(for: updated)
361
+ let finalRecord = store.update(id: taskId) { record in
362
+ record.stagedFilePath = nil
363
+ record.optimizedFilePath = nil
364
+ } ?? updated
365
+ emit(event: [
366
+ "type": "completed",
367
+ "taskId": taskId,
368
+ "task": finalRecord.toDictionary(),
369
+ ])
370
+ }
371
+
372
+ func handleFailure(taskId: String, error: Error, response: URLResponse?) {
373
+ if (error as NSError).code == NSURLErrorCancelled {
374
+ return
375
+ }
376
+ let retryable = isRetryable(error: error, response: response)
377
+ let nsError = error as NSError
378
+ failOrRetry(
379
+ taskId: taskId,
380
+ code: .NETWORK_ERROR,
381
+ message: error.localizedDescription,
382
+ retryable: retryable,
383
+ phase: failurePhase(taskId: taskId),
384
+ httpStatus: (response as? HTTPURLResponse)?.statusCode,
385
+ providerCode: nil,
386
+ providerMessage: nil,
387
+ nativeCause: "\(nsError.domain):\(nsError.code)"
388
+ )
389
+ }
390
+
391
+ private func failurePhase(taskId: String) -> String {
392
+ switch store.task(id: taskId)?.state {
393
+ case .compressing:
394
+ return "compressing"
395
+ case .connecting:
396
+ return "connecting"
397
+ case .verifying:
398
+ return "verifying"
399
+ case .retrying:
400
+ return "retrying"
401
+ default:
402
+ return "uploading"
403
+ }
404
+ }
405
+
406
+ func handleHttpFailure(taskId: String, statusCode: Int, responseBody: String?) {
407
+ let code: UploadErrorCode = statusCode == 401 || statusCode == 403 ? .AUTH_ERROR : .PROVIDER_ERROR
408
+ let providerError = parseProviderError(from: responseBody)
409
+ failOrRetry(
410
+ taskId: taskId,
411
+ code: code,
412
+ message: providerError.message ?? "Upload failed with HTTP status \(statusCode).",
413
+ retryable: statusCode >= 500,
414
+ phase: "verifying",
415
+ httpStatus: statusCode,
416
+ providerCode: providerError.code,
417
+ providerMessage: providerError.message,
418
+ nativeCause: nil
419
+ )
420
+ }
421
+
422
+ private func startUpload(for record: UploadTaskRecord, fileURL: URL) throws {
423
+ guard let destination = URL(string: record.uploadUrl) else {
424
+ throw UploadManagerError.invalidUploadUrl(record.uploadUrl)
425
+ }
426
+
427
+ var request = URLRequest(url: destination)
428
+ request.httpMethod = record.method
429
+ for (key, value) in record.headers {
430
+ request.setValue(value, forHTTPHeaderField: key)
431
+ }
432
+
433
+ let session = session(for: record)
434
+ let uploadTask = session.uploadTask(with: request, fromFile: fileURL)
435
+ uploadTask.taskDescription = record.id
436
+ uploadTask.priority = URLSessionTask.defaultPriority
437
+
438
+ let updated = store.update(id: record.id) { task in
439
+ let nextAttempt = task.attempt + 1
440
+ task.state = .connecting
441
+ task.attempt = nextAttempt
442
+ task.nextRetryAt = nil
443
+ if nextAttempt > 1 {
444
+ task.progress.bytesUploaded = 0
445
+ task.progress.percentage = 0
446
+ }
447
+ task.sessionTaskIdentifier = uploadTask.taskIdentifier
448
+ }
449
+ if let updated {
450
+ emit(event: ["type": "connecting", "taskId": record.id, "task": updated.toDictionary()])
451
+ }
452
+ uploadTask.resume()
453
+ }
454
+
455
+ private func startUploadOrMarkFailed(for record: UploadTaskRecord, fileURL: URL) throws {
456
+ do {
457
+ try startUpload(for: record, fileURL: fileURL)
458
+ } catch {
459
+ markFailed(
460
+ taskId: record.id,
461
+ code: .UNKNOWN,
462
+ message: error.localizedDescription,
463
+ retryable: false,
464
+ phase: "connecting",
465
+ httpStatus: nil,
466
+ providerCode: nil,
467
+ providerMessage: nil,
468
+ nativeCause: nil
469
+ )
470
+ throw error
471
+ }
472
+ }
473
+
474
+ private func session(for record: UploadTaskRecord) -> URLSession {
475
+ if record.background {
476
+ return backgroundSession
477
+ }
478
+ return record.networkPolicy == .failFast ? foregroundFailFastSession : foregroundWaitSession
479
+ }
480
+
481
+ private func reconcileBackgroundSessionTasks() -> Set<String> {
482
+ let semaphore = DispatchSemaphore(value: 0)
483
+ var activeTaskIds = Set<String>()
484
+ backgroundSession.getAllTasks { tasks in
485
+ for task in tasks {
486
+ guard let taskId = task.taskDescription else {
487
+ continue
488
+ }
489
+ activeTaskIds.insert(taskId)
490
+ if self.store.task(id: taskId) == nil {
491
+ task.cancel()
492
+ } else {
493
+ _ = self.store.update(id: taskId) { record in
494
+ record.sessionTaskIdentifier = task.taskIdentifier
495
+ }
496
+ }
497
+ }
498
+ semaphore.signal()
499
+ }
500
+ _ = semaphore.wait(timeout: .now() + 2)
501
+ return activeTaskIds
502
+ }
503
+
504
+ private func markFailed(
505
+ taskId: String,
506
+ code: UploadErrorCode,
507
+ message: String,
508
+ retryable: Bool,
509
+ phase: String?,
510
+ httpStatus: Int?,
511
+ providerCode: String?,
512
+ providerMessage: String?,
513
+ nativeCause: String?
514
+ ) {
515
+ let error = UploadErrorPayload(
516
+ code: code,
517
+ message: message,
518
+ retryable: retryable,
519
+ taskId: taskId,
520
+ phase: phase,
521
+ httpStatus: httpStatus,
522
+ providerCode: providerCode,
523
+ providerMessage: providerMessage,
524
+ nativeCause: nativeCause
525
+ )
526
+ guard let updated = store.update(id: taskId, mutate: { task in
527
+ task.state = .failed
528
+ task.error = error
529
+ task.nextRetryAt = nil
530
+ task.sessionTaskIdentifier = nil
531
+ }) else {
532
+ return
533
+ }
534
+ lastProgressEmit.removeValue(forKey: taskId)
535
+ cleanupDerivedFilesIfNeeded(for: updated)
536
+ let finalRecord = store.update(id: taskId) { record in
537
+ record.stagedFilePath = nil
538
+ record.optimizedFilePath = nil
539
+ } ?? updated
540
+ emit(event: [
541
+ "type": "failed",
542
+ "taskId": taskId,
543
+ "error": error.toDictionary(),
544
+ "task": finalRecord.toDictionary(),
545
+ ])
546
+ }
547
+
548
+ private func failOrRetry(
549
+ taskId: String,
550
+ code: UploadErrorCode,
551
+ message: String,
552
+ retryable: Bool,
553
+ phase: String?,
554
+ httpStatus: Int?,
555
+ providerCode: String?,
556
+ providerMessage: String?,
557
+ nativeCause: String?
558
+ ) {
559
+ guard let record = store.task(id: taskId) else {
560
+ return
561
+ }
562
+
563
+ if retryable && record.networkPolicy != .failFast && record.attempt < record.maxAttempts {
564
+ scheduleRetry(
565
+ record: record,
566
+ code: code,
567
+ message: message,
568
+ phase: phase,
569
+ httpStatus: httpStatus,
570
+ providerCode: providerCode,
571
+ providerMessage: providerMessage,
572
+ nativeCause: nativeCause
573
+ )
574
+ return
575
+ }
576
+
577
+ markFailed(
578
+ taskId: taskId,
579
+ code: code,
580
+ message: message,
581
+ retryable: retryable,
582
+ phase: phase,
583
+ httpStatus: httpStatus,
584
+ providerCode: providerCode,
585
+ providerMessage: providerMessage,
586
+ nativeCause: nativeCause
587
+ )
588
+ }
589
+
590
+ private func scheduleRetry(
591
+ record: UploadTaskRecord,
592
+ code: UploadErrorCode,
593
+ message: String,
594
+ phase: String?,
595
+ httpStatus: Int?,
596
+ providerCode: String?,
597
+ providerMessage: String?,
598
+ nativeCause: String?
599
+ ) {
600
+ let delayMs = retryDelayMs(forAttempt: record.attempt)
601
+ let nextRetryAt = Date().timeIntervalSince1970 * 1000 + delayMs
602
+ let error = UploadErrorPayload(
603
+ code: code,
604
+ message: message,
605
+ retryable: true,
606
+ taskId: record.id,
607
+ phase: phase,
608
+ httpStatus: httpStatus,
609
+ providerCode: providerCode,
610
+ providerMessage: providerMessage,
611
+ nativeCause: nativeCause
612
+ )
613
+ guard let updated = store.update(id: record.id, mutate: { task in
614
+ task.state = .retrying
615
+ task.error = error
616
+ task.nextRetryAt = nextRetryAt
617
+ task.sessionTaskIdentifier = nil
618
+ }) else {
619
+ return
620
+ }
621
+ lastProgressEmit.removeValue(forKey: record.id)
622
+ emit(event: [
623
+ "type": "retrying",
624
+ "taskId": record.id,
625
+ "attempt": updated.attempt + 1,
626
+ "delayMs": delayMs,
627
+ "error": error.toDictionary(),
628
+ ])
629
+ scheduleStart(for: record.id, delayMs: delayMs)
630
+ }
631
+
632
+ private func scheduleStart(for taskId: String, delayMs: TimeInterval) {
633
+ DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + delayMs / 1000) { [weak self] in
634
+ guard let self,
635
+ let latest = self.store.task(id: taskId),
636
+ latest.state == .retrying || latest.state == .queued || latest.state == .connecting || latest.state == .uploading else {
637
+ return
638
+ }
639
+ do {
640
+ let fileURL = try self.resolveUploadFileURL(for: latest)
641
+ try self.startUpload(for: latest, fileURL: fileURL)
642
+ } catch {
643
+ self.markFailed(
644
+ taskId: taskId,
645
+ code: .FILE_NOT_FOUND,
646
+ message: error.localizedDescription,
647
+ retryable: false,
648
+ phase: "compressing",
649
+ httpStatus: nil,
650
+ providerCode: nil,
651
+ providerMessage: nil,
652
+ nativeCause: nil
653
+ )
654
+ }
655
+ }
656
+ }
657
+
658
+ private func retryDelayMs(forAttempt attempt: Int) -> TimeInterval {
659
+ let exponent = max(0, attempt - 1)
660
+ let delay = min(maxRetryDelayMs, baseRetryDelayMs * pow(2.0, Double(exponent)))
661
+ return delay + Double.random(in: 0...(delay * 0.2))
662
+ }
663
+
664
+ private func isRetryable(error: Error, response: URLResponse?) -> Bool {
665
+ let nsError = error as NSError
666
+ if nsError.domain == NSURLErrorDomain {
667
+ switch nsError.code {
668
+ case NSURLErrorTimedOut,
669
+ NSURLErrorNetworkConnectionLost,
670
+ NSURLErrorNotConnectedToInternet,
671
+ NSURLErrorCannotConnectToHost,
672
+ NSURLErrorCannotFindHost,
673
+ NSURLErrorDNSLookupFailed,
674
+ NSURLErrorInternationalRoamingOff,
675
+ NSURLErrorDataNotAllowed:
676
+ return true
677
+ default:
678
+ break
679
+ }
680
+ }
681
+ if let http = response as? HTTPURLResponse {
682
+ return http.statusCode >= 500
683
+ }
684
+ return false
685
+ }
686
+
687
+ private func parseProviderError(from responseBody: String?) -> (code: String?, message: String?) {
688
+ guard let responseBody, !responseBody.isEmpty else {
689
+ return (nil, nil)
690
+ }
691
+ if let data = responseBody.data(using: .utf8),
692
+ let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
693
+ let code = object["Code"] as? String ?? object["code"] as? String
694
+ let message = object["Message"] as? String ?? object["message"] as? String
695
+ return (code, message)
696
+ }
697
+ return (
698
+ xmlValue("Code", in: responseBody),
699
+ xmlValue("Message", in: responseBody)
700
+ )
701
+ }
702
+
703
+ private func xmlValue(_ name: String, in body: String) -> String? {
704
+ guard let openRange = body.range(of: "<\(name)>"),
705
+ let closeRange = body.range(of: "</\(name)>", range: openRange.upperBound..<body.endIndex) else {
706
+ return nil
707
+ }
708
+ return String(body[openRange.upperBound..<closeRange.lowerBound])
709
+ }
710
+
711
+ private func emit(event: [String: Any]) {
712
+ eventEmitter?(event)
713
+ }
714
+
715
+ private struct PreparedUploadFile {
716
+ let url: URL
717
+ let stagedPath: String?
718
+ }
719
+
720
+ private func prepareUploadFile(
721
+ for taskId: String,
722
+ localUri: String,
723
+ stagedFilePath: String?
724
+ ) throws -> PreparedUploadFile {
725
+ if let stagedFilePath, !stagedFilePath.isEmpty {
726
+ let stagedURL = URL(fileURLWithPath: stagedFilePath)
727
+ if FileManager.default.fileExists(atPath: stagedURL.path),
728
+ FileManager.default.isReadableFile(atPath: stagedURL.path) {
729
+ return PreparedUploadFile(url: stagedURL, stagedPath: stagedFilePath)
730
+ }
731
+ }
732
+
733
+ let sourceURL = try resolveSourceFileURL(localUri)
734
+ guard FileStager.needsStaging(uri: localUri, fileURL: sourceURL) else {
735
+ return PreparedUploadFile(url: sourceURL, stagedPath: nil)
736
+ }
737
+
738
+ let stagedURL = try FileStager.stageFile(sourceURL: sourceURL, taskId: taskId)
739
+ return PreparedUploadFile(url: stagedURL, stagedPath: stagedURL.path)
740
+ }
741
+
742
+ private func resolveUploadFileURL(for record: UploadTaskRecord) throws -> URL {
743
+ if let optimizedFilePath = record.optimizedFilePath,
744
+ FileManager.default.fileExists(atPath: optimizedFilePath) {
745
+ return URL(fileURLWithPath: optimizedFilePath)
746
+ }
747
+ let prepared = try prepareUploadFile(
748
+ for: record.id,
749
+ localUri: record.localUri,
750
+ stagedFilePath: record.stagedFilePath
751
+ )
752
+ if let stagedPath = prepared.stagedPath, record.stagedFilePath != stagedPath {
753
+ _ = store.update(id: record.id) { task in
754
+ task.stagedFilePath = stagedPath
755
+ }
756
+ }
757
+ return prepared.url
758
+ }
759
+
760
+ private func cleanupDerivedFilesIfNeeded(for record: UploadTaskRecord) {
761
+ cleanupStagedFileIfNeeded(for: record)
762
+ if let optimizedFilePath = record.optimizedFilePath {
763
+ CompressionPipeline.cleanupOptimizedFile(path: optimizedFilePath)
764
+ }
765
+ }
766
+
767
+ private func cleanupStagedFileIfNeeded(for record: UploadTaskRecord) {
768
+ guard let stagedFilePath = record.stagedFilePath else {
769
+ return
770
+ }
771
+ FileStager.cleanupStagedFile(path: stagedFilePath)
772
+ }
773
+
774
+ private func resolveSourceFileURL(_ uri: String) throws -> URL {
775
+ let fileURL: URL
776
+ if uri.hasPrefix("file://") {
777
+ guard let parsed = URL(string: uri) else {
778
+ throw UploadManagerError.fileNotFound(uri)
779
+ }
780
+ fileURL = parsed
781
+ } else {
782
+ fileURL = URL(fileURLWithPath: uri)
783
+ }
784
+
785
+ // Picker/Inbox paths (e.g. …-Inbox/topImage.jpeg) are temporary; after restart the file
786
+ // is gone. URLSession throws NSInvalidArgumentException if fromFile: is missing — validate first.
787
+ guard FileManager.default.fileExists(atPath: fileURL.path),
788
+ FileManager.default.isReadableFile(atPath: fileURL.path) else {
789
+ throw UploadManagerError.fileNotFound(uri)
790
+ }
791
+ return fileURL
792
+ }
793
+ }
794
+
795
+ enum UploadManagerError: Error, LocalizedError {
796
+ case fileNotFound(String)
797
+ case invalidUploadUrl(String)
798
+ case invalidRetryState(String, String)
799
+ case taskNotFound(String)
800
+
801
+ var errorDescription: String? {
802
+ switch self {
803
+ case .fileNotFound(let uri):
804
+ return "File not found at \(uri)."
805
+ case .invalidUploadUrl(let url):
806
+ return "Invalid upload URL: \(url)."
807
+ case .invalidRetryState(let id, let state):
808
+ return "Upload task \(id) cannot be retried from state \(state)."
809
+ case .taskNotFound(let id):
810
+ return "Upload task not found: \(id)."
811
+ }
812
+ }
813
+ }