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

Show bundle download progress on iOS #15066

Closed
16 changes: 8 additions & 8 deletions RNTester/RNTesterUnitTests/RCTMultipartStreamReaderTests.m
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
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;
Copy link
Contributor

Choose a reason for hiding this comment

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

Why do we convert to kilobytes here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Mostly just to make the numbers smaller, we don't really have a way to show units with the current setup so it's no really clear what the numbers are anyway.

Copy link
Contributor

Choose a reason for hiding this comment

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

Why we don't make them smaller right before displaying them?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is as close as we get to displaying them, this is also used for showing the packager module transform progress so it just takes a progress number and the total then formats it to something like "10% (10/100)".

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