@ammarahmed/react-native-upload 6.32.0 → 7.0.0-alpha.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/.eslintignore +1 -2
- package/README.md +166 -1
- package/android/build.gradle +1 -1
- package/android/src/main/java/com/appfolio/uploader/MultipartCompletionService.kt +33 -0
- package/android/src/main/java/com/appfolio/uploader/MultipartUploadService.kt +200 -0
- package/android/src/main/java/com/appfolio/uploader/MultipartUploader.kt +787 -0
- package/android/src/main/java/com/appfolio/uploader/UploaderModule.kt +76 -2
- package/dist/index.d.ts +161 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +318 -9
- package/ios/RNFileUploader.m +80 -24
- package/ios/RNMultipartUploader.h +28 -0
- package/ios/RNMultipartUploader.m +748 -0
- package/package.json +3 -3
- package/src/index.ts +404 -2
|
@@ -0,0 +1,787 @@
|
|
|
1
|
+
package com.appfolio.uploader
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.util.Log
|
|
5
|
+
import com.facebook.react.bridge.Arguments
|
|
6
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
7
|
+
import com.facebook.react.bridge.ReadableMap
|
|
8
|
+
import com.facebook.react.bridge.ReadableType
|
|
9
|
+
import com.facebook.react.bridge.UiThreadUtil
|
|
10
|
+
import com.facebook.react.bridge.WritableArray
|
|
11
|
+
import com.facebook.react.bridge.WritableMap
|
|
12
|
+
import com.facebook.react.jstasks.HeadlessJsTaskConfig
|
|
13
|
+
import com.facebook.react.jstasks.HeadlessJsTaskContext
|
|
14
|
+
import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter
|
|
15
|
+
import kotlinx.coroutines.CancellationException
|
|
16
|
+
import kotlinx.coroutines.CompletableDeferred
|
|
17
|
+
import kotlinx.coroutines.CoroutineScope
|
|
18
|
+
import kotlinx.coroutines.CoroutineStart
|
|
19
|
+
import kotlinx.coroutines.Dispatchers
|
|
20
|
+
import kotlinx.coroutines.Job
|
|
21
|
+
import kotlinx.coroutines.SupervisorJob
|
|
22
|
+
import kotlinx.coroutines.async
|
|
23
|
+
import kotlinx.coroutines.awaitAll
|
|
24
|
+
import kotlinx.coroutines.coroutineScope
|
|
25
|
+
import kotlinx.coroutines.delay
|
|
26
|
+
import kotlinx.coroutines.isActive
|
|
27
|
+
import kotlinx.coroutines.launch
|
|
28
|
+
import kotlinx.coroutines.sync.Semaphore
|
|
29
|
+
import kotlinx.coroutines.sync.withPermit
|
|
30
|
+
import kotlinx.coroutines.withContext
|
|
31
|
+
import okhttp3.Call
|
|
32
|
+
import okhttp3.MediaType
|
|
33
|
+
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
|
34
|
+
import okhttp3.OkHttpClient
|
|
35
|
+
import okhttp3.Request
|
|
36
|
+
import okhttp3.RequestBody
|
|
37
|
+
import okio.BufferedSink
|
|
38
|
+
import org.json.JSONArray
|
|
39
|
+
import org.json.JSONObject
|
|
40
|
+
import java.io.File
|
|
41
|
+
import java.io.IOException
|
|
42
|
+
import java.io.RandomAccessFile
|
|
43
|
+
import java.util.concurrent.ConcurrentHashMap
|
|
44
|
+
import java.util.concurrent.TimeUnit
|
|
45
|
+
import java.util.concurrent.atomic.AtomicBoolean
|
|
46
|
+
import java.util.concurrent.atomic.AtomicLong
|
|
47
|
+
|
|
48
|
+
class UploadAlreadyExistsException(message: String) : Exception(message)
|
|
49
|
+
|
|
50
|
+
data class UploadParams(
|
|
51
|
+
val uploadId: String,
|
|
52
|
+
val path: String,
|
|
53
|
+
val parts: List<String>,
|
|
54
|
+
val maxParallel: Int,
|
|
55
|
+
val notificationEnabled: Boolean,
|
|
56
|
+
val title: String,
|
|
57
|
+
val startMessage: String,
|
|
58
|
+
val resumeMessage: String,
|
|
59
|
+
val progressMessage: String,
|
|
60
|
+
val channelName: String,
|
|
61
|
+
val existingEtags: List<String>,
|
|
62
|
+
val resumable: Boolean = true
|
|
63
|
+
) {
|
|
64
|
+
fun toJson(): JSONObject = JSONObject().apply {
|
|
65
|
+
put("uploadId", uploadId)
|
|
66
|
+
put("path", path)
|
|
67
|
+
put("parts", JSONArray(parts))
|
|
68
|
+
put("maxParallel", maxParallel)
|
|
69
|
+
put("notificationEnabled", notificationEnabled)
|
|
70
|
+
put("title", title)
|
|
71
|
+
put("startMessage", startMessage)
|
|
72
|
+
put("resumeMessage", resumeMessage)
|
|
73
|
+
put("progressMessage", progressMessage)
|
|
74
|
+
put("channelName", channelName)
|
|
75
|
+
put("resumable", resumable)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
companion object {
|
|
79
|
+
fun fromJson(json: JSONObject, existingEtags: List<String>): UploadParams {
|
|
80
|
+
val partsJson = json.getJSONArray("parts")
|
|
81
|
+
return UploadParams(
|
|
82
|
+
uploadId = json.getString("uploadId"),
|
|
83
|
+
path = json.getString("path"),
|
|
84
|
+
parts = List(partsJson.length()) { partsJson.getString(it) },
|
|
85
|
+
maxParallel = json.getInt("maxParallel"),
|
|
86
|
+
notificationEnabled = json.getBoolean("notificationEnabled"),
|
|
87
|
+
title = json.getString("title"),
|
|
88
|
+
startMessage = json.getString("startMessage"),
|
|
89
|
+
resumeMessage = json.getString("resumeMessage"),
|
|
90
|
+
progressMessage = json.getString("progressMessage"),
|
|
91
|
+
channelName = json.getString("channelName"),
|
|
92
|
+
existingEtags = existingEtags,
|
|
93
|
+
resumable = json.optBoolean("resumable", true)
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
fun fromReadableMap(options: ReadableMap): UploadParams {
|
|
98
|
+
val uploadId = options.getString("uploadId")
|
|
99
|
+
?: throw IllegalArgumentException("uploadId is required")
|
|
100
|
+
val rawPath = options.getString("path")
|
|
101
|
+
?: throw IllegalArgumentException("path is required")
|
|
102
|
+
val path = if (rawPath.startsWith("file://")) rawPath.substring(7) else rawPath
|
|
103
|
+
|
|
104
|
+
val partsArray = options.getArray("parts")
|
|
105
|
+
?: throw IllegalArgumentException("parts is required")
|
|
106
|
+
if (partsArray.size() == 0) {
|
|
107
|
+
throw IllegalArgumentException("parts array cannot be empty")
|
|
108
|
+
}
|
|
109
|
+
val parts = List(partsArray.size()) { i -> partsArray.getString(i)!! }
|
|
110
|
+
|
|
111
|
+
val maxParallel = if (options.hasKey("maxParallel") && options.getType("maxParallel") == ReadableType.Number) {
|
|
112
|
+
options.getInt("maxParallel")
|
|
113
|
+
} else {
|
|
114
|
+
3
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
val notification = if (options.hasKey("notification") && options.getType("notification") == ReadableType.Map) {
|
|
118
|
+
options.getMap("notification")
|
|
119
|
+
} else null
|
|
120
|
+
|
|
121
|
+
fun text(key: String, default: String): String =
|
|
122
|
+
if (notification != null && notification.hasKey(key) && notification.getType(key) == ReadableType.String) {
|
|
123
|
+
notification.getString(key) ?: default
|
|
124
|
+
} else default
|
|
125
|
+
|
|
126
|
+
val enabled = notification?.hasKey("enabled") == true && notification.getBoolean("enabled")
|
|
127
|
+
val filename = text("filename", File(path).name)
|
|
128
|
+
|
|
129
|
+
val existing = if (options.hasKey("existingEtags") && options.getType("existingEtags") == ReadableType.Array) {
|
|
130
|
+
val array = options.getArray("existingEtags")!!
|
|
131
|
+
List(array.size()) { i -> array.getString(i) ?: "" }
|
|
132
|
+
} else emptyList()
|
|
133
|
+
|
|
134
|
+
return UploadParams(
|
|
135
|
+
uploadId = uploadId,
|
|
136
|
+
path = path,
|
|
137
|
+
parts = parts,
|
|
138
|
+
maxParallel = maxParallel,
|
|
139
|
+
notificationEnabled = enabled,
|
|
140
|
+
title = text("onProgressTitle", filename),
|
|
141
|
+
startMessage = text("onStartMessage", "Starting upload..."),
|
|
142
|
+
resumeMessage = text("onResumeMessage", "Resuming upload..."),
|
|
143
|
+
progressMessage = text("onProgressMessage", "Uploading... {progress}%"),
|
|
144
|
+
channelName = text("notificationChannelName", "Background Upload Channel"),
|
|
145
|
+
existingEtags = existing,
|
|
146
|
+
resumable = !(options.hasKey("resumable") && options.getType("resumable") == ReadableType.Boolean && !options.getBoolean("resumable"))
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
class MultipartUploader private constructor(private val appContext: Context) {
|
|
153
|
+
companion object {
|
|
154
|
+
private const val TAG = "MultipartUploader"
|
|
155
|
+
private const val MIN_CHUNK_SIZE = 5L * 1024 * 1024
|
|
156
|
+
private const val MAX_RETRIES = 3
|
|
157
|
+
private const val ACK_GRACE_MS = 30_000L
|
|
158
|
+
private const val HEADLESS_ACK_GRACE_MS = 120_000L
|
|
159
|
+
private const val KEEP_ALIVE_TASK = "RNMultipartUploadKeepAlive"
|
|
160
|
+
private const val STATE_FILE = "rn-multipart-uploads.json"
|
|
161
|
+
|
|
162
|
+
const val STATUS_RUNNING = "running"
|
|
163
|
+
const val STATUS_COMPLETED = "completed"
|
|
164
|
+
const val STATUS_FAILED = "failed"
|
|
165
|
+
|
|
166
|
+
@Volatile
|
|
167
|
+
private var instance: MultipartUploader? = null
|
|
168
|
+
|
|
169
|
+
fun getInstance(context: Context): MultipartUploader =
|
|
170
|
+
instance ?: synchronized(this) {
|
|
171
|
+
instance ?: MultipartUploader(context.applicationContext).also { instance = it }
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private class HttpStatusException(val code: Int, message: String) : Exception(message) {
|
|
176
|
+
val isRetryable: Boolean
|
|
177
|
+
get() = code >= 500 || code == 408 || code == 429
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private class FileRegionBody(
|
|
181
|
+
private val file: File,
|
|
182
|
+
private val offset: Long,
|
|
183
|
+
private val size: Long,
|
|
184
|
+
private val mediaType: MediaType?
|
|
185
|
+
) : RequestBody() {
|
|
186
|
+
override fun contentType(): MediaType? = mediaType
|
|
187
|
+
|
|
188
|
+
override fun contentLength(): Long = size
|
|
189
|
+
|
|
190
|
+
override fun writeTo(sink: BufferedSink) {
|
|
191
|
+
RandomAccessFile(file, "r").use { raf ->
|
|
192
|
+
if (offset + size > raf.length()) {
|
|
193
|
+
throw IllegalArgumentException("Invalid chunk bounds: offset=$offset, size=$size, file=${raf.length()}")
|
|
194
|
+
}
|
|
195
|
+
raf.seek(offset)
|
|
196
|
+
val buffer = ByteArray(64 * 1024)
|
|
197
|
+
var remaining = size
|
|
198
|
+
while (remaining > 0) {
|
|
199
|
+
val read = raf.read(buffer, 0, Math.min(buffer.size.toLong(), remaining).toInt())
|
|
200
|
+
if (read < 0) {
|
|
201
|
+
throw IOException("Unexpected end of file while reading chunk")
|
|
202
|
+
}
|
|
203
|
+
sink.write(buffer, 0, read)
|
|
204
|
+
remaining -= read
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private data class PartInfo(
|
|
211
|
+
val partNumber: Int,
|
|
212
|
+
val url: String,
|
|
213
|
+
val offset: Long,
|
|
214
|
+
val size: Long
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
private class Record(
|
|
218
|
+
val params: UploadParams,
|
|
219
|
+
val totalBytes: Long,
|
|
220
|
+
val chunkSize: Long,
|
|
221
|
+
@Volatile var etags: Array<String>,
|
|
222
|
+
@Volatile var status: String,
|
|
223
|
+
@Volatile var error: String?
|
|
224
|
+
) {
|
|
225
|
+
fun sizeOf(index: Int): Long {
|
|
226
|
+
val offset = index * chunkSize
|
|
227
|
+
return if (index == params.parts.size - 1) totalBytes - offset else chunkSize
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
fun bytesUploaded(): Long {
|
|
231
|
+
var bytes = 0L
|
|
232
|
+
for (i in etags.indices) {
|
|
233
|
+
if (etags[i].isNotEmpty()) bytes += sizeOf(i)
|
|
234
|
+
}
|
|
235
|
+
return bytes
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private class UploadJob(
|
|
240
|
+
val uploadId: String,
|
|
241
|
+
val totalBytes: Long,
|
|
242
|
+
val partsCount: Int,
|
|
243
|
+
val bytesUploaded: AtomicLong,
|
|
244
|
+
val completedChunks: AtomicLong,
|
|
245
|
+
val partETags: ConcurrentHashMap<Int, String>,
|
|
246
|
+
val isPaused: AtomicBoolean = AtomicBoolean(false),
|
|
247
|
+
val activeCalls: ConcurrentHashMap<Int, Call> = ConcurrentHashMap()
|
|
248
|
+
) {
|
|
249
|
+
@Volatile
|
|
250
|
+
var job: Job? = null
|
|
251
|
+
|
|
252
|
+
@Volatile
|
|
253
|
+
var cancelledByUser = false
|
|
254
|
+
|
|
255
|
+
fun cancel() {
|
|
256
|
+
activeCalls.values.forEach { call ->
|
|
257
|
+
try { call.cancel() } catch (_: Exception) {}
|
|
258
|
+
}
|
|
259
|
+
job?.cancel()
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private val engineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
|
264
|
+
private val ackScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
|
265
|
+
private val records = ConcurrentHashMap<String, Record>()
|
|
266
|
+
private val activeUploads = ConcurrentHashMap<String, UploadJob>()
|
|
267
|
+
private val awaitingAck = ConcurrentHashMap<String, Job>()
|
|
268
|
+
private val stateLock = Any()
|
|
269
|
+
private var restored = false
|
|
270
|
+
|
|
271
|
+
@Volatile
|
|
272
|
+
private var reactContext: ReactApplicationContext? = null
|
|
273
|
+
private val keepAliveLock = Any()
|
|
274
|
+
private var keepAliveTaskId: Int? = null
|
|
275
|
+
|
|
276
|
+
private val httpClient: OkHttpClient = OkHttpClient.Builder()
|
|
277
|
+
.connectTimeout(60, TimeUnit.SECONDS)
|
|
278
|
+
.writeTimeout(60, TimeUnit.SECONDS)
|
|
279
|
+
.readTimeout(60, TimeUnit.SECONDS)
|
|
280
|
+
.retryOnConnectionFailure(true)
|
|
281
|
+
.build()
|
|
282
|
+
|
|
283
|
+
fun attachReactContext(context: ReactApplicationContext) {
|
|
284
|
+
reactContext = context
|
|
285
|
+
restore()
|
|
286
|
+
engineScope.launch { resumeInterrupted() }
|
|
287
|
+
if (activeUploads.isNotEmpty() || awaitingAck.isNotEmpty()) {
|
|
288
|
+
acquireJsKeepAlive()
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
fun detachReactContext(context: ReactApplicationContext) {
|
|
293
|
+
synchronized(keepAliveLock) {
|
|
294
|
+
keepAliveTaskId = null
|
|
295
|
+
}
|
|
296
|
+
if (reactContext === context) {
|
|
297
|
+
reactContext = null
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private fun isJsAlive(): Boolean = reactContext?.hasActiveReactInstance() == true
|
|
302
|
+
|
|
303
|
+
private fun emitEvent(eventName: String, params: WritableMap) {
|
|
304
|
+
val context = reactContext ?: return
|
|
305
|
+
if (!context.hasActiveReactInstance()) return
|
|
306
|
+
try {
|
|
307
|
+
context.getJSModule(RCTDeviceEventEmitter::class.java)?.emit(eventName, params)
|
|
308
|
+
} catch (e: Exception) {
|
|
309
|
+
Log.e(TAG, "Error emitting event $eventName: ${e.message}")
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private fun acquireJsKeepAlive() {
|
|
314
|
+
UiThreadUtil.runOnUiThread {
|
|
315
|
+
synchronized(keepAliveLock) {
|
|
316
|
+
val context = reactContext
|
|
317
|
+
if (keepAliveTaskId != null || context == null || !context.hasActiveReactInstance()) return@runOnUiThread
|
|
318
|
+
val config = HeadlessJsTaskConfig(KEEP_ALIVE_TASK, Arguments.createMap(), 0, true)
|
|
319
|
+
keepAliveTaskId = HeadlessJsTaskContext.getInstance(context).startTask(config)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private fun releaseJsKeepAlive() {
|
|
325
|
+
UiThreadUtil.runOnUiThread {
|
|
326
|
+
synchronized(keepAliveLock) {
|
|
327
|
+
val context = reactContext
|
|
328
|
+
val taskId = keepAliveTaskId ?: return@runOnUiThread
|
|
329
|
+
keepAliveTaskId = null
|
|
330
|
+
if (context != null && context.hasActiveReactInstance()) {
|
|
331
|
+
HeadlessJsTaskContext.getInstance(context).finishTask(taskId)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private fun stopServiceIfIdle() {
|
|
338
|
+
if (activeUploads.isEmpty() && awaitingAck.isEmpty()) {
|
|
339
|
+
releaseJsKeepAlive()
|
|
340
|
+
MultipartUploadService.stop(appContext)
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
private fun holdServiceUntilAcknowledged(uploadId: String, graceMs: Long) {
|
|
345
|
+
awaitingAck.remove(uploadId)?.cancel()
|
|
346
|
+
awaitingAck[uploadId] = ackScope.launch {
|
|
347
|
+
delay(graceMs)
|
|
348
|
+
awaitingAck.remove(uploadId)
|
|
349
|
+
stopServiceIfIdle()
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
fun acknowledgeUpload(uploadId: String): Boolean {
|
|
354
|
+
var acknowledged = false
|
|
355
|
+
val record = records[uploadId]
|
|
356
|
+
if (record != null && record.status != STATUS_RUNNING) {
|
|
357
|
+
records.remove(uploadId)
|
|
358
|
+
persist()
|
|
359
|
+
acknowledged = true
|
|
360
|
+
}
|
|
361
|
+
val timer = awaitingAck.remove(uploadId)
|
|
362
|
+
if (timer != null) {
|
|
363
|
+
timer.cancel()
|
|
364
|
+
acknowledged = true
|
|
365
|
+
}
|
|
366
|
+
stopServiceIfIdle()
|
|
367
|
+
return acknowledged
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
fun pendingUploads(): WritableArray {
|
|
371
|
+
restore()
|
|
372
|
+
val list = Arguments.createArray()
|
|
373
|
+
records.values.forEach { record ->
|
|
374
|
+
val bytes = record.bytesUploaded()
|
|
375
|
+
val map = Arguments.createMap()
|
|
376
|
+
map.putString("uploadId", record.params.uploadId)
|
|
377
|
+
map.putString("status", record.status)
|
|
378
|
+
map.putDouble("progress", if (record.totalBytes > 0) bytes.toDouble() / record.totalBytes.toDouble() else 1.0)
|
|
379
|
+
map.putDouble("bytesUploaded", bytes.toDouble())
|
|
380
|
+
map.putDouble("totalBytes", record.totalBytes.toDouble())
|
|
381
|
+
map.putArray("etags", etagsArray(record.etags.toList()))
|
|
382
|
+
if (record.error != null) map.putString("error", record.error) else map.putNull("error")
|
|
383
|
+
list.pushMap(map)
|
|
384
|
+
}
|
|
385
|
+
return list
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
fun completionPayload(uploadId: String): WritableMap? {
|
|
389
|
+
val record = records[uploadId] ?: return null
|
|
390
|
+
val map = Arguments.createMap()
|
|
391
|
+
map.putString("uploadId", uploadId)
|
|
392
|
+
map.putString("status", record.status)
|
|
393
|
+
map.putArray("etags", etagsArray(record.etags.toList()))
|
|
394
|
+
if (record.error != null) map.putString("error", record.error) else map.putNull("error")
|
|
395
|
+
return map
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
private fun etagsArray(etags: List<String>): WritableArray {
|
|
399
|
+
val array = Arguments.createArray()
|
|
400
|
+
etags.forEach { array.pushString(it) }
|
|
401
|
+
return array
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
private fun persist() {
|
|
405
|
+
synchronized(stateLock) {
|
|
406
|
+
try {
|
|
407
|
+
val array = JSONArray()
|
|
408
|
+
records.values.filter { it.params.resumable }.forEach { record ->
|
|
409
|
+
array.put(JSONObject().apply {
|
|
410
|
+
put("params", record.params.toJson())
|
|
411
|
+
put("totalBytes", record.totalBytes)
|
|
412
|
+
put("chunkSize", record.chunkSize)
|
|
413
|
+
put("etags", JSONArray(record.etags.toList()))
|
|
414
|
+
put("status", record.status)
|
|
415
|
+
put("error", record.error ?: JSONObject.NULL)
|
|
416
|
+
})
|
|
417
|
+
}
|
|
418
|
+
val target = File(appContext.filesDir, STATE_FILE)
|
|
419
|
+
val temp = File(appContext.filesDir, "$STATE_FILE.tmp")
|
|
420
|
+
temp.writeText(array.toString())
|
|
421
|
+
if (!temp.renameTo(target)) {
|
|
422
|
+
target.writeText(array.toString())
|
|
423
|
+
temp.delete()
|
|
424
|
+
}
|
|
425
|
+
} catch (e: Exception) {
|
|
426
|
+
Log.e(TAG, "Failed to persist upload state: ${e.message}")
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
private fun restore() {
|
|
432
|
+
synchronized(stateLock) {
|
|
433
|
+
if (restored) return
|
|
434
|
+
restored = true
|
|
435
|
+
try {
|
|
436
|
+
val file = File(appContext.filesDir, STATE_FILE)
|
|
437
|
+
if (!file.exists()) return
|
|
438
|
+
val array = JSONArray(file.readText())
|
|
439
|
+
for (i in 0 until array.length()) {
|
|
440
|
+
val json = array.getJSONObject(i)
|
|
441
|
+
val etagsJson = json.getJSONArray("etags")
|
|
442
|
+
val etags = Array(etagsJson.length()) { etagsJson.getString(it) }
|
|
443
|
+
val params = UploadParams.fromJson(json.getJSONObject("params"), emptyList())
|
|
444
|
+
val error = if (json.isNull("error")) null else json.getString("error")
|
|
445
|
+
records[params.uploadId] = Record(
|
|
446
|
+
params,
|
|
447
|
+
json.getLong("totalBytes"),
|
|
448
|
+
json.getLong("chunkSize"),
|
|
449
|
+
etags,
|
|
450
|
+
json.getString("status"),
|
|
451
|
+
error
|
|
452
|
+
)
|
|
453
|
+
}
|
|
454
|
+
} catch (e: Exception) {
|
|
455
|
+
Log.e(TAG, "Failed to restore upload state: ${e.message}")
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
fun resumeInterrupted(): List<UploadParams> {
|
|
461
|
+
restore()
|
|
462
|
+
val resumed = mutableListOf<UploadParams>()
|
|
463
|
+
for (record in records.values.toList()) {
|
|
464
|
+
if (record.status == STATUS_RUNNING && !activeUploads.containsKey(record.params.uploadId)) {
|
|
465
|
+
val params = record.params.copy(existingEtags = record.etags.toList())
|
|
466
|
+
try {
|
|
467
|
+
launchUpload(params, null)
|
|
468
|
+
resumed.add(params)
|
|
469
|
+
} catch (e: UploadAlreadyExistsException) {
|
|
470
|
+
continue
|
|
471
|
+
} catch (e: Exception) {
|
|
472
|
+
Log.w(TAG, "Could not resume ${params.uploadId}: ${e.message}")
|
|
473
|
+
record.status = STATUS_FAILED
|
|
474
|
+
record.error = e.message
|
|
475
|
+
persist()
|
|
476
|
+
notifyFinished(record)
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return resumed
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
suspend fun startMultipartUpload(params: UploadParams): WritableMap {
|
|
484
|
+
val deferred = CompletableDeferred<WritableMap>()
|
|
485
|
+
withContext(Dispatchers.IO) { launchUpload(params, deferred) }
|
|
486
|
+
return deferred.await()
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
private fun launchUpload(params: UploadParams, deferred: CompletableDeferred<WritableMap>?) {
|
|
490
|
+
restore()
|
|
491
|
+
val file = File(params.path)
|
|
492
|
+
if (!file.exists() || !file.isFile) {
|
|
493
|
+
throw IllegalArgumentException("File does not exist at path: ${params.path}")
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
val partsCount = params.parts.size
|
|
497
|
+
val fileSize = file.length()
|
|
498
|
+
if (fileSize <= 0) {
|
|
499
|
+
throw IllegalArgumentException("File is empty or invalid: ${params.path}")
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
val minRequiredSize = MIN_CHUNK_SIZE * (partsCount - 1)
|
|
503
|
+
if (partsCount > 1 && fileSize <= minRequiredSize) {
|
|
504
|
+
throw IllegalArgumentException(
|
|
505
|
+
"File size ($fileSize bytes) is too small for $partsCount parts. Minimum required size: " + (minRequiredSize + 1) + " bytes."
|
|
506
|
+
)
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
val chunkSize = Math.max(Math.ceil(fileSize.toDouble() / partsCount).toLong(), MIN_CHUNK_SIZE)
|
|
510
|
+
|
|
511
|
+
val initialEtags = ConcurrentHashMap<Int, String>()
|
|
512
|
+
val etagArray = Array(partsCount) { "" }
|
|
513
|
+
var initialUploadedBytes = 0L
|
|
514
|
+
var initialCompleted = 0L
|
|
515
|
+
val count = Math.min(params.existingEtags.size, partsCount)
|
|
516
|
+
for (i in 0 until count) {
|
|
517
|
+
val etag = params.existingEtags[i]
|
|
518
|
+
if (etag.isNotEmpty()) {
|
|
519
|
+
val size = if (i == partsCount - 1) fileSize - i * chunkSize else chunkSize
|
|
520
|
+
initialEtags[i + 1] = etag
|
|
521
|
+
etagArray[i] = etag
|
|
522
|
+
initialUploadedBytes += size
|
|
523
|
+
initialCompleted++
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
val uploadJob = UploadJob(
|
|
528
|
+
uploadId = params.uploadId,
|
|
529
|
+
totalBytes = fileSize,
|
|
530
|
+
partsCount = partsCount,
|
|
531
|
+
bytesUploaded = AtomicLong(initialUploadedBytes),
|
|
532
|
+
completedChunks = AtomicLong(initialCompleted),
|
|
533
|
+
partETags = initialEtags
|
|
534
|
+
)
|
|
535
|
+
if (activeUploads.putIfAbsent(params.uploadId, uploadJob) != null) {
|
|
536
|
+
throw UploadAlreadyExistsException("Upload already in progress for ${params.uploadId}")
|
|
537
|
+
}
|
|
538
|
+
awaitingAck.remove(params.uploadId)?.cancel()
|
|
539
|
+
|
|
540
|
+
val record = Record(params, fileSize, chunkSize, etagArray, STATUS_RUNNING, null)
|
|
541
|
+
records[params.uploadId] = record
|
|
542
|
+
persist()
|
|
543
|
+
|
|
544
|
+
acquireJsKeepAlive()
|
|
545
|
+
if (params.notificationEnabled) {
|
|
546
|
+
val startMsg = if (initialUploadedBytes > 0) params.resumeMessage else params.startMessage
|
|
547
|
+
MultipartUploadService.start(appContext, params.title, startMsg, params.channelName)
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (initialUploadedBytes > 0) {
|
|
551
|
+
emitEvent("RNFileUploader-multipart-progress", progressMap(uploadJob, (initialCompleted - 1).toInt(), null))
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
val parts = params.parts.mapIndexed { index, url ->
|
|
555
|
+
val offset = index * chunkSize
|
|
556
|
+
val size = if (index == partsCount - 1) fileSize - offset else chunkSize
|
|
557
|
+
if (size <= 0) {
|
|
558
|
+
activeUploads.remove(params.uploadId, uploadJob)
|
|
559
|
+
records.remove(params.uploadId)
|
|
560
|
+
persist()
|
|
561
|
+
throw IllegalArgumentException(
|
|
562
|
+
"Part ${index + 1} has invalid size ($size bytes). Offset: $offset, FileSize: $fileSize"
|
|
563
|
+
)
|
|
564
|
+
}
|
|
565
|
+
PartInfo(index + 1, url, offset, size)
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
val job = engineScope.launch(start = CoroutineStart.LAZY) {
|
|
569
|
+
try {
|
|
570
|
+
coroutineScope {
|
|
571
|
+
val semaphore = Semaphore(params.maxParallel)
|
|
572
|
+
parts.map { part ->
|
|
573
|
+
async {
|
|
574
|
+
semaphore.withPermit {
|
|
575
|
+
uploadPart(file, part, uploadJob, record)
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}.awaitAll()
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (uploadJob.completedChunks.get() < partsCount) {
|
|
582
|
+
throw Exception("Upload incomplete: completed ${uploadJob.completedChunks.get()} of $partsCount chunks")
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
record.status = STATUS_COMPLETED
|
|
586
|
+
record.error = null
|
|
587
|
+
persist()
|
|
588
|
+
|
|
589
|
+
val result = Arguments.createMap()
|
|
590
|
+
result.putString("uploadId", params.uploadId)
|
|
591
|
+
result.putBoolean("success", true)
|
|
592
|
+
result.putArray("etags", etagsArray(record.etags.toList()))
|
|
593
|
+
deferred?.complete(result)
|
|
594
|
+
notifyFinished(record)
|
|
595
|
+
} catch (e: CancellationException) {
|
|
596
|
+
if (uploadJob.cancelledByUser) {
|
|
597
|
+
records.remove(params.uploadId)
|
|
598
|
+
persist()
|
|
599
|
+
val cancelParams = Arguments.createMap()
|
|
600
|
+
cancelParams.putString("uploadId", params.uploadId)
|
|
601
|
+
cancelParams.putString("error", "Upload cancelled")
|
|
602
|
+
emitEvent("RNFileUploader-multipart-cancelled", cancelParams)
|
|
603
|
+
} else {
|
|
604
|
+
record.status = STATUS_FAILED
|
|
605
|
+
record.error = e.message ?: "Upload cancelled"
|
|
606
|
+
persist()
|
|
607
|
+
notifyFinished(record)
|
|
608
|
+
}
|
|
609
|
+
deferred?.completeExceptionally(e)
|
|
610
|
+
} catch (e: Exception) {
|
|
611
|
+
Log.e(TAG, "Multipart upload failed for ${params.uploadId}: ${e.message}", e)
|
|
612
|
+
record.status = STATUS_FAILED
|
|
613
|
+
record.error = e.message ?: "Upload failed"
|
|
614
|
+
persist()
|
|
615
|
+
notifyFinished(record)
|
|
616
|
+
deferred?.completeExceptionally(e)
|
|
617
|
+
} finally {
|
|
618
|
+
activeUploads.remove(params.uploadId, uploadJob)
|
|
619
|
+
if (!uploadJob.cancelledByUser) {
|
|
620
|
+
holdServiceUntilAcknowledged(
|
|
621
|
+
params.uploadId,
|
|
622
|
+
if (isJsAlive()) ACK_GRACE_MS else HEADLESS_ACK_GRACE_MS
|
|
623
|
+
)
|
|
624
|
+
}
|
|
625
|
+
stopServiceIfIdle()
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
uploadJob.job = job
|
|
629
|
+
job.start()
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
private fun notifyFinished(record: Record) {
|
|
633
|
+
if (isJsAlive()) {
|
|
634
|
+
val params = Arguments.createMap()
|
|
635
|
+
params.putString("uploadId", record.params.uploadId)
|
|
636
|
+
params.putArray("etags", etagsArray(record.etags.toList()))
|
|
637
|
+
if (record.status == STATUS_COMPLETED) {
|
|
638
|
+
params.putBoolean("success", true)
|
|
639
|
+
emitEvent("RNFileUploader-multipart-completed", params)
|
|
640
|
+
} else {
|
|
641
|
+
params.putString("error", record.error ?: "Upload failed")
|
|
642
|
+
emitEvent("RNFileUploader-multipart-error", params)
|
|
643
|
+
}
|
|
644
|
+
} else {
|
|
645
|
+
MultipartCompletionService.start(appContext, record.params.uploadId)
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
private fun progressMap(uploadJob: UploadJob, chunkIndex: Int, etag: String?): WritableMap {
|
|
650
|
+
val progress = if (uploadJob.totalBytes > 0) {
|
|
651
|
+
uploadJob.bytesUploaded.get().toDouble() / uploadJob.totalBytes.toDouble()
|
|
652
|
+
} else 1.0
|
|
653
|
+
val map = Arguments.createMap()
|
|
654
|
+
map.putString("uploadId", uploadJob.uploadId)
|
|
655
|
+
map.putDouble("progress", progress)
|
|
656
|
+
map.putDouble("bytesUploaded", uploadJob.bytesUploaded.get().toDouble())
|
|
657
|
+
map.putDouble("totalBytes", uploadJob.totalBytes.toDouble())
|
|
658
|
+
map.putInt("chunkIndex", chunkIndex)
|
|
659
|
+
if (etag != null) map.putString("etag", etag)
|
|
660
|
+
val etags = Arguments.createArray()
|
|
661
|
+
for (i in 0 until uploadJob.partsCount) {
|
|
662
|
+
etags.pushString(uploadJob.partETags[i + 1] ?: "")
|
|
663
|
+
}
|
|
664
|
+
map.putArray("etags", etags)
|
|
665
|
+
return map
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
private suspend fun uploadPart(
|
|
669
|
+
file: File,
|
|
670
|
+
part: PartInfo,
|
|
671
|
+
uploadJob: UploadJob,
|
|
672
|
+
record: Record
|
|
673
|
+
): Boolean = withContext(Dispatchers.IO) {
|
|
674
|
+
if (uploadJob.partETags.containsKey(part.partNumber)) {
|
|
675
|
+
return@withContext true
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
var retries = 0
|
|
679
|
+
var lastError: Exception? = null
|
|
680
|
+
|
|
681
|
+
while (retries < MAX_RETRIES) {
|
|
682
|
+
while (uploadJob.isPaused.get()) {
|
|
683
|
+
delay(500)
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
if (!isActive) {
|
|
687
|
+
throw CancellationException("Upload cancelled")
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
try {
|
|
691
|
+
if (!file.exists()) {
|
|
692
|
+
uploadJob.cancel()
|
|
693
|
+
throw CancellationException("Upload cancelled: file deleted")
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
val mediaType = "application/octet-stream".toMediaTypeOrNull()
|
|
697
|
+
val request = Request.Builder()
|
|
698
|
+
.url(part.url)
|
|
699
|
+
.put(FileRegionBody(file, part.offset, part.size, mediaType))
|
|
700
|
+
.build()
|
|
701
|
+
|
|
702
|
+
val call = httpClient.newCall(request)
|
|
703
|
+
uploadJob.activeCalls[part.partNumber] = call
|
|
704
|
+
|
|
705
|
+
val response = try {
|
|
706
|
+
call.execute()
|
|
707
|
+
} finally {
|
|
708
|
+
uploadJob.activeCalls.remove(part.partNumber)
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
if (!response.isSuccessful) {
|
|
712
|
+
val errorBody = response.body?.string() ?: ""
|
|
713
|
+
throw HttpStatusException(response.code, "HTTP ${response.code}: $errorBody")
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
val etag = response.header("ETag")?.trim('"')
|
|
717
|
+
?: response.header("etag")?.trim('"')
|
|
718
|
+
?: ""
|
|
719
|
+
|
|
720
|
+
if (etag.isEmpty()) {
|
|
721
|
+
throw Exception("No ETag in response for part ${part.partNumber}")
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
synchronized(uploadJob) {
|
|
725
|
+
if (!uploadJob.partETags.containsKey(part.partNumber)) {
|
|
726
|
+
uploadJob.partETags[part.partNumber] = etag
|
|
727
|
+
uploadJob.completedChunks.incrementAndGet()
|
|
728
|
+
uploadJob.bytesUploaded.addAndGet(part.size)
|
|
729
|
+
record.etags = Array(uploadJob.partsCount) { i -> uploadJob.partETags[i + 1] ?: "" }
|
|
730
|
+
persist()
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
emitEvent("RNFileUploader-multipart-progress", progressMap(uploadJob, part.partNumber - 1, etag))
|
|
735
|
+
|
|
736
|
+
if (record.params.notificationEnabled) {
|
|
737
|
+
val percent = (uploadJob.bytesUploaded.get().toDouble() / uploadJob.totalBytes.toDouble() * 100).toInt()
|
|
738
|
+
MultipartUploadService.updateProgress(
|
|
739
|
+
appContext,
|
|
740
|
+
record.params.title,
|
|
741
|
+
percent,
|
|
742
|
+
record.params.progressMessage.replace("{progress}", percent.toString())
|
|
743
|
+
)
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
return@withContext true
|
|
747
|
+
} catch (e: CancellationException) {
|
|
748
|
+
throw e
|
|
749
|
+
} catch (e: Exception) {
|
|
750
|
+
if (e is java.io.FileNotFoundException || !file.exists()) {
|
|
751
|
+
uploadJob.cancel()
|
|
752
|
+
throw CancellationException("Upload cancelled: file deleted")
|
|
753
|
+
}
|
|
754
|
+
if (e is HttpStatusException && !e.isRetryable) {
|
|
755
|
+
throw e
|
|
756
|
+
}
|
|
757
|
+
lastError = e
|
|
758
|
+
retries++
|
|
759
|
+
Log.w(TAG, "Part ${part.partNumber} failed (attempt $retries): ${e.message}")
|
|
760
|
+
if (retries < MAX_RETRIES) {
|
|
761
|
+
delay(1000L * retries)
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
throw lastError ?: Exception("Part ${part.partNumber} failed after $MAX_RETRIES retries")
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
fun cancelUpload(uploadId: String): Boolean {
|
|
770
|
+
val uploadJob = activeUploads[uploadId] ?: return false
|
|
771
|
+
uploadJob.cancelledByUser = true
|
|
772
|
+
uploadJob.cancel()
|
|
773
|
+
return true
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
fun pauseUpload(uploadId: String): Boolean {
|
|
777
|
+
val uploadJob = activeUploads[uploadId] ?: return false
|
|
778
|
+
uploadJob.isPaused.set(true)
|
|
779
|
+
return true
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
fun resumeUpload(uploadId: String): Boolean {
|
|
783
|
+
val uploadJob = activeUploads[uploadId] ?: return false
|
|
784
|
+
uploadJob.isPaused.set(false)
|
|
785
|
+
return true
|
|
786
|
+
}
|
|
787
|
+
}
|