@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.
@@ -0,0 +1,748 @@
1
+ #import "RNMultipartUploader.h"
2
+
3
+ static NSString *const SESSION_IDENTIFIER = @"com.reactnativeupload.multipart";
4
+ static int64_t const MIN_CHUNK_SIZE = 5 * 1024 * 1024;
5
+ static NSInteger const MAX_RETRIES = 3;
6
+ static NSUInteger const COPY_BLOCK_SIZE = 1024 * 1024;
7
+
8
+ static NSString *const STATUS_RUNNING = @"running";
9
+ static NSString *const STATUS_PAUSED = @"paused";
10
+ static NSString *const STATUS_COMPLETED = @"completed";
11
+ static NSString *const STATUS_FAILED = @"failed";
12
+
13
+ @interface RNMultipartSession : NSObject
14
+
15
+ @property (nonatomic, copy) NSString *uploadId;
16
+ @property (nonatomic, copy) NSString *filePath;
17
+ @property (nonatomic, copy) NSString *tempDirectory;
18
+ @property (nonatomic, copy) NSString *appGroup;
19
+ @property (nonatomic, copy) NSString *status;
20
+ @property (nonatomic, copy) NSString *errorMessage;
21
+ @property (nonatomic, assign) BOOL resumable;
22
+ @property (nonatomic, copy) NSArray<NSString *> *urls;
23
+ @property (nonatomic, assign) NSInteger maxParallel;
24
+ @property (nonatomic, assign) int64_t totalBytes;
25
+ @property (nonatomic, assign) int64_t chunkSize;
26
+ @property (nonatomic, strong) NSMutableArray<NSString *> *etags;
27
+ @property (nonatomic, strong) NSMutableDictionary<NSNumber *, NSURLSessionUploadTask *> *activeTasks;
28
+ @property (nonatomic, strong) NSMutableDictionary<NSNumber *, NSNumber *> *retryCounts;
29
+ @property (nonatomic, strong) NSMutableSet<NSNumber *> *pendingRetries;
30
+ @property (nonatomic, copy) RCTPromiseResolveBlock resolve;
31
+ @property (nonatomic, copy) RCTPromiseRejectBlock reject;
32
+
33
+ @end
34
+
35
+ @implementation RNMultipartSession
36
+
37
+ - (instancetype)init {
38
+ self = [super init];
39
+ if (self) {
40
+ _activeTasks = [NSMutableDictionary dictionary];
41
+ _retryCounts = [NSMutableDictionary dictionary];
42
+ _pendingRetries = [NSMutableSet set];
43
+ }
44
+ return self;
45
+ }
46
+
47
+ - (BOOL)isActive {
48
+ return [self.status isEqualToString:STATUS_RUNNING] || [self.status isEqualToString:STATUS_PAUSED];
49
+ }
50
+
51
+ - (int64_t)sizeOfChunk:(NSInteger)index {
52
+ int64_t offset = (int64_t)index * self.chunkSize;
53
+ return MAX(0, MIN(self.chunkSize, self.totalBytes - offset));
54
+ }
55
+
56
+ - (NSInteger)completedCount {
57
+ NSInteger count = 0;
58
+ for (NSString *etag in self.etags) {
59
+ if (etag.length > 0) count++;
60
+ }
61
+ return count;
62
+ }
63
+
64
+ - (int64_t)bytesUploaded {
65
+ int64_t bytes = 0;
66
+ for (NSInteger i = 0; i < (NSInteger)self.etags.count; i++) {
67
+ if (self.etags[i].length > 0) bytes += [self sizeOfChunk:i];
68
+ }
69
+ return bytes;
70
+ }
71
+
72
+ - (double)progress {
73
+ return self.totalBytes > 0 ? (double)[self bytesUploaded] / (double)self.totalBytes : 1.0;
74
+ }
75
+
76
+ - (NSString *)chunkPath:(NSInteger)index {
77
+ return [self.tempDirectory stringByAppendingPathComponent:[NSString stringWithFormat:@"chunk_%@_%ld.tmp", self.uploadId, (long)index]];
78
+ }
79
+
80
+ - (void)cleanupTempFiles {
81
+ NSFileManager *fm = [NSFileManager defaultManager];
82
+ NSString *prefix = [NSString stringWithFormat:@"chunk_%@_", self.uploadId];
83
+ for (NSString *name in [fm contentsOfDirectoryAtPath:self.tempDirectory error:nil]) {
84
+ if ([name hasPrefix:prefix]) {
85
+ [fm removeItemAtPath:[self.tempDirectory stringByAppendingPathComponent:name] error:nil];
86
+ }
87
+ }
88
+ }
89
+
90
+ @end
91
+
92
+ @interface RNMultipartUploader () <NSURLSessionTaskDelegate>
93
+
94
+ @property (nonatomic, strong) NSMutableDictionary<NSString *, RNMultipartSession *> *sessions;
95
+ @property (nonatomic, strong) dispatch_queue_t queue;
96
+ @property (nonatomic, strong) NSURLSession *urlSession;
97
+ @property (nonatomic, copy) void (^eventEmitter)(NSString *eventName, id body);
98
+ @property (nonatomic, strong) NSMutableArray<NSDictionary *> *bufferedEvents;
99
+ @property (nonatomic, copy) void (^pendingBackgroundHandler)(void);
100
+ @property (nonatomic, assign) BOOL restored;
101
+ @property (nonatomic, assign) BOOL tasksLoaded;
102
+ @property (nonatomic, assign) BOOL finishEventsPending;
103
+
104
+ @end
105
+
106
+ @implementation RNMultipartUploader
107
+
108
+ + (instancetype)sharedInstance {
109
+ static RNMultipartUploader *instance = nil;
110
+ static dispatch_once_t onceToken;
111
+ dispatch_once(&onceToken, ^{
112
+ instance = [[RNMultipartUploader alloc] init];
113
+ });
114
+ return instance;
115
+ }
116
+
117
+ + (NSString *)sessionIdentifier {
118
+ return SESSION_IDENTIFIER;
119
+ }
120
+
121
+ - (instancetype)init {
122
+ self = [super init];
123
+ if (self) {
124
+ _sessions = [NSMutableDictionary dictionary];
125
+ _bufferedEvents = [NSMutableArray array];
126
+ _queue = dispatch_queue_create("com.reactnativeupload.multipart.manager", DISPATCH_QUEUE_SERIAL);
127
+ }
128
+ return self;
129
+ }
130
+
131
+ #pragma mark - Events
132
+
133
+ - (void)setEventEmitter:(void (^)(NSString *, id))eventEmitter {
134
+ dispatch_async(self.queue, ^{
135
+ self->_eventEmitter = [eventEmitter copy];
136
+ if (self->_eventEmitter) {
137
+ NSArray *buffered = [self.bufferedEvents copy];
138
+ [self.bufferedEvents removeAllObjects];
139
+ for (NSDictionary *event in buffered) {
140
+ self->_eventEmitter(event[@"name"], event[@"body"]);
141
+ }
142
+ }
143
+ });
144
+ }
145
+
146
+ - (void)emit:(NSString *)name body:(NSDictionary *)body buffered:(BOOL)buffered {
147
+ if (self.eventEmitter) {
148
+ self.eventEmitter(name, body);
149
+ } else if (buffered) {
150
+ [self.bufferedEvents addObject:@{@"name": name, @"body": body}];
151
+ }
152
+ }
153
+
154
+ - (void)emitProgress:(RNMultipartSession *)session chunkIndex:(NSInteger)index etag:(NSString *)etag {
155
+ NSMutableDictionary *body = [@{
156
+ @"uploadId": session.uploadId,
157
+ @"progress": @([session progress]),
158
+ @"bytesUploaded": @([session bytesUploaded]),
159
+ @"totalBytes": @(session.totalBytes),
160
+ @"chunkIndex": @(index),
161
+ @"etags": [session.etags copy]
162
+ } mutableCopy];
163
+ if (etag) body[@"etag"] = etag;
164
+ [self emit:@"RNFileUploader-multipart-progress" body:body buffered:NO];
165
+ }
166
+
167
+ #pragma mark - Persistence
168
+
169
+ - (NSString *)supportDirectory {
170
+ NSString *base = NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, YES).firstObject;
171
+ NSString *dir = [base stringByAppendingPathComponent:@"RNMultipartUploader"];
172
+ [[NSFileManager defaultManager] createDirectoryAtPath:dir withIntermediateDirectories:YES attributes:nil error:nil];
173
+ return dir;
174
+ }
175
+
176
+ - (NSString *)stateFilePath {
177
+ return [[self supportDirectory] stringByAppendingPathComponent:@"state.json"];
178
+ }
179
+
180
+ - (NSString *)chunkDirectoryForAppGroup:(NSString *)appGroup {
181
+ NSString *dir = nil;
182
+ if (appGroup.length > 0) {
183
+ NSURL *container = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:appGroup];
184
+ if (container) dir = [[container path] stringByAppendingPathComponent:@"RNMultipartChunks"];
185
+ }
186
+ if (!dir) dir = [[self supportDirectory] stringByAppendingPathComponent:@"chunks"];
187
+ [[NSFileManager defaultManager] createDirectoryAtPath:dir withIntermediateDirectories:YES attributes:nil error:nil];
188
+ [[NSURL fileURLWithPath:dir] setResourceValue:@YES forKey:NSURLIsExcludedFromBackupKey error:nil];
189
+ return dir;
190
+ }
191
+
192
+ - (void)persist {
193
+ NSMutableArray *entries = [NSMutableArray array];
194
+ for (RNMultipartSession *s in self.sessions.allValues) {
195
+ if (!s.resumable) continue;
196
+ [entries addObject:@{
197
+ @"uploadId": s.uploadId,
198
+ @"path": s.filePath,
199
+ @"parts": s.urls,
200
+ @"maxParallel": @(s.maxParallel),
201
+ @"totalBytes": @(s.totalBytes),
202
+ @"chunkSize": @(s.chunkSize),
203
+ @"etags": s.etags,
204
+ @"status": s.status,
205
+ @"error": s.errorMessage ?: @"",
206
+ @"tempDirectory": s.tempDirectory,
207
+ @"appGroup": s.appGroup ?: @""
208
+ }];
209
+ }
210
+ NSData *data = [NSJSONSerialization dataWithJSONObject:entries options:0 error:nil];
211
+ [data writeToFile:[self stateFilePath] atomically:YES];
212
+ }
213
+
214
+ - (void)restoreLocked {
215
+ if (self.restored) return;
216
+ self.restored = YES;
217
+
218
+ NSData *data = [NSData dataWithContentsOfFile:[self stateFilePath]];
219
+ NSArray *entries = data ? [NSJSONSerialization JSONObjectWithData:data options:0 error:nil] : nil;
220
+ if (![entries isKindOfClass:[NSArray class]]) {
221
+ entries = @[];
222
+ }
223
+
224
+ for (NSDictionary *e in entries) {
225
+ RNMultipartSession *s = [[RNMultipartSession alloc] init];
226
+ s.uploadId = e[@"uploadId"];
227
+ s.filePath = e[@"path"];
228
+ s.urls = e[@"parts"];
229
+ s.maxParallel = [e[@"maxParallel"] integerValue];
230
+ s.totalBytes = [e[@"totalBytes"] longLongValue];
231
+ s.chunkSize = [e[@"chunkSize"] longLongValue];
232
+ s.etags = [e[@"etags"] mutableCopy];
233
+ s.status = e[@"status"];
234
+ s.errorMessage = [e[@"error"] length] > 0 ? e[@"error"] : nil;
235
+ s.tempDirectory = e[@"tempDirectory"];
236
+ s.appGroup = e[@"appGroup"];
237
+ s.resumable = YES;
238
+ self.sessions[s.uploadId] = s;
239
+ }
240
+
241
+ [self removeOrphanChunkFiles];
242
+
243
+ RNMultipartSession *first = self.sessions.allValues.firstObject;
244
+ NSURLSession *session = [self sessionForAppGroup:first.appGroup maxParallel:first.maxParallel > 0 ? first.maxParallel : 3];
245
+ [session getTasksWithCompletionHandler:^(NSArray *dataTasks, NSArray *uploadTasks, NSArray *downloadTasks) {
246
+ dispatch_async(self.queue, ^{
247
+ for (NSURLSessionUploadTask *task in uploadTasks) {
248
+ NSString *uploadId = nil;
249
+ NSInteger index = -1;
250
+ if (![self parseDescription:task.taskDescription uploadId:&uploadId index:&index]) {
251
+ [task cancel];
252
+ continue;
253
+ }
254
+ RNMultipartSession *s = self.sessions[uploadId];
255
+ if (!s || ![s.status isEqualToString:STATUS_RUNNING] || index < 0 || index >= (NSInteger)s.urls.count || s.etags[index].length > 0) {
256
+ [task cancel];
257
+ continue;
258
+ }
259
+ s.activeTasks[@(index)] = task;
260
+ }
261
+ self.tasksLoaded = YES;
262
+ if (self.sessions.count == 0) {
263
+ [self finishBackgroundEvents];
264
+ }
265
+ if (self.finishEventsPending) {
266
+ self.finishEventsPending = NO;
267
+ [self enqueueRunningSessions];
268
+ }
269
+ });
270
+ }];
271
+ }
272
+
273
+ - (void)removeOrphanChunkFiles {
274
+ NSMutableSet<NSString *> *directories = [NSMutableSet setWithObject:[self chunkDirectoryForAppGroup:nil]];
275
+ for (RNMultipartSession *s in self.sessions.allValues) {
276
+ if (s.tempDirectory) [directories addObject:s.tempDirectory];
277
+ }
278
+ NSFileManager *fm = [NSFileManager defaultManager];
279
+ for (NSString *directory in directories) {
280
+ for (NSString *name in [fm contentsOfDirectoryAtPath:directory error:nil]) {
281
+ if (![name hasPrefix:@"chunk_"] || ![name hasSuffix:@".tmp"]) continue;
282
+ NSString *body = [name substringWithRange:NSMakeRange(6, name.length - 10)];
283
+ NSRange separator = [body rangeOfString:@"_" options:NSBackwardsSearch];
284
+ if (separator.location == NSNotFound) continue;
285
+ NSString *uploadId = [body substringToIndex:separator.location];
286
+ if (!self.sessions[uploadId]) {
287
+ [fm removeItemAtPath:[directory stringByAppendingPathComponent:name] error:nil];
288
+ }
289
+ }
290
+ }
291
+ }
292
+
293
+ - (void)restore {
294
+ dispatch_async(self.queue, ^{
295
+ [self restoreLocked];
296
+ });
297
+ }
298
+
299
+ #pragma mark - URL session
300
+
301
+ - (NSURLSession *)sessionForAppGroup:(NSString *)appGroup maxParallel:(NSInteger)maxParallel {
302
+ if (!self.urlSession) {
303
+ NSURLSessionConfiguration *config = [NSURLSessionConfiguration backgroundSessionConfigurationWithIdentifier:SESSION_IDENTIFIER];
304
+ config.discretionary = NO;
305
+ config.sessionSendsLaunchEvents = YES;
306
+ config.HTTPMaximumConnectionsPerHost = MAX(1, maxParallel);
307
+ if (appGroup.length > 0) {
308
+ config.sharedContainerIdentifier = appGroup;
309
+ }
310
+ self.urlSession = [NSURLSession sessionWithConfiguration:config delegate:self delegateQueue:nil];
311
+ }
312
+ return self.urlSession;
313
+ }
314
+
315
+ - (BOOL)parseDescription:(NSString *)description uploadId:(NSString **)uploadId index:(NSInteger *)index {
316
+ NSRange range = [description rangeOfString:@":" options:NSBackwardsSearch];
317
+ if (description.length == 0 || range.location == NSNotFound) return NO;
318
+ *uploadId = [description substringToIndex:range.location];
319
+ *index = [[description substringFromIndex:range.location + 1] integerValue];
320
+ return YES;
321
+ }
322
+
323
+ #pragma mark - Background events
324
+
325
+ - (void)setBackgroundCompletionHandler:(void (^)(void))completionHandler {
326
+ self.pendingBackgroundHandler = completionHandler;
327
+ [self restore];
328
+ }
329
+
330
+ - (void)finishBackgroundEvents {
331
+ void (^handler)(void) = self.pendingBackgroundHandler;
332
+ self.pendingBackgroundHandler = nil;
333
+ if (handler) {
334
+ dispatch_async(dispatch_get_main_queue(), handler);
335
+ }
336
+ }
337
+
338
+ #pragma mark - Public API
339
+
340
+ - (void)startUploadWithOptions:(NSDictionary *)options
341
+ resolver:(RCTPromiseResolveBlock)resolve
342
+ rejecter:(RCTPromiseRejectBlock)reject {
343
+ NSString *uploadId = options[@"uploadId"];
344
+ if (!uploadId || uploadId.length == 0) {
345
+ reject(@"INVALID_ARGUMENT", @"uploadId is required", nil);
346
+ return;
347
+ }
348
+
349
+ NSString *filePath = options[@"path"];
350
+ if (!filePath || filePath.length == 0) {
351
+ reject(@"INVALID_ARGUMENT", @"path is required", nil);
352
+ return;
353
+ }
354
+
355
+ NSArray *parts = options[@"parts"];
356
+ if (!parts || parts.count == 0) {
357
+ reject(@"INVALID_ARGUMENT", @"parts array cannot be empty", nil);
358
+ return;
359
+ }
360
+
361
+ NSString *localPath = filePath;
362
+ if ([localPath hasPrefix:@"file://"]) {
363
+ localPath = [localPath substringFromIndex:7];
364
+ }
365
+ localPath = [localPath stringByRemovingPercentEncoding] ?: localPath;
366
+
367
+ BOOL isDirectory = NO;
368
+ if (![[NSFileManager defaultManager] fileExistsAtPath:localPath isDirectory:&isDirectory] || isDirectory) {
369
+ reject(@"INVALID_ARGUMENT", [NSString stringWithFormat:@"File does not exist: %@", localPath], nil);
370
+ return;
371
+ }
372
+
373
+ NSDictionary *attrs = [[NSFileManager defaultManager] attributesOfItemAtPath:localPath error:nil];
374
+ int64_t fileSize = [attrs[NSFileSize] longLongValue];
375
+ if (fileSize <= 0) {
376
+ reject(@"INVALID_ARGUMENT", @"File is empty", nil);
377
+ return;
378
+ }
379
+
380
+ int64_t partsCount = (int64_t)parts.count;
381
+ int64_t chunkSize = MAX((int64_t)ceil((double)fileSize / (double)partsCount), MIN_CHUNK_SIZE);
382
+ if ((partsCount - 1) * chunkSize >= fileSize) {
383
+ reject(@"INVALID_ARGUMENT", [NSString stringWithFormat:@"File size (%lld bytes) is too small for %lld parts. Each part except the last must be at least %lld bytes.", fileSize, partsCount, (long long)MIN_CHUNK_SIZE], nil);
384
+ return;
385
+ }
386
+
387
+ dispatch_async(self.queue, ^{
388
+ [self restoreLocked];
389
+
390
+ RNMultipartSession *existing = self.sessions[uploadId];
391
+ if (existing && [existing isActive]) {
392
+ reject(@"ALREADY_EXISTS", [NSString stringWithFormat:@"Upload already in progress for %@", uploadId], nil);
393
+ return;
394
+ }
395
+
396
+ NSString *appGroup = options[@"appGroup"];
397
+ NSInteger maxParallel = [options[@"maxParallel"] integerValue] > 0 ? [options[@"maxParallel"] integerValue] : 3;
398
+
399
+ RNMultipartSession *session = [[RNMultipartSession alloc] init];
400
+ session.uploadId = uploadId;
401
+ session.filePath = localPath;
402
+ session.urls = parts;
403
+ session.maxParallel = maxParallel;
404
+ session.totalBytes = fileSize;
405
+ session.chunkSize = chunkSize;
406
+ session.appGroup = appGroup;
407
+ session.tempDirectory = [self chunkDirectoryForAppGroup:appGroup];
408
+ session.status = STATUS_RUNNING;
409
+ session.resumable = options[@"resumable"] == nil ? YES : [options[@"resumable"] boolValue];
410
+ session.resolve = resolve;
411
+ session.reject = reject;
412
+ session.etags = [NSMutableArray arrayWithCapacity:parts.count];
413
+ for (NSUInteger i = 0; i < parts.count; i++) {
414
+ [session.etags addObject:@""];
415
+ }
416
+
417
+ NSArray *existingEtags = options[@"existingEtags"];
418
+ if ([existingEtags isKindOfClass:[NSArray class]]) {
419
+ for (NSUInteger i = 0; i < existingEtags.count && i < parts.count; i++) {
420
+ NSString *etag = existingEtags[i];
421
+ if ([etag isKindOfClass:[NSString class]] && etag.length > 0) {
422
+ session.etags[i] = etag;
423
+ }
424
+ }
425
+ }
426
+
427
+ self.sessions[uploadId] = session;
428
+ [self sessionForAppGroup:appGroup maxParallel:maxParallel];
429
+ [self persist];
430
+
431
+ if ([session bytesUploaded] > 0) {
432
+ [self emitProgress:session chunkIndex:MAX(0, [session completedCount] - 1) etag:nil];
433
+ }
434
+
435
+ if ([session completedCount] == (NSInteger)session.urls.count) {
436
+ [self finishSuccess:session];
437
+ } else {
438
+ [self enqueueRemaining:session];
439
+ }
440
+ });
441
+ }
442
+
443
+ - (BOOL)cancelUpload:(NSString *)uploadId {
444
+ __block BOOL found = NO;
445
+ dispatch_sync(self.queue, ^{
446
+ [self restoreLocked];
447
+ RNMultipartSession *session = self.sessions[uploadId];
448
+ if (!session || ![session isActive]) return;
449
+ found = YES;
450
+
451
+ [self cancelActiveTasks:session];
452
+ [session cleanupTempFiles];
453
+ [self.sessions removeObjectForKey:uploadId];
454
+ [self persist];
455
+
456
+ [self emit:@"RNFileUploader-multipart-cancelled" body:@{
457
+ @"uploadId": uploadId,
458
+ @"error": @"Upload cancelled"
459
+ } buffered:YES];
460
+
461
+ if (session.reject) {
462
+ session.reject(@"CANCELLED", @"Upload cancelled", nil);
463
+ }
464
+ session.resolve = nil;
465
+ session.reject = nil;
466
+ });
467
+ return found;
468
+ }
469
+
470
+ - (BOOL)pauseUpload:(NSString *)uploadId {
471
+ __block BOOL found = NO;
472
+ dispatch_sync(self.queue, ^{
473
+ [self restoreLocked];
474
+ RNMultipartSession *session = self.sessions[uploadId];
475
+ if (!session || ![session.status isEqualToString:STATUS_RUNNING]) return;
476
+ found = YES;
477
+ session.status = STATUS_PAUSED;
478
+ [self cancelActiveTasks:session];
479
+ [self persist];
480
+ });
481
+ return found;
482
+ }
483
+
484
+ - (BOOL)resumeUpload:(NSString *)uploadId {
485
+ __block BOOL found = NO;
486
+ dispatch_sync(self.queue, ^{
487
+ [self restoreLocked];
488
+ RNMultipartSession *session = self.sessions[uploadId];
489
+ if (!session || ![session.status isEqualToString:STATUS_PAUSED]) return;
490
+ found = YES;
491
+ session.status = STATUS_RUNNING;
492
+ [self persist];
493
+ [self enqueueRemaining:session];
494
+ });
495
+ return found;
496
+ }
497
+
498
+ - (NSArray<NSDictionary *> *)pendingUploads {
499
+ __block NSArray *result = @[];
500
+ dispatch_sync(self.queue, ^{
501
+ [self restoreLocked];
502
+ NSMutableArray *list = [NSMutableArray array];
503
+ for (RNMultipartSession *s in self.sessions.allValues) {
504
+ [list addObject:@{
505
+ @"uploadId": s.uploadId,
506
+ @"status": s.status,
507
+ @"progress": @([s progress]),
508
+ @"bytesUploaded": @([s bytesUploaded]),
509
+ @"totalBytes": @(s.totalBytes),
510
+ @"etags": [s.etags copy],
511
+ @"error": s.errorMessage ?: [NSNull null]
512
+ }];
513
+ }
514
+ result = list;
515
+ });
516
+ return result;
517
+ }
518
+
519
+ - (BOOL)acknowledgeUpload:(NSString *)uploadId {
520
+ __block BOOL removed = NO;
521
+ dispatch_sync(self.queue, ^{
522
+ [self restoreLocked];
523
+ RNMultipartSession *session = self.sessions[uploadId];
524
+ if (!session || [session isActive]) return;
525
+ [session cleanupTempFiles];
526
+ [self.sessions removeObjectForKey:uploadId];
527
+ [self persist];
528
+ removed = YES;
529
+ });
530
+ return removed;
531
+ }
532
+
533
+ #pragma mark - Chunk handling
534
+
535
+ - (void)cancelActiveTasks:(RNMultipartSession *)session {
536
+ NSArray *tasks = session.activeTasks.allValues;
537
+ [session.activeTasks removeAllObjects];
538
+ [session.pendingRetries removeAllObjects];
539
+ for (NSURLSessionUploadTask *task in tasks) {
540
+ [task cancel];
541
+ }
542
+ }
543
+
544
+ - (void)enqueueRemaining:(RNMultipartSession *)session {
545
+ for (NSInteger i = 0; i < (NSInteger)session.urls.count; i++) {
546
+ if (![session.status isEqualToString:STATUS_RUNNING]) return;
547
+ if (session.etags[i].length == 0 && !session.activeTasks[@(i)] && ![session.pendingRetries containsObject:@(i)]) {
548
+ if (![self enqueueChunk:i session:session]) return;
549
+ }
550
+ }
551
+ }
552
+
553
+ - (NSError *)prepareChunk:(NSInteger)index session:(RNMultipartSession *)session {
554
+ int64_t offset = (int64_t)index * session.chunkSize;
555
+ int64_t size = [session sizeOfChunk:index];
556
+ NSString *tempPath = [session chunkPath:index];
557
+
558
+ NSFileHandle *input = [NSFileHandle fileHandleForReadingAtPath:session.filePath];
559
+ if (!input) {
560
+ return [NSError errorWithDomain:@"RNMultipartUploader" code:-1 userInfo:@{NSLocalizedDescriptionKey: @"Cannot open file for reading"}];
561
+ }
562
+
563
+ NSFileManager *fm = [NSFileManager defaultManager];
564
+ [fm removeItemAtPath:tempPath error:nil];
565
+ if (![fm createFileAtPath:tempPath contents:nil attributes:nil]) {
566
+ [input closeFile];
567
+ return [NSError errorWithDomain:@"RNMultipartUploader" code:-1 userInfo:@{NSLocalizedDescriptionKey: @"Cannot create temporary chunk file"}];
568
+ }
569
+ NSFileHandle *output = [NSFileHandle fileHandleForWritingAtPath:tempPath];
570
+ if (!output) {
571
+ [input closeFile];
572
+ return [NSError errorWithDomain:@"RNMultipartUploader" code:-1 userInfo:@{NSLocalizedDescriptionKey: @"Cannot open temporary chunk file for writing"}];
573
+ }
574
+
575
+ NSError *failure = nil;
576
+ @try {
577
+ [input seekToFileOffset:(unsigned long long)offset];
578
+ int64_t remaining = size;
579
+ while (remaining > 0 && !failure) {
580
+ @autoreleasepool {
581
+ NSData *block = [input readDataOfLength:(NSUInteger)MIN((int64_t)COPY_BLOCK_SIZE, remaining)];
582
+ if (block.length == 0) {
583
+ failure = [NSError errorWithDomain:@"RNMultipartUploader" code:-1 userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Failed to read chunk %ld from file", (long)index]}];
584
+ } else {
585
+ [output writeData:block];
586
+ remaining -= (int64_t)block.length;
587
+ }
588
+ }
589
+ }
590
+ [output synchronizeFile];
591
+ } @catch (NSException *exception) {
592
+ failure = [NSError errorWithDomain:@"RNMultipartUploader" code:-1 userInfo:@{NSLocalizedDescriptionKey: exception.reason ?: @"Exception while copying chunk"}];
593
+ }
594
+ [output closeFile];
595
+ [input closeFile];
596
+
597
+ if (failure) [fm removeItemAtPath:tempPath error:nil];
598
+ return failure;
599
+ }
600
+
601
+ - (BOOL)enqueueChunk:(NSInteger)index session:(RNMultipartSession *)session {
602
+ NSError *error = [self prepareChunk:index session:session];
603
+ if (error) {
604
+ [self failSession:session error:error];
605
+ return NO;
606
+ }
607
+
608
+ NSURL *url = [NSURL URLWithString:session.urls[index]];
609
+ NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url];
610
+ [request setHTTPMethod:@"PUT"];
611
+ [request setValue:@"application/octet-stream" forHTTPHeaderField:@"Content-Type"];
612
+ [request setValue:[NSString stringWithFormat:@"%lld", (long long)[session sizeOfChunk:index]] forHTTPHeaderField:@"Content-Length"];
613
+
614
+ NSURLSession *urlSession = [self sessionForAppGroup:session.appGroup maxParallel:session.maxParallel];
615
+ NSURLSessionUploadTask *task = [urlSession uploadTaskWithRequest:request fromFile:[NSURL fileURLWithPath:[session chunkPath:index]]];
616
+ task.taskDescription = [NSString stringWithFormat:@"%@:%ld", session.uploadId, (long)index];
617
+ session.activeTasks[@(index)] = task;
618
+ [task resume];
619
+ return YES;
620
+ }
621
+
622
+ - (void)retryOrFail:(RNMultipartSession *)session index:(NSInteger)index error:(NSError *)error retryable:(BOOL)retryable {
623
+ NSInteger retries = [session.retryCounts[@(index)] integerValue];
624
+ if (!retryable || retries >= MAX_RETRIES) {
625
+ [self failSession:session error:error];
626
+ return;
627
+ }
628
+
629
+ session.retryCounts[@(index)] = @(retries + 1);
630
+ [session.pendingRetries addObject:@(index)];
631
+ NSTimeInterval delay = pow(2.0, (double)retries);
632
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delay * NSEC_PER_SEC)), self.queue, ^{
633
+ if (![session.pendingRetries containsObject:@(index)]) return;
634
+ [session.pendingRetries removeObject:@(index)];
635
+ [self enqueueRemaining:session];
636
+ });
637
+ }
638
+
639
+ - (void)finishSuccess:(RNMultipartSession *)session {
640
+ if (![session isActive]) return;
641
+ session.status = STATUS_COMPLETED;
642
+ [session cleanupTempFiles];
643
+ [self persist];
644
+
645
+ NSDictionary *result = @{
646
+ @"uploadId": session.uploadId,
647
+ @"success": @YES,
648
+ @"etags": [session.etags copy]
649
+ };
650
+
651
+ [self emit:@"RNFileUploader-multipart-completed" body:result buffered:YES];
652
+
653
+ if (session.resolve) {
654
+ session.resolve(result);
655
+ }
656
+ session.resolve = nil;
657
+ session.reject = nil;
658
+ }
659
+
660
+ - (void)failSession:(RNMultipartSession *)session error:(NSError *)error {
661
+ if (![session isActive]) return;
662
+ session.status = STATUS_FAILED;
663
+ session.errorMessage = error.localizedDescription ?: @"Upload failed";
664
+ [self cancelActiveTasks:session];
665
+ [session cleanupTempFiles];
666
+ [self persist];
667
+
668
+ [self emit:@"RNFileUploader-multipart-error" body:@{
669
+ @"uploadId": session.uploadId,
670
+ @"error": session.errorMessage,
671
+ @"etags": [session.etags copy]
672
+ } buffered:YES];
673
+
674
+ if (session.reject) {
675
+ session.reject(@"MULTIPART_UPLOAD_ERROR", session.errorMessage, error);
676
+ }
677
+ session.resolve = nil;
678
+ session.reject = nil;
679
+ }
680
+
681
+ #pragma mark - NSURLSessionTaskDelegate
682
+
683
+ - (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error {
684
+ dispatch_async(self.queue, ^{
685
+ NSString *uploadId = nil;
686
+ NSInteger index = -1;
687
+ if (![self parseDescription:task.taskDescription uploadId:&uploadId index:&index]) return;
688
+
689
+ RNMultipartSession *s = self.sessions[uploadId];
690
+ if (!s || ![s.status isEqualToString:STATUS_RUNNING] || index < 0 || index >= (NSInteger)s.urls.count) return;
691
+
692
+ NSURLSessionUploadTask *tracked = s.activeTasks[@(index)];
693
+ if (tracked && tracked != task) return;
694
+ if (!tracked && error.code == NSURLErrorCancelled) return;
695
+
696
+ [s.activeTasks removeObjectForKey:@(index)];
697
+ [[NSFileManager defaultManager] removeItemAtPath:[s chunkPath:index] error:nil];
698
+
699
+ if (error) {
700
+ [self retryOrFail:s index:index error:error retryable:YES];
701
+ return;
702
+ }
703
+
704
+ NSHTTPURLResponse *response = (NSHTTPURLResponse *)task.response;
705
+ NSInteger status = response.statusCode;
706
+ if (status < 200 || status >= 300) {
707
+ BOOL retryable = status >= 500 || status == 408 || status == 429;
708
+ NSString *msg = [NSString stringWithFormat:@"HTTP status %ld on chunk %ld", (long)status, (long)index];
709
+ [self retryOrFail:s index:index error:[NSError errorWithDomain:@"RNMultipartUploader" code:status userInfo:@{NSLocalizedDescriptionKey: msg}] retryable:retryable];
710
+ return;
711
+ }
712
+
713
+ NSString *etag = [response valueForHTTPHeaderField:@"ETag"] ?: @"";
714
+ etag = [etag stringByTrimmingCharactersInSet:[NSCharacterSet characterSetWithCharactersInString:@"\""]];
715
+ if (etag.length == 0) {
716
+ [self failSession:s error:[NSError errorWithDomain:@"RNMultipartUploader" code:-1 userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Missing ETag on chunk %ld", (long)index]}]];
717
+ return;
718
+ }
719
+
720
+ s.etags[index] = etag;
721
+ [self persist];
722
+ [self emitProgress:s chunkIndex:index etag:etag];
723
+
724
+ if ([s completedCount] == (NSInteger)s.urls.count) {
725
+ [self finishSuccess:s];
726
+ }
727
+ });
728
+ }
729
+
730
+ - (void)enqueueRunningSessions {
731
+ for (RNMultipartSession *s in self.sessions.allValues) {
732
+ if ([s.status isEqualToString:STATUS_RUNNING]) {
733
+ [self enqueueRemaining:s];
734
+ }
735
+ }
736
+ }
737
+
738
+ - (void)URLSessionDidFinishEventsForBackgroundURLSession:(NSURLSession *)session {
739
+ dispatch_async(self.queue, ^{
740
+ if (self.tasksLoaded) {
741
+ [self enqueueRunningSessions];
742
+ } else {
743
+ self.finishEventsPending = YES;
744
+ }
745
+ });
746
+ }
747
+
748
+ @end