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.
- package/android/build.gradle +25 -0
- package/android/src/main/AndroidManifest.xml +15 -0
- package/android/src/main/java/expo/modules/uploados/UploadosModule.kt +121 -0
- package/android/src/main/java/expo/modules/uploados/upload/CompressionPipeline.kt +192 -0
- package/android/src/main/java/expo/modules/uploados/upload/FileStager.kt +125 -0
- package/android/src/main/java/expo/modules/uploados/upload/ProgressRequestBody.kt +36 -0
- package/android/src/main/java/expo/modules/uploados/upload/UploadManager.kt +857 -0
- package/android/src/main/java/expo/modules/uploados/upload/UploadModels.kt +209 -0
- package/android/src/main/java/expo/modules/uploados/upload/UploadNotificationHelper.kt +93 -0
- package/android/src/main/java/expo/modules/uploados/upload/UploadTaskStore.kt +224 -0
- package/android/src/main/java/expo/modules/uploados/upload/UploadWorker.kt +31 -0
- package/build/Uploados.types.d.ts +226 -0
- package/build/Uploados.types.d.ts.map +1 -0
- package/build/Uploados.types.js +2 -0
- package/build/Uploados.types.js.map +1 -0
- package/build/UploadosModule.d.ts +13 -0
- package/build/UploadosModule.d.ts.map +1 -0
- package/build/UploadosModule.js +3 -0
- package/build/UploadosModule.js.map +1 -0
- package/build/UploadosModule.web.d.ts +13 -0
- package/build/UploadosModule.web.d.ts.map +1 -0
- package/build/UploadosModule.web.js +33 -0
- package/build/UploadosModule.web.js.map +1 -0
- package/build/createUploader.d.ts +3 -0
- package/build/createUploader.d.ts.map +1 -0
- package/build/createUploader.js +108 -0
- package/build/createUploader.js.map +1 -0
- package/build/index.d.ts +5 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +5 -0
- package/build/index.js.map +1 -0
- package/build/normalizeUploadOptions.d.ts +9 -0
- package/build/normalizeUploadOptions.d.ts.map +1 -0
- package/build/normalizeUploadOptions.js +81 -0
- package/build/normalizeUploadOptions.js.map +1 -0
- package/build/providers/defineUploadProvider.d.ts +26 -0
- package/build/providers/defineUploadProvider.d.ts.map +1 -0
- package/build/providers/defineUploadProvider.js +39 -0
- package/build/providers/defineUploadProvider.js.map +1 -0
- package/build/providers/multipartPlan.d.ts +10 -0
- package/build/providers/multipartPlan.d.ts.map +1 -0
- package/build/providers/multipartPlan.js +28 -0
- package/build/providers/multipartPlan.js.map +1 -0
- package/eslint.config.cjs +5 -0
- package/expo-module.config.json +10 -0
- package/ios/Upload/CompressionPipeline.swift +183 -0
- package/ios/Upload/FileStager.swift +67 -0
- package/ios/Upload/UploadManager.swift +813 -0
- package/ios/Upload/UploadModels.swift +305 -0
- package/ios/Upload/UploadSessionDelegate.swift +82 -0
- package/ios/Upload/UploadTaskStore.swift +92 -0
- package/ios/Upload/UploadosAppDelegate.swift +14 -0
- package/ios/Uploados.podspec +23 -0
- package/ios/UploadosModule.swift +87 -0
- package/jest.config.js +15 -0
- package/package.json +54 -0
- package/readme.md +169 -0
- package/src/Uploados.types.ts +260 -0
- package/src/UploadosModule.ts +18 -0
- package/src/UploadosModule.web.ts +49 -0
- package/src/createUploader.ts +146 -0
- package/src/index.ts +4 -0
- package/src/normalizeUploadOptions.ts +132 -0
- package/src/providers/defineUploadProvider.ts +75 -0
- package/src/providers/multipartPlan.ts +43 -0
- 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
|
+
}
|