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,209 @@
|
|
|
1
|
+
package expo.modules.uploados.upload
|
|
2
|
+
|
|
3
|
+
import java.io.File
|
|
4
|
+
|
|
5
|
+
enum class UploadState(val raw: String) {
|
|
6
|
+
CREATED("created"),
|
|
7
|
+
VALIDATING("validating"),
|
|
8
|
+
COMPRESSING("compressing"),
|
|
9
|
+
QUEUED("queued"),
|
|
10
|
+
CONNECTING("connecting"),
|
|
11
|
+
UPLOADING("uploading"),
|
|
12
|
+
VERIFYING("verifying"),
|
|
13
|
+
RETRYING("retrying"),
|
|
14
|
+
COMPLETED("completed"),
|
|
15
|
+
PAUSED("paused"),
|
|
16
|
+
FAILED("failed"),
|
|
17
|
+
CANCELLED("cancelled");
|
|
18
|
+
|
|
19
|
+
companion object {
|
|
20
|
+
fun fromRaw(value: String): UploadState? =
|
|
21
|
+
entries.firstOrNull { it.raw == value }
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
enum class UploadNetworkPolicy(val raw: String) {
|
|
26
|
+
WAIT("wait"),
|
|
27
|
+
FAIL_FAST("failFast");
|
|
28
|
+
|
|
29
|
+
companion object {
|
|
30
|
+
fun fromRaw(value: String): UploadNetworkPolicy? =
|
|
31
|
+
entries.firstOrNull { it.raw == value }
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
enum class UploadExecutionResult {
|
|
36
|
+
FINISHED,
|
|
37
|
+
RETRY,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
enum class UploadErrorCode(val raw: String) {
|
|
41
|
+
FILE_NOT_FOUND("FILE_NOT_FOUND"),
|
|
42
|
+
FILE_TOO_LARGE("FILE_TOO_LARGE"),
|
|
43
|
+
NETWORK_ERROR("NETWORK_ERROR"),
|
|
44
|
+
AUTH_ERROR("AUTH_ERROR"),
|
|
45
|
+
PROVIDER_ERROR("PROVIDER_ERROR"),
|
|
46
|
+
MULTIPART_FAILED("MULTIPART_FAILED"),
|
|
47
|
+
COMPRESSION_FAILED("COMPRESSION_FAILED"),
|
|
48
|
+
BACKGROUND_RESTRICTED("BACKGROUND_RESTRICTED"),
|
|
49
|
+
CANCELLED("CANCELLED"),
|
|
50
|
+
UNKNOWN("UNKNOWN");
|
|
51
|
+
|
|
52
|
+
companion object {
|
|
53
|
+
fun fromRaw(value: String): UploadErrorCode? =
|
|
54
|
+
entries.firstOrNull { it.raw == value }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
data class UploadErrorPayload(
|
|
59
|
+
val code: UploadErrorCode,
|
|
60
|
+
val message: String,
|
|
61
|
+
val retryable: Boolean,
|
|
62
|
+
val taskId: String,
|
|
63
|
+
val phase: String? = null,
|
|
64
|
+
val httpStatus: Int? = null,
|
|
65
|
+
val providerCode: String? = null,
|
|
66
|
+
val providerMessage: String? = null,
|
|
67
|
+
val nativeCause: String? = null,
|
|
68
|
+
) {
|
|
69
|
+
fun toMap(): Map<String, Any> {
|
|
70
|
+
val map = mutableMapOf<String, Any>(
|
|
71
|
+
"code" to code.raw,
|
|
72
|
+
"message" to message,
|
|
73
|
+
"retryable" to retryable,
|
|
74
|
+
"taskId" to taskId,
|
|
75
|
+
)
|
|
76
|
+
phase?.let { map["phase"] = it }
|
|
77
|
+
httpStatus?.let { map["httpStatus"] = it }
|
|
78
|
+
providerCode?.let { map["providerCode"] = it }
|
|
79
|
+
providerMessage?.let { map["providerMessage"] = it }
|
|
80
|
+
nativeCause?.let { map["nativeCause"] = it }
|
|
81
|
+
return map
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
data class UploadFileMetadata(
|
|
86
|
+
val originalUri: String,
|
|
87
|
+
val uploadUri: String,
|
|
88
|
+
val isStaged: Boolean,
|
|
89
|
+
val sizeBytes: Long? = null,
|
|
90
|
+
) {
|
|
91
|
+
fun toMap(): Map<String, Any> {
|
|
92
|
+
val map = mutableMapOf<String, Any>(
|
|
93
|
+
"originalUri" to originalUri,
|
|
94
|
+
"uploadUri" to uploadUri,
|
|
95
|
+
"isStaged" to isStaged,
|
|
96
|
+
)
|
|
97
|
+
sizeBytes?.let { map["sizeBytes"] = it }
|
|
98
|
+
return map
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
data class UploadProgress(
|
|
103
|
+
var bytesUploaded: Long,
|
|
104
|
+
var totalBytes: Long,
|
|
105
|
+
var percentage: Double,
|
|
106
|
+
) {
|
|
107
|
+
fun toMap(): Map<String, Any> = mapOf(
|
|
108
|
+
"bytesUploaded" to bytesUploaded,
|
|
109
|
+
"totalBytes" to totalBytes,
|
|
110
|
+
"percentage" to percentage,
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
data class UploadTaskRecord(
|
|
115
|
+
val id: String,
|
|
116
|
+
val localUri: String,
|
|
117
|
+
var stagedFilePath: String? = null,
|
|
118
|
+
var optimizedFilePath: String? = null,
|
|
119
|
+
val compressionEnabled: Boolean = false,
|
|
120
|
+
var compressionPreset: String? = null,
|
|
121
|
+
var originalSize: Long? = null,
|
|
122
|
+
var optimizedSize: Long? = null,
|
|
123
|
+
val uploadUrl: String,
|
|
124
|
+
val method: String,
|
|
125
|
+
var state: UploadState,
|
|
126
|
+
var progress: UploadProgress,
|
|
127
|
+
val background: Boolean,
|
|
128
|
+
val networkPolicy: UploadNetworkPolicy,
|
|
129
|
+
var attempt: Int,
|
|
130
|
+
val maxAttempts: Int,
|
|
131
|
+
var nextRetryAt: Double? = null,
|
|
132
|
+
val createdAt: Double,
|
|
133
|
+
var updatedAt: Double,
|
|
134
|
+
var headers: Map<String, String>,
|
|
135
|
+
var error: UploadErrorPayload? = null,
|
|
136
|
+
) {
|
|
137
|
+
fun fileMetadata(): UploadFileMetadata {
|
|
138
|
+
val uploadPath = optimizedFilePath ?: stagedFilePath
|
|
139
|
+
val uploadUri = uploadPath?.let { File(it).toURI().toString() }
|
|
140
|
+
?: if (localUri.startsWith("file://") || localUri.startsWith("content://")) {
|
|
141
|
+
localUri
|
|
142
|
+
} else {
|
|
143
|
+
File(localUri).toURI().toString()
|
|
144
|
+
}
|
|
145
|
+
val sizeBytes = optimizedSize ?: originalSize ?: progress.totalBytes.takeIf { it > 0 }
|
|
146
|
+
return UploadFileMetadata(
|
|
147
|
+
originalUri = localUri,
|
|
148
|
+
uploadUri = uploadUri,
|
|
149
|
+
isStaged = stagedFilePath != null || optimizedFilePath != null,
|
|
150
|
+
sizeBytes = sizeBytes,
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
fun compressionStatsMap(): Map<String, Any>? {
|
|
155
|
+
val preset = compressionPreset
|
|
156
|
+
val original = originalSize
|
|
157
|
+
val optimized = optimizedSize
|
|
158
|
+
if (!compressionEnabled || preset == null || original == null || optimized == null) {
|
|
159
|
+
return null
|
|
160
|
+
}
|
|
161
|
+
return mapOf(
|
|
162
|
+
"preset" to preset,
|
|
163
|
+
"originalSize" to original,
|
|
164
|
+
"optimizedSize" to optimized,
|
|
165
|
+
"format" to "jpeg",
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
fun toMap(): Map<String, Any?> {
|
|
170
|
+
val map = mutableMapOf<String, Any?>(
|
|
171
|
+
"id" to id,
|
|
172
|
+
"localUri" to localUri,
|
|
173
|
+
"file" to fileMetadata().toMap(),
|
|
174
|
+
"uploadUrl" to uploadUrl,
|
|
175
|
+
"method" to method,
|
|
176
|
+
"state" to state.raw,
|
|
177
|
+
"progress" to progress.toMap(),
|
|
178
|
+
"background" to background,
|
|
179
|
+
"networkPolicy" to networkPolicy.raw,
|
|
180
|
+
"attempt" to attempt,
|
|
181
|
+
"maxAttempts" to maxAttempts,
|
|
182
|
+
"createdAt" to createdAt,
|
|
183
|
+
"updatedAt" to updatedAt,
|
|
184
|
+
)
|
|
185
|
+
nextRetryAt?.let { map["nextRetryAt"] = it }
|
|
186
|
+
originalSize?.let { map["originalSize"] = it }
|
|
187
|
+
optimizedSize?.let { map["optimizedSize"] = it }
|
|
188
|
+
compressionStatsMap()?.let { map["compression"] = it }
|
|
189
|
+
error?.let { map["error"] = it.toMap() }
|
|
190
|
+
return map
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
data class CreateUploadOptions(
|
|
195
|
+
val localUri: String,
|
|
196
|
+
val uploadUrl: String,
|
|
197
|
+
val method: String,
|
|
198
|
+
val headers: Map<String, String>,
|
|
199
|
+
val background: Boolean,
|
|
200
|
+
val networkPolicy: UploadNetworkPolicy,
|
|
201
|
+
val maxAttempts: Int,
|
|
202
|
+
val compressionEnabled: Boolean = false,
|
|
203
|
+
val compressionPreset: CompressionPreset = CompressionPreset.BALANCED,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
class UploadManagerException(
|
|
207
|
+
val errorCode: UploadErrorCode,
|
|
208
|
+
override val message: String,
|
|
209
|
+
) : Exception(message)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
package expo.modules.uploados.upload
|
|
2
|
+
|
|
3
|
+
import android.app.Notification
|
|
4
|
+
import android.app.NotificationChannel
|
|
5
|
+
import android.app.NotificationManager
|
|
6
|
+
import android.content.Context
|
|
7
|
+
import android.content.pm.ServiceInfo
|
|
8
|
+
import android.os.Build
|
|
9
|
+
import androidx.core.app.NotificationCompat
|
|
10
|
+
import androidx.work.ForegroundInfo
|
|
11
|
+
import kotlin.math.roundToInt
|
|
12
|
+
|
|
13
|
+
object UploadNotificationHelper {
|
|
14
|
+
private const val CHANNEL_ID = "uploados_uploads"
|
|
15
|
+
private const val CHANNEL_NAME = "Uploads"
|
|
16
|
+
|
|
17
|
+
fun foregroundInfo(context: Context, task: UploadTaskRecord): ForegroundInfo {
|
|
18
|
+
val notification = buildNotification(context, task)
|
|
19
|
+
val notificationId = notificationId(task.id)
|
|
20
|
+
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
21
|
+
ForegroundInfo(notificationId, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
|
22
|
+
} else {
|
|
23
|
+
ForegroundInfo(notificationId, notification)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
fun showProgress(context: Context, task: UploadTaskRecord) {
|
|
28
|
+
try {
|
|
29
|
+
notificationManager(context).notify(notificationId(task.id), buildNotification(context, task))
|
|
30
|
+
} catch (_: RuntimeException) {
|
|
31
|
+
// Notification updates are best-effort; upload state remains the source of truth.
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
fun cancel(context: Context, taskId: String) {
|
|
36
|
+
try {
|
|
37
|
+
notificationManager(context).cancel(notificationId(taskId))
|
|
38
|
+
} catch (_: RuntimeException) {
|
|
39
|
+
// Notification cleanup should not affect completed, failed, or cancelled uploads.
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private fun buildNotification(context: Context, task: UploadTaskRecord): Notification {
|
|
44
|
+
ensureChannel(context)
|
|
45
|
+
|
|
46
|
+
val percentage = task.progress.percentage.roundToInt().coerceIn(0, 100)
|
|
47
|
+
val hasTotal = task.progress.totalBytes > 0
|
|
48
|
+
val contentText = if (hasTotal) {
|
|
49
|
+
"$percentage% uploaded"
|
|
50
|
+
} else {
|
|
51
|
+
"Preparing upload"
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return NotificationCompat.Builder(context, CHANNEL_ID)
|
|
55
|
+
.setSmallIcon(notificationIcon(context))
|
|
56
|
+
.setContentTitle("Upload in progress")
|
|
57
|
+
.setContentText(contentText)
|
|
58
|
+
.setOngoing(task.state != UploadState.COMPLETED && task.state != UploadState.FAILED)
|
|
59
|
+
.setOnlyAlertOnce(true)
|
|
60
|
+
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
|
61
|
+
.setPriority(NotificationCompat.PRIORITY_LOW)
|
|
62
|
+
.setProgress(100, percentage, !hasTotal)
|
|
63
|
+
.build()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private fun ensureChannel(context: Context) {
|
|
67
|
+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
val manager = notificationManager(context)
|
|
71
|
+
if (manager.getNotificationChannel(CHANNEL_ID) != null) {
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
val channel = NotificationChannel(
|
|
75
|
+
CHANNEL_ID,
|
|
76
|
+
CHANNEL_NAME,
|
|
77
|
+
NotificationManager.IMPORTANCE_LOW,
|
|
78
|
+
)
|
|
79
|
+
channel.setShowBadge(false)
|
|
80
|
+
manager.createNotificationChannel(channel)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private fun notificationManager(context: Context): NotificationManager =
|
|
84
|
+
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
85
|
+
|
|
86
|
+
private fun notificationIcon(context: Context): Int {
|
|
87
|
+
val appIcon = context.applicationInfo.icon
|
|
88
|
+
return if (appIcon != 0) appIcon else android.R.drawable.stat_sys_upload
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private fun notificationId(taskId: String): Int =
|
|
92
|
+
taskId.hashCode().and(Int.MAX_VALUE).coerceAtLeast(1)
|
|
93
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
package expo.modules.uploados.upload
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import org.json.JSONArray
|
|
5
|
+
import org.json.JSONObject
|
|
6
|
+
import java.io.File
|
|
7
|
+
|
|
8
|
+
class UploadTaskStore private constructor(context: Context) {
|
|
9
|
+
private val fileName = "uploados-tasks.json"
|
|
10
|
+
private val lock = Any()
|
|
11
|
+
private val tasks = mutableMapOf<String, UploadTaskRecord>()
|
|
12
|
+
private val storeDir: File =
|
|
13
|
+
File(context.filesDir, "uploados").also { it.mkdirs() }
|
|
14
|
+
|
|
15
|
+
init {
|
|
16
|
+
loadFromDisk()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
fun allTasks(): List<UploadTaskRecord> = synchronized(lock) {
|
|
20
|
+
tasks.values.sortedBy { it.createdAt }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
fun task(id: String): UploadTaskRecord? = synchronized(lock) {
|
|
24
|
+
tasks[id]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
fun upsert(task: UploadTaskRecord) {
|
|
28
|
+
synchronized(lock) {
|
|
29
|
+
tasks[task.id] = task
|
|
30
|
+
persistLocked()
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
fun update(id: String, mutate: (UploadTaskRecord) -> Unit): UploadTaskRecord? {
|
|
35
|
+
synchronized(lock) {
|
|
36
|
+
val existing = tasks[id] ?: return null
|
|
37
|
+
val copy = existing.copy(
|
|
38
|
+
stagedFilePath = existing.stagedFilePath,
|
|
39
|
+
optimizedFilePath = existing.optimizedFilePath,
|
|
40
|
+
compressionPreset = existing.compressionPreset,
|
|
41
|
+
originalSize = existing.originalSize,
|
|
42
|
+
optimizedSize = existing.optimizedSize,
|
|
43
|
+
progress = existing.progress.copy(),
|
|
44
|
+
headers = existing.headers.toMap(),
|
|
45
|
+
error = existing.error,
|
|
46
|
+
)
|
|
47
|
+
mutate(copy)
|
|
48
|
+
copy.updatedAt = System.currentTimeMillis().toDouble()
|
|
49
|
+
tasks[id] = copy
|
|
50
|
+
persistLocked()
|
|
51
|
+
return copy
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
fun restorableTasks(): List<UploadTaskRecord> = synchronized(lock) {
|
|
56
|
+
tasks.values.filter { task ->
|
|
57
|
+
when (task.state) {
|
|
58
|
+
UploadState.CREATED,
|
|
59
|
+
UploadState.VALIDATING,
|
|
60
|
+
UploadState.COMPRESSING,
|
|
61
|
+
UploadState.QUEUED,
|
|
62
|
+
UploadState.CONNECTING,
|
|
63
|
+
UploadState.UPLOADING,
|
|
64
|
+
UploadState.VERIFYING,
|
|
65
|
+
UploadState.RETRYING,
|
|
66
|
+
UploadState.PAUSED -> true
|
|
67
|
+
else -> false
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private fun loadFromDisk() {
|
|
73
|
+
synchronized(lock) {
|
|
74
|
+
val file = storeFile()
|
|
75
|
+
if (!file.exists()) {
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
val json = JSONArray(file.readText())
|
|
80
|
+
tasks.clear()
|
|
81
|
+
for (i in 0 until json.length()) {
|
|
82
|
+
val record = decodeTask(json.getJSONObject(i))
|
|
83
|
+
tasks[record.id] = record
|
|
84
|
+
}
|
|
85
|
+
} catch (_: Exception) {
|
|
86
|
+
tasks.clear()
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private fun persistLocked() {
|
|
92
|
+
val file = storeFile()
|
|
93
|
+
val tmpFile = File(storeDir, "$fileName.tmp")
|
|
94
|
+
val backupFile = File(storeDir, "$fileName.bak")
|
|
95
|
+
try {
|
|
96
|
+
val array = JSONArray()
|
|
97
|
+
tasks.values.forEach { array.put(encodeTask(it)) }
|
|
98
|
+
tmpFile.writeText(array.toString())
|
|
99
|
+
if (file.exists()) {
|
|
100
|
+
file.copyTo(backupFile, overwrite = true)
|
|
101
|
+
}
|
|
102
|
+
if (file.exists() && !file.delete()) {
|
|
103
|
+
throw IllegalStateException("Could not replace upload task store.")
|
|
104
|
+
}
|
|
105
|
+
if (!tmpFile.renameTo(file)) {
|
|
106
|
+
if (backupFile.exists()) {
|
|
107
|
+
backupFile.copyTo(file, overwrite = true)
|
|
108
|
+
}
|
|
109
|
+
throw IllegalStateException("Could not persist upload task store.")
|
|
110
|
+
}
|
|
111
|
+
backupFile.delete()
|
|
112
|
+
} catch (_: Exception) {
|
|
113
|
+
tmpFile.delete()
|
|
114
|
+
// Persistence failures should not crash uploads.
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private fun storeFile(): File = File(storeDir, fileName)
|
|
119
|
+
|
|
120
|
+
companion object {
|
|
121
|
+
@Volatile
|
|
122
|
+
private var instance: UploadTaskStore? = null
|
|
123
|
+
|
|
124
|
+
fun getInstance(context: Context): UploadTaskStore {
|
|
125
|
+
return instance ?: synchronized(this) {
|
|
126
|
+
instance ?: UploadTaskStore(context.applicationContext).also { instance = it }
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private fun encodeTask(task: UploadTaskRecord): JSONObject {
|
|
133
|
+
val json = JSONObject()
|
|
134
|
+
json.put("id", task.id)
|
|
135
|
+
json.put("localUri", task.localUri)
|
|
136
|
+
task.stagedFilePath?.let { json.put("stagedFilePath", it) }
|
|
137
|
+
task.optimizedFilePath?.let { json.put("optimizedFilePath", it) }
|
|
138
|
+
json.put("compressionEnabled", task.compressionEnabled)
|
|
139
|
+
task.compressionPreset?.let { json.put("compressionPreset", it) }
|
|
140
|
+
task.originalSize?.let { json.put("originalSize", it) }
|
|
141
|
+
task.optimizedSize?.let { json.put("optimizedSize", it) }
|
|
142
|
+
json.put("uploadUrl", task.uploadUrl)
|
|
143
|
+
json.put("method", task.method)
|
|
144
|
+
json.put("state", task.state.raw)
|
|
145
|
+
json.put("background", task.background)
|
|
146
|
+
json.put("networkPolicy", task.networkPolicy.raw)
|
|
147
|
+
json.put("attempt", task.attempt)
|
|
148
|
+
json.put("maxAttempts", task.maxAttempts)
|
|
149
|
+
task.nextRetryAt?.let { json.put("nextRetryAt", it) }
|
|
150
|
+
json.put("createdAt", task.createdAt)
|
|
151
|
+
json.put("updatedAt", task.updatedAt)
|
|
152
|
+
val progress = JSONObject()
|
|
153
|
+
progress.put("bytesUploaded", task.progress.bytesUploaded)
|
|
154
|
+
progress.put("totalBytes", task.progress.totalBytes)
|
|
155
|
+
progress.put("percentage", task.progress.percentage)
|
|
156
|
+
json.put("progress", progress)
|
|
157
|
+
val headers = JSONObject()
|
|
158
|
+
task.headers.forEach { (key, value) -> headers.put(key, value) }
|
|
159
|
+
json.put("headers", headers)
|
|
160
|
+
task.error?.let { error ->
|
|
161
|
+
val errorJson = JSONObject()
|
|
162
|
+
errorJson.put("code", error.code.raw)
|
|
163
|
+
errorJson.put("message", error.message)
|
|
164
|
+
errorJson.put("retryable", error.retryable)
|
|
165
|
+
errorJson.put("taskId", error.taskId)
|
|
166
|
+
error.phase?.let { errorJson.put("phase", it) }
|
|
167
|
+
error.httpStatus?.let { errorJson.put("httpStatus", it) }
|
|
168
|
+
error.providerCode?.let { errorJson.put("providerCode", it) }
|
|
169
|
+
error.providerMessage?.let { errorJson.put("providerMessage", it) }
|
|
170
|
+
error.nativeCause?.let { errorJson.put("nativeCause", it) }
|
|
171
|
+
json.put("error", errorJson)
|
|
172
|
+
}
|
|
173
|
+
return json
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private fun decodeTask(json: JSONObject): UploadTaskRecord {
|
|
177
|
+
val progressJson = json.getJSONObject("progress")
|
|
178
|
+
val headersJson = json.optJSONObject("headers") ?: JSONObject()
|
|
179
|
+
val headers = mutableMapOf<String, String>()
|
|
180
|
+
headersJson.keys().forEach { key ->
|
|
181
|
+
headers[key] = headersJson.getString(key)
|
|
182
|
+
}
|
|
183
|
+
val error = json.optJSONObject("error")?.let { errorJson ->
|
|
184
|
+
UploadErrorPayload(
|
|
185
|
+
code = UploadErrorCode.fromRaw(errorJson.getString("code")) ?: UploadErrorCode.UNKNOWN,
|
|
186
|
+
message = errorJson.getString("message"),
|
|
187
|
+
retryable = errorJson.getBoolean("retryable"),
|
|
188
|
+
taskId = errorJson.getString("taskId"),
|
|
189
|
+
phase = errorJson.optString("phase").takeIf { it.isNotEmpty() },
|
|
190
|
+
httpStatus = if (errorJson.has("httpStatus")) errorJson.getInt("httpStatus") else null,
|
|
191
|
+
providerCode = errorJson.optString("providerCode").takeIf { it.isNotEmpty() },
|
|
192
|
+
providerMessage = errorJson.optString("providerMessage").takeIf { it.isNotEmpty() },
|
|
193
|
+
nativeCause = errorJson.optString("nativeCause").takeIf { it.isNotEmpty() },
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
return UploadTaskRecord(
|
|
197
|
+
id = json.getString("id"),
|
|
198
|
+
localUri = json.getString("localUri"),
|
|
199
|
+
stagedFilePath = json.optString("stagedFilePath").takeIf { it.isNotEmpty() },
|
|
200
|
+
optimizedFilePath = json.optString("optimizedFilePath").takeIf { it.isNotEmpty() },
|
|
201
|
+
compressionEnabled = json.optBoolean("compressionEnabled", false),
|
|
202
|
+
compressionPreset = json.optString("compressionPreset").takeIf { it.isNotEmpty() },
|
|
203
|
+
originalSize = if (json.has("originalSize")) json.getLong("originalSize") else null,
|
|
204
|
+
optimizedSize = if (json.has("optimizedSize")) json.getLong("optimizedSize") else null,
|
|
205
|
+
uploadUrl = json.getString("uploadUrl"),
|
|
206
|
+
method = json.getString("method"),
|
|
207
|
+
state = UploadState.fromRaw(json.getString("state")) ?: UploadState.FAILED,
|
|
208
|
+
progress = UploadProgress(
|
|
209
|
+
bytesUploaded = progressJson.getLong("bytesUploaded"),
|
|
210
|
+
totalBytes = progressJson.getLong("totalBytes"),
|
|
211
|
+
percentage = progressJson.getDouble("percentage"),
|
|
212
|
+
),
|
|
213
|
+
background = json.getBoolean("background"),
|
|
214
|
+
networkPolicy = UploadNetworkPolicy.fromRaw(json.optString("networkPolicy", UploadNetworkPolicy.WAIT.raw))
|
|
215
|
+
?: UploadNetworkPolicy.WAIT,
|
|
216
|
+
attempt = json.optInt("attempt", 0),
|
|
217
|
+
maxAttempts = json.optInt("maxAttempts", 3).coerceAtLeast(1),
|
|
218
|
+
nextRetryAt = if (json.has("nextRetryAt")) json.getDouble("nextRetryAt") else null,
|
|
219
|
+
createdAt = json.getDouble("createdAt"),
|
|
220
|
+
updatedAt = json.getDouble("updatedAt"),
|
|
221
|
+
headers = headers,
|
|
222
|
+
error = error,
|
|
223
|
+
)
|
|
224
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
package expo.modules.uploados.upload
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import androidx.work.CoroutineWorker
|
|
5
|
+
import androidx.work.WorkerParameters
|
|
6
|
+
|
|
7
|
+
class UploadWorker(
|
|
8
|
+
appContext: Context,
|
|
9
|
+
workerParams: WorkerParameters,
|
|
10
|
+
) : CoroutineWorker(appContext, workerParams) {
|
|
11
|
+
override suspend fun doWork(): Result {
|
|
12
|
+
val taskId = inputData.getString(UploadManager.WORK_INPUT_TASK_ID)
|
|
13
|
+
?: return Result.failure()
|
|
14
|
+
return try {
|
|
15
|
+
val store = UploadTaskStore.getInstance(applicationContext)
|
|
16
|
+
store.task(taskId)?.let { task ->
|
|
17
|
+
setForeground(UploadNotificationHelper.foregroundInfo(applicationContext, task))
|
|
18
|
+
}
|
|
19
|
+
when (UploadManager.getInstance(applicationContext).executeUpload(taskId)) {
|
|
20
|
+
UploadExecutionResult.RETRY -> Result.retry()
|
|
21
|
+
UploadExecutionResult.FINISHED -> Result.success()
|
|
22
|
+
}
|
|
23
|
+
} catch (_: UploadCancelledException) {
|
|
24
|
+
Result.failure()
|
|
25
|
+
} catch (_: Exception) {
|
|
26
|
+
Result.failure()
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
class UploadCancelledException : Exception()
|