Skip to content

Commit

Permalink
Show bundle download progress on iOS
Browse files Browse the repository at this point in the history
Summary:
This shows progress for the download of the JS bundle (different from the packager transform progress that we show already). This is useful especially when loading the JS bundle from a remote source or when developing on device (on simulator + localhost it pretty much just downloads instantly). This will be nice for the expo client since all bundles are loaded over the network and can take several seconds to load.

This depends on facebook/metro#28 to work but won't crash or anything without it, it just won't show the progress percentage.

![img_05070155d2cc-1](https://user-images.githubusercontent.com/2677334/28293828-2c08d974-6b24-11e7-9334-e106ef3326d9.jpeg)

**Test plan**
Tested that bundle download progress is shown properly in RNTester on both localhost + simulator and on real device with network conditionner to simulate a slow loading bundle.

Tested that it doesn't cause issues if the packager doesn't send the Content-Length header.
Closes #15066

Differential Revision: D5449073

Pulled By: shergin

fbshipit-source-id: 43a8fb559393bbdc04f77916500e21898695bac5
  • Loading branch information
janicduplessis authored and facebook-github-bot committed Aug 14, 2017
1 parent b58207e commit ef23d2b
Show file tree
Hide file tree
Showing 6 changed files with 95 additions and 17 deletions.
16 changes: 8 additions & 8 deletions RNTester/RNTesterUnitTests/RCTMultipartStreamReaderTests.m
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,12 @@ - (void)testSimpleCase {
NSInputStream *inputStream = [NSInputStream inputStreamWithData:[response dataUsingEncoding:NSUTF8StringEncoding]];
RCTMultipartStreamReader *reader = [[RCTMultipartStreamReader alloc] initWithInputStream:inputStream boundary:@"sample_boundary"];
__block NSInteger count = 0;
BOOL success = [reader readAllParts:^(NSDictionary *headers, NSData *content, BOOL done) {
BOOL success = [reader readAllPartsWithCompletionCallback:^(NSDictionary *headers, NSData *content, BOOL done) {
XCTAssertTrue(done);
XCTAssertEqualObjects(headers[@"Content-Type"], @"application/json; charset=utf-8");
XCTAssertEqualObjects([[NSString alloc] initWithData:content encoding:NSUTF8StringEncoding], @"{}");
count++;
}];
} progressCallback: nil];
XCTAssertTrue(success);
XCTAssertEqual(count, 1);
}
Expand All @@ -56,13 +56,13 @@ - (void)testMultipleParts {
NSInputStream *inputStream = [NSInputStream inputStreamWithData:[response dataUsingEncoding:NSUTF8StringEncoding]];
RCTMultipartStreamReader *reader = [[RCTMultipartStreamReader alloc] initWithInputStream:inputStream boundary:@"sample_boundary"];
__block NSInteger count = 0;
BOOL success = [reader readAllParts:^(__unused NSDictionary *headers, NSData *content, BOOL done) {
BOOL success = [reader readAllPartsWithCompletionCallback:^(__unused NSDictionary *headers, NSData *content, BOOL done) {
count++;
XCTAssertEqual(done, count == 3);
NSString *expectedBody = [NSString stringWithFormat:@"%ld", (long)count];
NSString *actualBody = [[NSString alloc] initWithData:content encoding:NSUTF8StringEncoding];
XCTAssertEqualObjects(actualBody, expectedBody);
}];
} progressCallback:nil];
XCTAssertTrue(success);
XCTAssertEqual(count, 3);
}
Expand All @@ -73,9 +73,9 @@ - (void)testNoDelimiter {
NSInputStream *inputStream = [NSInputStream inputStreamWithData:[response dataUsingEncoding:NSUTF8StringEncoding]];
RCTMultipartStreamReader *reader = [[RCTMultipartStreamReader alloc] initWithInputStream:inputStream boundary:@"sample_boundary"];
__block NSInteger count = 0;
BOOL success = [reader readAllParts:^(__unused NSDictionary *headers, __unused NSData *content, __unused BOOL done) {
BOOL success = [reader readAllPartsWithCompletionCallback:^(__unused NSDictionary *headers, __unused NSData *content, __unused BOOL done) {
count++;
}];
} progressCallback:nil];
XCTAssertFalse(success);
XCTAssertEqual(count, 0);
}
Expand All @@ -93,9 +93,9 @@ - (void)testNoCloseDelimiter {
NSInputStream *inputStream = [NSInputStream inputStreamWithData:[response dataUsingEncoding:NSUTF8StringEncoding]];
RCTMultipartStreamReader *reader = [[RCTMultipartStreamReader alloc] initWithInputStream:inputStream boundary:@"sample_boundary"];
__block NSInteger count = 0;
BOOL success = [reader readAllParts:^(__unused NSDictionary *headers, __unused NSData *content, __unused BOOL done) {
BOOL success = [reader readAllPartsWithCompletionCallback:^(__unused NSDictionary *headers, __unused NSData *content, __unused BOOL done) {
count++;
}];
} progressCallback:nil];
XCTAssertFalse(success);
XCTAssertEqual(count, 1);
}
Expand Down
16 changes: 15 additions & 1 deletion React/Base/RCTJavaScriptLoader.mm
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,6 @@ static void attemptAsynchronousLoadOfBundleAtURL(NSURL *scriptURL, RCTSourceLoad
return;
}


RCTMultipartDataTask *task = [[RCTMultipartDataTask alloc] initWithURL:scriptURL partHandler:^(NSInteger statusCode, NSDictionary *headers, NSData *data, NSError *error, BOOL done) {
if (!done) {
if (onProgress) {
Expand Down Expand Up @@ -261,6 +260,11 @@ static void attemptAsynchronousLoadOfBundleAtURL(NSURL *scriptURL, RCTSourceLoad
}

onComplete(nil, data, data.length);
} progressHandler:^(NSDictionary *headers, NSNumber *loaded, NSNumber *total) {
// Only care about download progress events for the javascript bundle part.
if ([headers[@"Content-Type"] isEqualToString:@"application/javascript"]) {
onProgress(progressEventFromDownloadProgress(loaded, total));
}
}];

[task startTask];
Expand All @@ -287,6 +291,16 @@ static void attemptAsynchronousLoadOfBundleAtURL(NSURL *scriptURL, RCTSourceLoad
return progress;
}

static RCTLoadingProgress *progressEventFromDownloadProgress(NSNumber *total, NSNumber *done)
{
RCTLoadingProgress *progress = [RCTLoadingProgress new];
progress.status = @"Downloading JavaScript bundle";
// Progress values are in bytes transform them to kilobytes for smaller numbers.
progress.done = done != nil ? @([done integerValue] / 1024) : nil;
progress.total = total != nil ? @([total integerValue] / 1024) : nil;
return progress;
}

static NSDictionary *userInfoForRawResponse(NSString *rawText)
{
NSDictionary *parsedResponse = RCTJSONParse(rawText, nil);
Expand Down
5 changes: 4 additions & 1 deletion React/Base/RCTMultipartDataTask.h
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ typedef void (^RCTMultipartDataTaskCallback)(NSInteger statusCode, NSDictionary

@interface RCTMultipartDataTask : NSObject

- (instancetype)initWithURL:(NSURL *)url partHandler:(RCTMultipartDataTaskCallback)partHandler;
- (instancetype)initWithURL:(NSURL *)url
partHandler:(RCTMultipartDataTaskCallback)partHandler
progressHandler:(RCTMultipartProgressCallback)progressHandler;

- (void)startTask;

@end
10 changes: 7 additions & 3 deletions React/Base/RCTMultipartDataTask.m
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,21 @@ static BOOL isStreamTaskSupported() {
@implementation RCTMultipartDataTask {
NSURL *_url;
RCTMultipartDataTaskCallback _partHandler;
RCTMultipartProgressCallback _progressHandler;
NSInteger _statusCode;
NSDictionary *_headers;
NSString *_boundary;
NSMutableData *_data;
}

- (instancetype)initWithURL:(NSURL *)url partHandler:(RCTMultipartDataTaskCallback)partHandler
- (instancetype)initWithURL:(NSURL *)url
partHandler:(RCTMultipartDataTaskCallback)partHandler
progressHandler:(RCTMultipartProgressCallback)progressHandler
{
if (self = [super init]) {
_url = url;
_partHandler = [partHandler copy];
_progressHandler = [progressHandler copy];
}
return self;
}
Expand Down Expand Up @@ -117,9 +121,9 @@ - (void)URLSession:(__unused NSURLSession *)session
_partHandler = nil;
NSInteger statusCode = _statusCode;

BOOL completed = [reader readAllParts:^(NSDictionary *headers, NSData *content, BOOL done) {
BOOL completed = [reader readAllPartsWithCompletionCallback:^(NSDictionary *headers, NSData *content, BOOL done) {
partHandler(statusCode, headers, content, nil, done);
}];
} progressCallback:_progressHandler];
if (!completed) {
partHandler(statusCode, nil, nil, [NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorCancelled userInfo:nil], YES);
}
Expand Down
4 changes: 3 additions & 1 deletion React/Base/RCTMultipartStreamReader.h
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@
#import <Foundation/Foundation.h>

typedef void (^RCTMultipartCallback)(NSDictionary *headers, NSData *content, BOOL done);
typedef void (^RCTMultipartProgressCallback)(NSDictionary *headers, NSNumber *loaded, NSNumber *total);


// RCTMultipartStreamReader can be used to parse responses with Content-Type: multipart/mixed
// See https://www.w3.org/Protocols/rfc1341/7_2_Multipart.html
@interface RCTMultipartStreamReader : NSObject

- (instancetype)initWithInputStream:(NSInputStream *)stream boundary:(NSString *)boundary;
- (BOOL)readAllParts:(RCTMultipartCallback)callback;
- (BOOL)readAllPartsWithCompletionCallback:(RCTMultipartCallback)callback
progressCallback:(RCTMultipartProgressCallback)progressCallback;

@end
61 changes: 58 additions & 3 deletions React/Base/RCTMultipartStreamReader.m
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,22 @@

#import "RCTMultipartStreamReader.h"

#import <QuartzCore/CAAnimation.h>

#define CRLF @"\r\n"

@implementation RCTMultipartStreamReader {
__strong NSInputStream *_stream;
__strong NSString *_boundary;
CFTimeInterval _lastDownloadProgress;
}

- (instancetype)initWithInputStream:(NSInputStream *)stream boundary:(NSString *)boundary
{
if (self = [super init]) {
_stream = stream;
_boundary = boundary;
_lastDownloadProgress = CACurrentMediaTime();
}
return self;
}
Expand All @@ -42,12 +46,17 @@ - (NSDictionary *)parseHeaders:(NSData *)data
return headers;
}

- (void)emitChunk:(NSData *)data callback:(RCTMultipartCallback)callback done:(BOOL)done
- (void)emitChunk:(NSData *)data headers:(NSDictionary *)headers callback:(RCTMultipartCallback)callback done:(BOOL)done
{
NSData *marker = [CRLF CRLF dataUsingEncoding:NSUTF8StringEncoding];
NSRange range = [data rangeOfData:marker options:0 range:NSMakeRange(0, data.length)];
if (range.location == NSNotFound) {
callback(nil, data, done);
} else if (headers != nil) {
// If headers were parsed already just use that to avoid doing it twice.
NSInteger bodyStart = range.location + marker.length;
NSData *bodyData = [data subdataWithRange:NSMakeRange(bodyStart, data.length - bodyStart)];
callback(headers, bodyData, done);
} else {
NSData *headersData = [data subdataWithRange:NSMakeRange(0, range.location)];
NSInteger bodyStart = range.location + marker.length;
Expand All @@ -56,14 +65,35 @@ - (void)emitChunk:(NSData *)data callback:(RCTMultipartCallback)callback done:(B
}
}

- (BOOL)readAllParts:(RCTMultipartCallback)callback
- (void)emitProgress:(NSDictionary *)headers
contentLength:(NSUInteger)contentLength
final:(BOOL)final
callback:(RCTMultipartProgressCallback)callback
{
if (headers == nil) {
return;
}
// Throttle progress events so we don't send more that around 60 per second.
CFTimeInterval currentTime = CACurrentMediaTime();

NSUInteger headersContentLength = headers[@"Content-Length"] != nil ? [headers[@"Content-Length"] unsignedIntValue] : 0;
if (callback && (currentTime - _lastDownloadProgress > 0.016 || final)) {
_lastDownloadProgress = currentTime;
callback(headers, @(headersContentLength), @(contentLength));
}
}

- (BOOL)readAllPartsWithCompletionCallback:(RCTMultipartCallback)callback
progressCallback:(RCTMultipartProgressCallback)progressCallback
{
NSInteger chunkStart = 0;
NSInteger bytesSeen = 0;

NSData *delimiter = [[NSString stringWithFormat:@"%@--%@%@", CRLF, _boundary, CRLF] dataUsingEncoding:NSUTF8StringEncoding];
NSData *closeDelimiter = [[NSString stringWithFormat:@"%@--%@--%@", CRLF, _boundary, CRLF] dataUsingEncoding:NSUTF8StringEncoding];
NSMutableData *content = [[NSMutableData alloc] initWithCapacity:1];
NSDictionary *currentHeaders = nil;
NSUInteger currentHeadersLength = 0;

const NSUInteger bufferLen = 4 * 1024;
uint8_t buffer[bufferLen];
Expand All @@ -75,13 +105,32 @@ - (BOOL)readAllParts:(RCTMultipartCallback)callback
// to allow for the edge case when the delimiter is cut by read call
NSInteger searchStart = MAX(bytesSeen - (NSInteger)closeDelimiter.length, chunkStart);
NSRange remainingBufferRange = NSMakeRange(searchStart, content.length - searchStart);

// Check for delimiters.
NSRange range = [content rangeOfData:delimiter options:0 range:remainingBufferRange];
if (range.location == NSNotFound) {
isCloseDelimiter = YES;
range = [content rangeOfData:closeDelimiter options:0 range:remainingBufferRange];
}

if (range.location == NSNotFound) {
if (currentHeaders == nil) {
// Check for the headers delimiter.
NSData *headersMarker = [CRLF CRLF dataUsingEncoding:NSUTF8StringEncoding];
NSRange headersRange = [content rangeOfData:headersMarker options:0 range:remainingBufferRange];
if (headersRange.location != NSNotFound) {
NSData *headersData = [content subdataWithRange:NSMakeRange(chunkStart, headersRange.location - chunkStart)];
currentHeadersLength = headersData.length;
currentHeaders = [self parseHeaders:headersData];
}
} else {
// When headers are loaded start sending progress callbacks.
[self emitProgress:currentHeaders
contentLength:content.length - currentHeadersLength
final:NO
callback:progressCallback];
}

bytesSeen = content.length;
NSInteger bytesRead = [_stream read:buffer maxLength:bufferLen];
if (bytesRead <= 0 || _stream.streamError) {
Expand All @@ -98,7 +147,13 @@ - (BOOL)readAllParts:(RCTMultipartCallback)callback
// Ignore preamble
if (chunkStart > 0) {
NSData *chunk = [content subdataWithRange:NSMakeRange(chunkStart, length)];
[self emitChunk:chunk callback:callback done:isCloseDelimiter];
[self emitProgress:currentHeaders
contentLength:chunk.length - currentHeadersLength
final:YES
callback:progressCallback];
[self emitChunk:chunk headers:currentHeaders callback:callback done:isCloseDelimiter];
currentHeaders = nil;
currentHeadersLength = 0;
}

if (isCloseDelimiter) {
Expand Down

2 comments on commit ef23d2b

@gre
Copy link
Contributor

@gre gre commented on ef23d2b Sep 1, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When I try to migrate, I get a crash in emitProgress, at line

  NSUInteger headersContentLength = headers[@"Content-Length"] != nil ? [headers[@"Content-Length"] unsignedIntValue] : 0;
-[NSTaggedPointerString unsignedIntValue]: unrecognized selector sent to instance 0xa313033333231327

screen shot 2017-09-01 at 20 46 32

@chirag04
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gre can you try #15755?

Please sign in to comment.