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,305 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
enum UploadState: String, Codable {
|
|
4
|
+
case created
|
|
5
|
+
case validating
|
|
6
|
+
case compressing
|
|
7
|
+
case queued
|
|
8
|
+
case connecting
|
|
9
|
+
case uploading
|
|
10
|
+
case verifying
|
|
11
|
+
case retrying
|
|
12
|
+
case completed
|
|
13
|
+
case paused
|
|
14
|
+
case failed
|
|
15
|
+
case cancelled
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
enum UploadNetworkPolicy: String, Codable {
|
|
19
|
+
case wait
|
|
20
|
+
case failFast
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
enum UploadErrorCode: String, Codable {
|
|
24
|
+
case FILE_NOT_FOUND
|
|
25
|
+
case FILE_TOO_LARGE
|
|
26
|
+
case NETWORK_ERROR
|
|
27
|
+
case AUTH_ERROR
|
|
28
|
+
case PROVIDER_ERROR
|
|
29
|
+
case MULTIPART_FAILED
|
|
30
|
+
case COMPRESSION_FAILED
|
|
31
|
+
case BACKGROUND_RESTRICTED
|
|
32
|
+
case CANCELLED
|
|
33
|
+
case UNKNOWN
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
struct UploadErrorPayload: Codable {
|
|
37
|
+
let code: UploadErrorCode
|
|
38
|
+
let message: String
|
|
39
|
+
let retryable: Bool
|
|
40
|
+
let taskId: String
|
|
41
|
+
let phase: String?
|
|
42
|
+
let httpStatus: Int?
|
|
43
|
+
let providerCode: String?
|
|
44
|
+
let providerMessage: String?
|
|
45
|
+
let nativeCause: String?
|
|
46
|
+
|
|
47
|
+
func toDictionary() -> [String: Any] {
|
|
48
|
+
var dict: [String: Any] = [
|
|
49
|
+
"code": code.rawValue,
|
|
50
|
+
"message": message,
|
|
51
|
+
"retryable": retryable,
|
|
52
|
+
"taskId": taskId,
|
|
53
|
+
]
|
|
54
|
+
if let phase {
|
|
55
|
+
dict["phase"] = phase
|
|
56
|
+
}
|
|
57
|
+
if let httpStatus {
|
|
58
|
+
dict["httpStatus"] = httpStatus
|
|
59
|
+
}
|
|
60
|
+
if let providerCode {
|
|
61
|
+
dict["providerCode"] = providerCode
|
|
62
|
+
}
|
|
63
|
+
if let providerMessage {
|
|
64
|
+
dict["providerMessage"] = providerMessage
|
|
65
|
+
}
|
|
66
|
+
if let nativeCause {
|
|
67
|
+
dict["nativeCause"] = nativeCause
|
|
68
|
+
}
|
|
69
|
+
return dict
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
struct UploadProgress: Codable {
|
|
74
|
+
var bytesUploaded: Int
|
|
75
|
+
var totalBytes: Int
|
|
76
|
+
var percentage: Double
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
struct UploadFileMetadata: Codable {
|
|
80
|
+
let originalUri: String
|
|
81
|
+
let uploadUri: String
|
|
82
|
+
let isStaged: Bool
|
|
83
|
+
let sizeBytes: Int?
|
|
84
|
+
|
|
85
|
+
func toDictionary() -> [String: Any] {
|
|
86
|
+
var dict: [String: Any] = [
|
|
87
|
+
"originalUri": originalUri,
|
|
88
|
+
"uploadUri": uploadUri,
|
|
89
|
+
"isStaged": isStaged,
|
|
90
|
+
]
|
|
91
|
+
if let sizeBytes {
|
|
92
|
+
dict["sizeBytes"] = sizeBytes
|
|
93
|
+
}
|
|
94
|
+
return dict
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
struct UploadTaskRecord: Codable {
|
|
99
|
+
let id: String
|
|
100
|
+
let localUri: String
|
|
101
|
+
var stagedFilePath: String?
|
|
102
|
+
var optimizedFilePath: String?
|
|
103
|
+
var compressionEnabled: Bool
|
|
104
|
+
var compressionPreset: String?
|
|
105
|
+
var originalSize: Int?
|
|
106
|
+
var optimizedSize: Int?
|
|
107
|
+
let uploadUrl: String
|
|
108
|
+
let method: String
|
|
109
|
+
var state: UploadState
|
|
110
|
+
var progress: UploadProgress
|
|
111
|
+
let background: Bool
|
|
112
|
+
let networkPolicy: UploadNetworkPolicy
|
|
113
|
+
var attempt: Int
|
|
114
|
+
let maxAttempts: Int
|
|
115
|
+
var nextRetryAt: TimeInterval?
|
|
116
|
+
let createdAt: TimeInterval
|
|
117
|
+
var updatedAt: TimeInterval
|
|
118
|
+
var headers: [String: String]
|
|
119
|
+
var sessionTaskIdentifier: Int?
|
|
120
|
+
var error: UploadErrorPayload?
|
|
121
|
+
|
|
122
|
+
init(
|
|
123
|
+
id: String,
|
|
124
|
+
localUri: String,
|
|
125
|
+
stagedFilePath: String? = nil,
|
|
126
|
+
optimizedFilePath: String? = nil,
|
|
127
|
+
compressionEnabled: Bool = false,
|
|
128
|
+
compressionPreset: String? = nil,
|
|
129
|
+
originalSize: Int? = nil,
|
|
130
|
+
optimizedSize: Int? = nil,
|
|
131
|
+
uploadUrl: String,
|
|
132
|
+
method: String,
|
|
133
|
+
state: UploadState,
|
|
134
|
+
progress: UploadProgress,
|
|
135
|
+
background: Bool,
|
|
136
|
+
networkPolicy: UploadNetworkPolicy,
|
|
137
|
+
attempt: Int,
|
|
138
|
+
maxAttempts: Int,
|
|
139
|
+
nextRetryAt: TimeInterval?,
|
|
140
|
+
createdAt: TimeInterval,
|
|
141
|
+
updatedAt: TimeInterval,
|
|
142
|
+
headers: [String: String],
|
|
143
|
+
sessionTaskIdentifier: Int?,
|
|
144
|
+
error: UploadErrorPayload?
|
|
145
|
+
) {
|
|
146
|
+
self.id = id
|
|
147
|
+
self.localUri = localUri
|
|
148
|
+
self.stagedFilePath = stagedFilePath
|
|
149
|
+
self.optimizedFilePath = optimizedFilePath
|
|
150
|
+
self.compressionEnabled = compressionEnabled
|
|
151
|
+
self.compressionPreset = compressionPreset
|
|
152
|
+
self.originalSize = originalSize
|
|
153
|
+
self.optimizedSize = optimizedSize
|
|
154
|
+
self.uploadUrl = uploadUrl
|
|
155
|
+
self.method = method
|
|
156
|
+
self.state = state
|
|
157
|
+
self.progress = progress
|
|
158
|
+
self.background = background
|
|
159
|
+
self.networkPolicy = networkPolicy
|
|
160
|
+
self.attempt = attempt
|
|
161
|
+
self.maxAttempts = max(1, maxAttempts)
|
|
162
|
+
self.nextRetryAt = nextRetryAt
|
|
163
|
+
self.createdAt = createdAt
|
|
164
|
+
self.updatedAt = updatedAt
|
|
165
|
+
self.headers = headers
|
|
166
|
+
self.sessionTaskIdentifier = sessionTaskIdentifier
|
|
167
|
+
self.error = error
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
enum CodingKeys: String, CodingKey {
|
|
171
|
+
case id
|
|
172
|
+
case localUri
|
|
173
|
+
case stagedFilePath
|
|
174
|
+
case optimizedFilePath
|
|
175
|
+
case compressionEnabled
|
|
176
|
+
case compressionPreset
|
|
177
|
+
case originalSize
|
|
178
|
+
case optimizedSize
|
|
179
|
+
case uploadUrl
|
|
180
|
+
case method
|
|
181
|
+
case state
|
|
182
|
+
case progress
|
|
183
|
+
case background
|
|
184
|
+
case networkPolicy
|
|
185
|
+
case attempt
|
|
186
|
+
case maxAttempts
|
|
187
|
+
case nextRetryAt
|
|
188
|
+
case createdAt
|
|
189
|
+
case updatedAt
|
|
190
|
+
case headers
|
|
191
|
+
case sessionTaskIdentifier
|
|
192
|
+
case error
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
init(from decoder: Decoder) throws {
|
|
196
|
+
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
197
|
+
id = try container.decode(String.self, forKey: .id)
|
|
198
|
+
localUri = try container.decode(String.self, forKey: .localUri)
|
|
199
|
+
stagedFilePath = try container.decodeIfPresent(String.self, forKey: .stagedFilePath)
|
|
200
|
+
optimizedFilePath = try container.decodeIfPresent(String.self, forKey: .optimizedFilePath)
|
|
201
|
+
compressionEnabled = try container.decodeIfPresent(Bool.self, forKey: .compressionEnabled) ?? false
|
|
202
|
+
compressionPreset = try container.decodeIfPresent(String.self, forKey: .compressionPreset)
|
|
203
|
+
originalSize = try container.decodeIfPresent(Int.self, forKey: .originalSize)
|
|
204
|
+
optimizedSize = try container.decodeIfPresent(Int.self, forKey: .optimizedSize)
|
|
205
|
+
uploadUrl = try container.decode(String.self, forKey: .uploadUrl)
|
|
206
|
+
method = try container.decode(String.self, forKey: .method)
|
|
207
|
+
state = try container.decode(UploadState.self, forKey: .state)
|
|
208
|
+
progress = try container.decode(UploadProgress.self, forKey: .progress)
|
|
209
|
+
background = try container.decode(Bool.self, forKey: .background)
|
|
210
|
+
networkPolicy = try container.decodeIfPresent(UploadNetworkPolicy.self, forKey: .networkPolicy) ?? .wait
|
|
211
|
+
attempt = try container.decodeIfPresent(Int.self, forKey: .attempt) ?? 0
|
|
212
|
+
maxAttempts = max(1, try container.decodeIfPresent(Int.self, forKey: .maxAttempts) ?? 3)
|
|
213
|
+
nextRetryAt = try container.decodeIfPresent(TimeInterval.self, forKey: .nextRetryAt)
|
|
214
|
+
createdAt = try container.decode(TimeInterval.self, forKey: .createdAt)
|
|
215
|
+
updatedAt = try container.decode(TimeInterval.self, forKey: .updatedAt)
|
|
216
|
+
headers = try container.decodeIfPresent([String: String].self, forKey: .headers) ?? [:]
|
|
217
|
+
sessionTaskIdentifier = try container.decodeIfPresent(Int.self, forKey: .sessionTaskIdentifier)
|
|
218
|
+
error = try container.decodeIfPresent(UploadErrorPayload.self, forKey: .error)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
func fileMetadata() -> UploadFileMetadata {
|
|
222
|
+
let uploadPath = optimizedFilePath ?? stagedFilePath
|
|
223
|
+
let uploadUri: String
|
|
224
|
+
if let uploadPath {
|
|
225
|
+
uploadUri = URL(fileURLWithPath: uploadPath).absoluteString
|
|
226
|
+
} else if localUri.hasPrefix("file://") {
|
|
227
|
+
uploadUri = localUri
|
|
228
|
+
} else {
|
|
229
|
+
uploadUri = URL(fileURLWithPath: localUri).absoluteString
|
|
230
|
+
}
|
|
231
|
+
let sizeBytes = optimizedSize ?? originalSize ?? (progress.totalBytes > 0 ? progress.totalBytes : nil)
|
|
232
|
+
return UploadFileMetadata(
|
|
233
|
+
originalUri: localUri,
|
|
234
|
+
uploadUri: uploadUri,
|
|
235
|
+
isStaged: stagedFilePath != nil || optimizedFilePath != nil,
|
|
236
|
+
sizeBytes: sizeBytes
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
func compressionStatsDictionary() -> [String: Any]? {
|
|
241
|
+
guard compressionEnabled,
|
|
242
|
+
let preset = compressionPreset,
|
|
243
|
+
let originalSize,
|
|
244
|
+
let optimizedSize else {
|
|
245
|
+
return nil
|
|
246
|
+
}
|
|
247
|
+
return [
|
|
248
|
+
"preset": preset,
|
|
249
|
+
"originalSize": originalSize,
|
|
250
|
+
"optimizedSize": optimizedSize,
|
|
251
|
+
"format": "jpeg",
|
|
252
|
+
]
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
func toDictionary() -> [String: Any] {
|
|
256
|
+
let file = fileMetadata()
|
|
257
|
+
var dict: [String: Any] = [
|
|
258
|
+
"id": id,
|
|
259
|
+
"localUri": localUri,
|
|
260
|
+
"uploadUrl": uploadUrl,
|
|
261
|
+
"file": file.toDictionary(),
|
|
262
|
+
"method": method,
|
|
263
|
+
"state": state.rawValue,
|
|
264
|
+
"progress": [
|
|
265
|
+
"bytesUploaded": progress.bytesUploaded,
|
|
266
|
+
"totalBytes": progress.totalBytes,
|
|
267
|
+
"percentage": progress.percentage,
|
|
268
|
+
],
|
|
269
|
+
"background": background,
|
|
270
|
+
"networkPolicy": networkPolicy.rawValue,
|
|
271
|
+
"attempt": attempt,
|
|
272
|
+
"maxAttempts": maxAttempts,
|
|
273
|
+
"createdAt": createdAt,
|
|
274
|
+
"updatedAt": updatedAt,
|
|
275
|
+
]
|
|
276
|
+
if let nextRetryAt {
|
|
277
|
+
dict["nextRetryAt"] = nextRetryAt
|
|
278
|
+
}
|
|
279
|
+
if let originalSize {
|
|
280
|
+
dict["originalSize"] = originalSize
|
|
281
|
+
}
|
|
282
|
+
if let optimizedSize {
|
|
283
|
+
dict["optimizedSize"] = optimizedSize
|
|
284
|
+
}
|
|
285
|
+
if let compression = compressionStatsDictionary() {
|
|
286
|
+
dict["compression"] = compression
|
|
287
|
+
}
|
|
288
|
+
if let error {
|
|
289
|
+
dict["error"] = error.toDictionary()
|
|
290
|
+
}
|
|
291
|
+
return dict
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
struct CreateUploadOptions {
|
|
296
|
+
let localUri: String
|
|
297
|
+
let uploadUrl: String
|
|
298
|
+
let method: String
|
|
299
|
+
let headers: [String: String]
|
|
300
|
+
let background: Bool
|
|
301
|
+
let networkPolicy: UploadNetworkPolicy
|
|
302
|
+
let maxAttempts: Int
|
|
303
|
+
let compressionEnabled: Bool
|
|
304
|
+
let compressionPreset: CompressionPreset
|
|
305
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
final class UploadSessionDelegate: NSObject, URLSessionDelegate, URLSessionTaskDelegate, URLSessionDataDelegate {
|
|
4
|
+
weak var manager: UploadManager?
|
|
5
|
+
private let responseDataLock = NSLock()
|
|
6
|
+
private var responseDataByTaskId: [String: Data] = [:]
|
|
7
|
+
private let maxResponseBodyBytes = 16 * 1024
|
|
8
|
+
|
|
9
|
+
func urlSession(
|
|
10
|
+
_ session: URLSession,
|
|
11
|
+
task: URLSessionTask,
|
|
12
|
+
didSendBodyData bytesSent: Int64,
|
|
13
|
+
totalBytesSent: Int64,
|
|
14
|
+
totalBytesExpectedToSend: Int64
|
|
15
|
+
) {
|
|
16
|
+
guard let taskId = task.taskDescription else {
|
|
17
|
+
return
|
|
18
|
+
}
|
|
19
|
+
let totalBytes: Int
|
|
20
|
+
if totalBytesExpectedToSend > 0 {
|
|
21
|
+
totalBytes = Int(totalBytesExpectedToSend)
|
|
22
|
+
} else if let storedTotal = UploadTaskStore.shared.task(id: taskId)?.progress.totalBytes,
|
|
23
|
+
storedTotal > 0 {
|
|
24
|
+
totalBytes = storedTotal
|
|
25
|
+
} else {
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
manager?.handleProgress(
|
|
29
|
+
taskId: taskId,
|
|
30
|
+
bytesUploaded: Int(totalBytesSent),
|
|
31
|
+
totalBytes: totalBytes
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
|
36
|
+
guard let taskId = task.taskDescription else {
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
if let error {
|
|
40
|
+
manager?.handleFailure(taskId: taskId, error: error, response: task.response)
|
|
41
|
+
_ = takeResponseBody(taskId: taskId)
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
if let http = task.response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
|
|
45
|
+
manager?.handleHttpFailure(
|
|
46
|
+
taskId: taskId,
|
|
47
|
+
statusCode: http.statusCode,
|
|
48
|
+
responseBody: takeResponseBody(taskId: taskId)
|
|
49
|
+
)
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
_ = takeResponseBody(taskId: taskId)
|
|
53
|
+
manager?.handleCompletion(taskId: taskId)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
|
|
57
|
+
guard let taskId = dataTask.taskDescription else {
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
responseDataLock.lock()
|
|
61
|
+
var existing = responseDataByTaskId[taskId] ?? Data()
|
|
62
|
+
if existing.count < maxResponseBodyBytes {
|
|
63
|
+
existing.append(data.prefix(maxResponseBodyBytes - existing.count))
|
|
64
|
+
responseDataByTaskId[taskId] = existing
|
|
65
|
+
}
|
|
66
|
+
responseDataLock.unlock()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
|
|
70
|
+
UploadManager.shared.invokeBackgroundCompletionHandler(for: session.configuration.identifier)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private func takeResponseBody(taskId: String) -> String? {
|
|
74
|
+
responseDataLock.lock()
|
|
75
|
+
let data = responseDataByTaskId.removeValue(forKey: taskId)
|
|
76
|
+
responseDataLock.unlock()
|
|
77
|
+
guard let data, !data.isEmpty else {
|
|
78
|
+
return nil
|
|
79
|
+
}
|
|
80
|
+
return String(data: data, encoding: .utf8)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
final class UploadTaskStore {
|
|
4
|
+
static let shared = UploadTaskStore()
|
|
5
|
+
|
|
6
|
+
private let fileName = "uploados-tasks.json"
|
|
7
|
+
private let queue = DispatchQueue(label: "expo.modules.uploados.store")
|
|
8
|
+
private var tasks: [String: UploadTaskRecord] = [:]
|
|
9
|
+
|
|
10
|
+
private init() {
|
|
11
|
+
loadFromDisk()
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
func allTasks() -> [UploadTaskRecord] {
|
|
15
|
+
queue.sync {
|
|
16
|
+
Array(tasks.values).sorted { $0.createdAt < $1.createdAt }
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
func task(id: String) -> UploadTaskRecord? {
|
|
21
|
+
queue.sync { tasks[id] }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
func upsert(_ task: UploadTaskRecord) {
|
|
25
|
+
queue.sync {
|
|
26
|
+
tasks[task.id] = task
|
|
27
|
+
persistLocked()
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
func update(id: String, mutate: (inout UploadTaskRecord) -> Void) -> UploadTaskRecord? {
|
|
32
|
+
queue.sync {
|
|
33
|
+
guard var task = tasks[id] else {
|
|
34
|
+
return nil
|
|
35
|
+
}
|
|
36
|
+
mutate(&task)
|
|
37
|
+
task.updatedAt = Date().timeIntervalSince1970 * 1000
|
|
38
|
+
tasks[id] = task
|
|
39
|
+
persistLocked()
|
|
40
|
+
return task
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
func restorableTasks() -> [UploadTaskRecord] {
|
|
45
|
+
queue.sync {
|
|
46
|
+
tasks.values.filter { task in
|
|
47
|
+
switch task.state {
|
|
48
|
+
case .created, .validating, .compressing, .queued, .connecting, .uploading, .verifying, .retrying, .paused:
|
|
49
|
+
return true
|
|
50
|
+
default:
|
|
51
|
+
return false
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private func loadFromDisk() {
|
|
58
|
+
queue.sync {
|
|
59
|
+
guard let url = storeURL(),
|
|
60
|
+
FileManager.default.fileExists(atPath: url.path) else {
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
do {
|
|
64
|
+
let data = try Data(contentsOf: url)
|
|
65
|
+
let decoded = try JSONDecoder().decode([UploadTaskRecord].self, from: data)
|
|
66
|
+
tasks = Dictionary(uniqueKeysWithValues: decoded.map { ($0.id, $0) })
|
|
67
|
+
} catch {
|
|
68
|
+
tasks = [:]
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private func persistLocked() {
|
|
74
|
+
guard let url = storeURL() else {
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
do {
|
|
78
|
+
let directory = url.deletingLastPathComponent()
|
|
79
|
+
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
80
|
+
let data = try JSONEncoder().encode(Array(tasks.values))
|
|
81
|
+
try data.write(to: url, options: .atomic)
|
|
82
|
+
} catch {
|
|
83
|
+
// Persistence failures should not crash uploads; state may be lost on restart.
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private func storeURL() -> URL? {
|
|
88
|
+
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first?
|
|
89
|
+
.appendingPathComponent("uploados", isDirectory: true)
|
|
90
|
+
.appendingPathComponent(fileName)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
|
|
3
|
+
public class UploadosAppDelegate: ExpoAppDelegateSubscriber {
|
|
4
|
+
public func application(
|
|
5
|
+
_ application: UIApplication,
|
|
6
|
+
handleEventsForBackgroundURLSession identifier: String,
|
|
7
|
+
completionHandler: @escaping () -> Void
|
|
8
|
+
) {
|
|
9
|
+
UploadManager.shared.registerBackgroundCompletionHandler(
|
|
10
|
+
for: identifier,
|
|
11
|
+
completionHandler: completionHandler
|
|
12
|
+
)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Pod::Spec.new do |s|
|
|
2
|
+
s.name = 'Uploados'
|
|
3
|
+
s.version = '0.1.0'
|
|
4
|
+
s.summary = 'Native signed-URL upload engine for Expo'
|
|
5
|
+
s.description = 'iOS URLSession-based signed-URL upload engine with staging, persistence, and JPEG compression'
|
|
6
|
+
s.author = 'bessim boujebli'
|
|
7
|
+
s.homepage = 'https://github.com/bessim-dev/uploados'
|
|
8
|
+
s.platforms = {
|
|
9
|
+
:ios => '15.1',
|
|
10
|
+
:tvos => '15.1'
|
|
11
|
+
}
|
|
12
|
+
s.source = { git: 'https://github.com/bessim-dev/uploados.git' }
|
|
13
|
+
s.static_framework = true
|
|
14
|
+
|
|
15
|
+
s.dependency 'ExpoModulesCore'
|
|
16
|
+
|
|
17
|
+
# Swift/Objective-C compatibility
|
|
18
|
+
s.pod_target_xcconfig = {
|
|
19
|
+
'DEFINES_MODULE' => 'YES',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
|
|
23
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
|
|
3
|
+
public class UploadosModule: Module {
|
|
4
|
+
public func definition() -> ModuleDefinition {
|
|
5
|
+
Name("Uploados")
|
|
6
|
+
|
|
7
|
+
Events("onUploadEvent")
|
|
8
|
+
|
|
9
|
+
OnCreate {
|
|
10
|
+
UploadManager.shared.setEventEmitter { [weak self] payload in
|
|
11
|
+
self?.sendEvent("onUploadEvent", payload)
|
|
12
|
+
}
|
|
13
|
+
_ = UploadManager.shared.restoreQueue()
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
AsyncFunction("createUploadAsync") { (options: [String: Any]) -> [String: Any] in
|
|
17
|
+
let parsed = try Self.parseCreateOptions(options)
|
|
18
|
+
let task = try UploadManager.shared.createUpload(options: parsed)
|
|
19
|
+
return task.toDictionary()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
AsyncFunction("cancelUploadAsync") { (taskId: String) -> [String: Any] in
|
|
23
|
+
let task = try UploadManager.shared.cancelUpload(taskId: taskId)
|
|
24
|
+
return task.toDictionary()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
AsyncFunction("retryUploadAsync") { (taskId: String) -> [String: Any] in
|
|
28
|
+
let task = try UploadManager.shared.retryUpload(taskId: taskId)
|
|
29
|
+
return task.toDictionary()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
AsyncFunction("getTaskAsync") { (taskId: String) -> [String: Any]? in
|
|
33
|
+
UploadTaskStore.shared.task(id: taskId)?.toDictionary()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
AsyncFunction("getAllTasksAsync") { () -> [[String: Any]] in
|
|
37
|
+
UploadTaskStore.shared.allTasks().map { $0.toDictionary() }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
AsyncFunction("restoreQueueAsync") { () -> [[String: Any]] in
|
|
41
|
+
UploadManager.shared.restoreQueue().map { $0.toDictionary() }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private static func parseCreateOptions(_ options: [String: Any]) throws -> CreateUploadOptions {
|
|
46
|
+
guard let localUri = options["localUri"] as? String else {
|
|
47
|
+
throw InvalidOptionsException("localUri")
|
|
48
|
+
}
|
|
49
|
+
guard let uploadUrl = options["uploadUrl"] as? String else {
|
|
50
|
+
throw InvalidOptionsException("uploadUrl")
|
|
51
|
+
}
|
|
52
|
+
let method = (options["method"] as? String)?.uppercased() ?? "PUT"
|
|
53
|
+
guard method == "PUT" || method == "POST" else {
|
|
54
|
+
throw InvalidOptionsException("method")
|
|
55
|
+
}
|
|
56
|
+
let networkPolicyRaw = options["networkPolicy"] as? String ?? "wait"
|
|
57
|
+
guard let networkPolicy = UploadNetworkPolicy(rawValue: networkPolicyRaw) else {
|
|
58
|
+
throw InvalidOptionsException("networkPolicy")
|
|
59
|
+
}
|
|
60
|
+
let retry = options["retry"] as? [String: Any]
|
|
61
|
+
let maxAttemptsValue = retry?["maxAttempts"] as? NSNumber
|
|
62
|
+
let maxAttempts = max(1, maxAttemptsValue?.intValue ?? 3)
|
|
63
|
+
let headers = options["headers"] as? [String: String] ?? [:]
|
|
64
|
+
let background = options["background"] as? Bool ?? false
|
|
65
|
+
let compression = options["compression"] as? [String: Any]
|
|
66
|
+
let compressionEnabled = compression?["enabled"] as? Bool ?? false
|
|
67
|
+
let compressionPresetRaw = compression?["preset"] as? String ?? CompressionPreset.balanced.rawValue
|
|
68
|
+
let compressionPreset = CompressionPreset(raw: compressionPresetRaw)
|
|
69
|
+
return CreateUploadOptions(
|
|
70
|
+
localUri: localUri,
|
|
71
|
+
uploadUrl: uploadUrl,
|
|
72
|
+
method: method,
|
|
73
|
+
headers: headers,
|
|
74
|
+
background: background,
|
|
75
|
+
networkPolicy: background ? .wait : networkPolicy,
|
|
76
|
+
maxAttempts: maxAttempts,
|
|
77
|
+
compressionEnabled: compressionEnabled,
|
|
78
|
+
compressionPreset: compressionPreset
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
internal final class InvalidOptionsException: GenericException<String>, @unchecked Sendable {
|
|
84
|
+
override var reason: String {
|
|
85
|
+
"Missing or invalid upload option: \(param)"
|
|
86
|
+
}
|
|
87
|
+
}
|
package/jest.config.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** @type {import('jest').Config} */
|
|
2
|
+
module.exports = {
|
|
3
|
+
testEnvironment: 'node',
|
|
4
|
+
roots: ['<rootDir>/src'],
|
|
5
|
+
testMatch: ['**/__tests__/**/*.test.ts'],
|
|
6
|
+
transform: {
|
|
7
|
+
'^.+\\.(ts|tsx)$': [
|
|
8
|
+
'babel-jest',
|
|
9
|
+
{
|
|
10
|
+
presets: ['babel-preset-expo'],
|
|
11
|
+
},
|
|
12
|
+
],
|
|
13
|
+
},
|
|
14
|
+
modulePathIgnorePatterns: ['<rootDir>/build/'],
|
|
15
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "uploados",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Native signed-URL upload engine for Expo apps",
|
|
5
|
+
"main": "build/index.js",
|
|
6
|
+
"types": "build/index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "node internal/module_scripts/build.js",
|
|
9
|
+
"clean": "node internal/module_scripts/clean.js",
|
|
10
|
+
"lint": "eslint src/",
|
|
11
|
+
"test": "node internal/module_scripts/test.js",
|
|
12
|
+
"prepare": "node internal/module_scripts/prepare.js",
|
|
13
|
+
"open:ios": "node internal/module_scripts/open-ios.js",
|
|
14
|
+
"open:android": "node internal/module_scripts/open-android.js"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"react-native",
|
|
18
|
+
"expo",
|
|
19
|
+
"uploads",
|
|
20
|
+
"presigned-url",
|
|
21
|
+
"background-uploads",
|
|
22
|
+
"image-compression",
|
|
23
|
+
"uploados",
|
|
24
|
+
"Uploados"
|
|
25
|
+
],
|
|
26
|
+
"repository": "https://github.com/bessim-dev/uploados",
|
|
27
|
+
"bugs": {
|
|
28
|
+
"url": "https://github.com/bessim-dev/uploados/issues"
|
|
29
|
+
},
|
|
30
|
+
"author": "bessim boujebli <bessim.boujebli@gmail.com> (bessim-dev)",
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"homepage": "https://github.com/bessim-dev/uploados#readme",
|
|
33
|
+
"dependencies": {},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@babel/core": "^7.26.0",
|
|
36
|
+
"@babel/runtime": "^7.29.7",
|
|
37
|
+
"@types/jest": "^29.2.1",
|
|
38
|
+
"@types/react": "~19.2.14",
|
|
39
|
+
"babel-preset-expo": "~56.0.0",
|
|
40
|
+
"eslint": "~9.39.4",
|
|
41
|
+
"eslint-config-universe": "^15.0.3",
|
|
42
|
+
"expo": "^56.0.5",
|
|
43
|
+
"jest": "^29.7.0",
|
|
44
|
+
"jest-expo": "~56.0.4",
|
|
45
|
+
"prettier": "^3.0.0",
|
|
46
|
+
"react-native": "0.85.3",
|
|
47
|
+
"typescript": "~6.0.3"
|
|
48
|
+
},
|
|
49
|
+
"peerDependencies": {
|
|
50
|
+
"expo": ">=52.0.0 <57.0.0",
|
|
51
|
+
"react": ">=18.3.1 <20.0.0",
|
|
52
|
+
"react-native": ">=0.76.0 <0.86.0"
|
|
53
|
+
}
|
|
54
|
+
}
|