Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

(iOS only) Resumable downloads and better background downloads handling #335

Merged
merged 6 commits into from
Dec 10, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion Downloader.h
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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;

Expand All @@ -22,7 +24,9 @@ typedef void (^ProgressCallback)(NSNumber*, NSNumber*);

@interface RNFSDownloader : NSObject <NSURLSessionDelegate, NSURLSessionDownloadDelegate>

- (void)downloadFile:(RNFSDownloadParams*)params;
- (NSString *)downloadFile:(RNFSDownloadParams*)params;
- (void)stopDownload;
- (void)resumeDownload;
- (BOOL)isResumable;

@end
59 changes: 45 additions & 14 deletions Downloader.m
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,24 @@ @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;

@end

@implementation RNFSDownloader

- (void)downloadFile:(RNFSDownloadParams*)params
- (NSString *)downloadFile:(RNFSDownloadParams*)params
{
_params = params;
NSString *uuid = nil;

_params = params;

_bytesWritten = 0;

Expand All @@ -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];
Expand All @@ -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
Expand Down Expand Up @@ -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
16 changes: 16 additions & 0 deletions FS.common.js
Original file line number Diff line number Diff line change
Expand Up @@ -212,10 +212,22 @@ var RNFS = {
RNFSManager.stopDownload(jobId);
},

resumeDownload(jobId: number): void {
RNFSManager.resumeDownload(jobId);
},

isResumable(jobId: number): Promise<bool> {
return RNFSManager.isResumable(jobId);
},

stopUpload(jobId: number): void {
RNFSManager.stopUpload(jobId);
},

completeHandlerIOS(jobId: number): void {
return RNFSManager.completeHandlerIOS(jobId);
},

readDir(dirpath: string): Promise<ReadDirItem[]> {
return readDirGeneric(dirpath, RNFSManager.readDir);
},
Expand Down Expand Up @@ -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,
Expand Down
61 changes: 55 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
Expand Down Expand Up @@ -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<Paste>)
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<bool>`

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<UploadResult> }`

`options` (`Object`) - An object containing named parameters
Expand Down Expand Up @@ -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 <RNFSManager.h>

...

- (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

Expand Down
4 changes: 4 additions & 0 deletions RNFSManager.h
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
#import <React/RCTBridgeModule.h>
#import <React/RCTLog.h>

typedef void (^CompletionHandler)();

@interface RNFSManager : NSObject <RCTBridgeModule>

+(void)setCompletionHandlerForIdentifier: (NSString *)identifier completionHandler: (CompletionHandler)completionHandler;

@end
57 changes: 56 additions & 1 deletion RNFSManager.m
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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