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,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
|
+
)
|