diff --git a/Downloader.h b/Downloader.h index bd0c8c1a..27d1157e 100644 --- a/Downloader.h +++ b/Downloader.h @@ -4,6 +4,7 @@ typedef void (^DownloadCompleteCallback)(NSNumber*, NSNumber*); typedef void (^ErrorCallback)(NSError*); typedef void (^BeginCallback)(NSNumber*, NSNumber*, NSDictionary*); typedef void (^ProgressCallback)(NSNumber*, NSNumber*); +typedef void (^ResumableCallback)(); @interface RNFSDownloadParams : NSObject @@ -14,6 +15,7 @@ typedef void (^ProgressCallback)(NSNumber*, NSNumber*); @property (copy) ErrorCallback errorCallback; // Something went wrong @property (copy) BeginCallback beginCallback; // Download has started (headers received) @property (copy) ProgressCallback progressCallback; // Download is progressing +@property (copy) ResumableCallback resumableCallback; // Download has stopped but is resumable @property bool background; // Whether to continue download when app is in background @property (copy) NSNumber* progressDivider; @@ -22,7 +24,9 @@ typedef void (^ProgressCallback)(NSNumber*, NSNumber*); @interface RNFSDownloader : NSObject -- (void)downloadFile:(RNFSDownloadParams*)params; +- (NSString *)downloadFile:(RNFSDownloadParams*)params; - (void)stopDownload; +- (void)resumeDownload; +- (BOOL)isResumable; @end diff --git a/Downloader.m b/Downloader.m index e42177cb..cd5ea99c 100644 --- a/Downloader.m +++ b/Downloader.m @@ -9,11 +9,12 @@ @interface RNFSDownloader() @property (copy) RNFSDownloadParams* params; @property (retain) NSURLSession* session; -@property (retain) NSURLSessionTask* task; +@property (retain) NSURLSessionDownloadTask* task; @property (retain) NSNumber* statusCode; @property (retain) NSNumber* lastProgressValue; @property (retain) NSNumber* contentLength; @property (retain) NSNumber* bytesWritten; +@property (retain) NSData* resumeData; @property (retain) NSFileHandle* fileHandle; @@ -21,9 +22,11 @@ @interface RNFSDownloader() @implementation RNFSDownloader -- (void)downloadFile:(RNFSDownloadParams*)params +- (NSString *)downloadFile:(RNFSDownloadParams*)params { - _params = params; + NSString *uuid = nil; + + _params = params; _bytesWritten = 0; @@ -36,14 +39,15 @@ - (void)downloadFile:(RNFSDownloadParams*)params NSError* error = [NSError errorWithDomain:@"Downloader" code:NSURLErrorFileDoesNotExist userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat: @"Failed to create target file at path: %@", _params.toFile]}]; - return _params.errorCallback(error); + _params.errorCallback(error); + return nil; } else { [_fileHandle closeFile]; } NSURLSessionConfiguration *config; if (_params.background) { - NSString *uuid = [[NSUUID UUID] UUIDString]; + uuid = [[NSUUID UUID] UUIDString]; config = [NSURLSessionConfiguration backgroundSessionConfigurationWithIdentifier:uuid]; } else { config = [NSURLSessionConfiguration defaultSessionConfiguration]; @@ -54,6 +58,8 @@ - (void)downloadFile:(RNFSDownloadParams*)params _session = [NSURLSession sessionWithConfiguration:config delegate:self delegateQueue:nil]; _task = [_session downloadTaskWithURL:url]; [_task resume]; + + return uuid; } - (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didWriteData:(int64_t)bytesWritten totalBytesWritten:(int64_t)totalBytesWritten totalBytesExpectedToWrite:(int64_t)totalBytesExpectedToWrite @@ -107,23 +113,48 @@ - (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTas - (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error { if (error && error.code != -999) { - _params.errorCallback(error); + _resumeData = error.userInfo[NSURLSessionDownloadTaskResumeData]; + if (_resumeData != nil) { + _params.resumableCallback(); + } else { + _params.errorCallback(error); + } } } - (void)stopDownload { if (_task.state == NSURLSessionTaskStateRunning) { - [_task cancel]; - - NSError *error = [NSError errorWithDomain:@"RNFS" - code:@"Aborted" - userInfo:@{ - NSLocalizedDescriptionKey: @"Download has been aborted" - }]; + [_task cancelByProducingResumeData:^(NSData * _Nullable resumeData) { + if (resumeData != nil) { + self.resumeData = resumeData; + _params.resumableCallback(); + } else { + NSError *error = [NSError errorWithDomain:@"RNFS" + code:@"Aborted" + userInfo:@{ + NSLocalizedDescriptionKey: @"Download has been aborted" + }]; + + _params.errorCallback(error); + } + }]; - return _params.errorCallback(error); } } +- (void)resumeDownload +{ + if (_resumeData != nil) { + _task = [_session downloadTaskWithResumeData:_resumeData]; + [_task resume]; + _resumeData = nil; + } +} + +- (BOOL)isResumable +{ + return _resumeData != nil; +} + @end diff --git a/FS.common.js b/FS.common.js index f5b76386..2da7c554 100755 --- a/FS.common.js +++ b/FS.common.js @@ -212,10 +212,22 @@ var RNFS = { RNFSManager.stopDownload(jobId); }, + resumeDownload(jobId: number): void { + RNFSManager.resumeDownload(jobId); + }, + + isResumable(jobId: number): Promise { + return RNFSManager.isResumable(jobId); + }, + stopUpload(jobId: number): void { RNFSManager.stopUpload(jobId); }, + completeHandlerIOS(jobId: number): void { + return RNFSManager.completeHandlerIOS(jobId); + }, + readDir(dirpath: string): Promise { return readDirGeneric(dirpath, RNFSManager.readDir); }, @@ -428,6 +440,10 @@ var RNFS = { subscriptions.push(NativeAppEventEmitter.addListener('DownloadProgress-' + jobId, options.progress)); } + if (options.resumable) { + subscriptions.push(NativeAppEventEmitter.addListener('DownloadResumable-' + jobId, options.resumable)); + } + var bridgeOptions = { jobId: jobId, fromUrl: options.fromUrl, diff --git a/README.md b/README.md index 2b48cfe1..c3d55aa5 100644 --- a/README.md +++ b/README.md @@ -460,6 +460,7 @@ type DownloadFileOptions = { progressDivider?: number; begin?: (res: DownloadBeginCallbackResult) => void; progress?: (res: DownloadProgressCallbackResult) => void; + resumable?: () => void; // only supported on iOS yet connectionTimeout?: number // only supported on Android yet readTimeout?: number // only supported on Android yet }; @@ -502,17 +503,36 @@ Use it for performance issues. If `progressDivider` = 0, you will receive all `progressCallback` calls, default value is 0. (IOS only): `options.background` (`Boolean`) - Whether to continue downloads when the app is not focused (default: `false`) - This option is currently only available for iOS, and you must [enable - background fetch](https://www.objc.io/issues/5-ios7/multitasking/#background-fetch) - for your project in XCode. You only need to enable background fetch in `Info.plist` and set - the fetch interval in `didFinishLaunchingWithOptions`. The `performFetchWithCompletionHandler` - callback is handled by RNFS. + This option is currently only available for iOS, see the [Background Downloads Tutorial (iOS)](#background-downloads-tutorial-ios) section. +(IOS only): If `options.resumable` is provided, it will be invoked when the download has stopped and and can be resumed using `resumeDownload()`. ### `stopDownload(jobId: number): void` Abort the current download job with this ID. The partial file will remain on the filesystem. +### (iOS only) `resumeDownload(jobId: number): void` + +Resume the current download job with this ID. + +### (iOS only) `isResumable(jobId: number): Promise` + +Check if the the download job with this ID is resumable with `resumeDownload()`. + +Example: + +``` +if (await RNFS.isResumable(jobId) { + RNFS.resumeDownload(jobId) +} +``` + +### (iOS only) `completeHandlerIOS(jobId: number): void` + +For use when using background downloads, tell iOS you are done handling a completed download. + +Read more about background donwloads in the [Background Downloads Tutorial (iOS)](#background-downloads-tutorial-ios) section. + ### (iOS only) `uploadFiles(options: UploadFileOptions): { jobId: number, promise: Promise }` `options` (`Object`) - An object containing named parameters @@ -593,7 +613,36 @@ This directory can be used to to share files between application of the same dev Invalid group identifier will cause a rejection. -For more information read the [Adding an App to an App Group](https://developer.apple.com/library/content/documentation/Miscellaneous/Reference/EntitlementKeyReference/Chapters/EnablingAppSandbox.html#//apple_ref/doc/uid/TP40011195-CH4-SW19) section. +For more information read the [Adding an App to an App Group](https://developer.apple.com/library/content/documentation/Miscellaneous/Reference/EntitlementKeyReference/Chapters/EnablingAppSandbox.html#//apple_ref/doc/uid/TP40011195-CH4-SW19) section. + +## Background Downloads Tutorial (iOS) + +Background downloads in iOS require a bit of a setup. + +First, in your `AppDelegate.m` file add the following: + +``` +#import + +... + +- (void)application:(UIApplication *)application handleEventsForBackgroundURLSession:(NSString *)identifier completionHandler:(void (^)())completionHandler +{ + [RNFSManager setCompletionHandlerForIdentifier:identifier completionHandler:completionHandler]; +} + +``` + +The `handleEventsForBackgroundURLSession` method is called when a background download is done and your app is not in the foreground. + +We need to pass the `completionHandler` to RNFS along with its `identifier`. + +The JavaScript will continue to work as usual when the download is done but now you must call `RNFS.completeHandlerIOS(jobId)` when you're done handling the download (show a notification etc.) + +**BE AWARE!** iOS will give about 30 sec. to run your code after `handleEventsForBackgroundURLSession` is called and until `completionHandler` +is triggered so don't do anything that might take a long time (like unzipping), you will be able to do it after the user re-launces the app, +otherwide iOS will terminate your app. + ## Test / Demo app diff --git a/RNFSManager.h b/RNFSManager.h index 11ac97a3..b0189ed5 100644 --- a/RNFSManager.h +++ b/RNFSManager.h @@ -9,6 +9,10 @@ #import #import +typedef void (^CompletionHandler)(); + @interface RNFSManager : NSObject ++(void)setCompletionHandlerForIdentifier: (NSString *)identifier completionHandler: (CompletionHandler)completionHandler; + @end diff --git a/RNFSManager.m b/RNFSManager.m index 2301473f..f9acc690 100755 --- a/RNFSManager.m +++ b/RNFSManager.m @@ -23,12 +23,15 @@ @interface RNFSManager() @property (retain) NSMutableDictionary* downloaders; +@property (retain) NSMutableDictionary* uuids; @property (retain) NSMutableDictionary* uploaders; @end @implementation RNFSManager +static NSMutableDictionary *completionHandlers; + @synthesize bridge = _bridge; RCT_EXPORT_MODULE(); @@ -454,14 +457,22 @@ - (dispatch_queue_t)methodQueue @"contentLength": contentLength, @"bytesWritten": bytesWritten}]; }; + + params.resumableCallback = ^() { + [self.bridge.eventDispatcher sendAppEventWithName:[NSString stringWithFormat:@"DownloadResumable-%@", jobId] body:nil]; + }; if (!self.downloaders) self.downloaders = [[NSMutableDictionary alloc] init]; RNFSDownloader* downloader = [RNFSDownloader alloc]; - [downloader downloadFile:params]; + NSString *uuid = [downloader downloadFile:params]; [self.downloaders setValue:downloader forKey:[jobId stringValue]]; + if (uuid) { + if (!self.uuids) self.uuids = [[NSMutableDictionary alloc] init]; + [self.uuids setValue:uuid forKey:[jobId stringValue]]; + } } RCT_EXPORT_METHOD(stopDownload:(nonnull NSNumber *)jobId) @@ -473,6 +484,44 @@ - (dispatch_queue_t)methodQueue } } +RCT_EXPORT_METHOD(resumeDownload:(nonnull NSNumber *)jobId) +{ + RNFSDownloader* downloader = [self.downloaders objectForKey:[jobId stringValue]]; + + if (downloader != nil) { + [downloader resumeDownload]; + } +} + +RCT_EXPORT_METHOD(isResumable:(nonnull NSNumber *)jobId + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject +) +{ + RNFSDownloader* downloader = [self.downloaders objectForKey:[jobId stringValue]]; + + if (downloader != nil) { + resolve([NSNumber numberWithBool:[downloader isResumable]]); + } else { + resolve([NSNumber numberWithBool:NO]); + } +} + +RCT_EXPORT_METHOD(completeHandlerIOS:(nonnull NSNumber *)jobId + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +{ + if (self.uuids) { + NSString *uuid = [self.uuids objectForKey:[jobId stringValue]]; + CompletionHandler completionHandler = [completionHandlers objectForKey:uuid]; + if (completionHandler) { + completionHandler(); + [completionHandlers removeObjectForKey:uuid]; + } + } + resolve(nil); +} + RCT_EXPORT_METHOD(uploadFiles:(NSDictionary *)options resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) @@ -746,4 +795,10 @@ - (NSDictionary *)constantsToExport }; } ++(void)setCompletionHandlerForIdentifier: (NSString *)identifier completionHandler: (CompletionHandler)completionHandler +{ + if (!completionHandlers) completionHandlers = [[NSMutableDictionary alloc] init]; + [completionHandlers setValue:completionHandler forKey:identifier]; +} + @end