Skip to content

Commit fe4852d

Browse files
janicduplessiside
authored andcommitted
Show bundle download progress on iOS
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 facebook#15066 Differential Revision: D5449073 Pulled By: shergin fbshipit-source-id: 43a8fb559393bbdc04f77916500e21898695bac5
1 parent fe0ce84 commit fe4852d

File tree

6 files changed

+95
-17
lines changed

6 files changed

+95
-17
lines changed

RNTester/RNTesterUnitTests/RCTMultipartStreamReaderTests.m

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,12 @@ - (void)testSimpleCase {
3131
NSInputStream *inputStream = [NSInputStream inputStreamWithData:[response dataUsingEncoding:NSUTF8StringEncoding]];
3232
RCTMultipartStreamReader *reader = [[RCTMultipartStreamReader alloc] initWithInputStream:inputStream boundary:@"sample_boundary"];
3333
__block NSInteger count = 0;
34-
BOOL success = [reader readAllParts:^(NSDictionary *headers, NSData *content, BOOL done) {
34+
BOOL success = [reader readAllPartsWithCompletionCallback:^(NSDictionary *headers, NSData *content, BOOL done) {
3535
XCTAssertTrue(done);
3636
XCTAssertEqualObjects(headers[@"Content-Type"], @"application/json; charset=utf-8");
3737
XCTAssertEqualObjects([[NSString alloc] initWithData:content encoding:NSUTF8StringEncoding], @"{}");
3838
count++;
39-
}];
39+
} progressCallback: nil];
4040
XCTAssertTrue(success);
4141
XCTAssertEqual(count, 1);
4242
}
@@ -56,13 +56,13 @@ - (void)testMultipleParts {
5656
NSInputStream *inputStream = [NSInputStream inputStreamWithData:[response dataUsingEncoding:NSUTF8StringEncoding]];
5757
RCTMultipartStreamReader *reader = [[RCTMultipartStreamReader alloc] initWithInputStream:inputStream boundary:@"sample_boundary"];
5858
__block NSInteger count = 0;
59-
BOOL success = [reader readAllParts:^(__unused NSDictionary *headers, NSData *content, BOOL done) {
59+
BOOL success = [reader readAllPartsWithCompletionCallback:^(__unused NSDictionary *headers, NSData *content, BOOL done) {
6060
count++;
6161
XCTAssertEqual(done, count == 3);
6262
NSString *expectedBody = [NSString stringWithFormat:@"%ld", (long)count];
6363
NSString *actualBody = [[NSString alloc] initWithData:content encoding:NSUTF8StringEncoding];
6464
XCTAssertEqualObjects(actualBody, expectedBody);
65-
}];
65+
} progressCallback:nil];
6666
XCTAssertTrue(success);
6767
XCTAssertEqual(count, 3);
6868
}
@@ -73,9 +73,9 @@ - (void)testNoDelimiter {
7373
NSInputStream *inputStream = [NSInputStream inputStreamWithData:[response dataUsingEncoding:NSUTF8StringEncoding]];
7474
RCTMultipartStreamReader *reader = [[RCTMultipartStreamReader alloc] initWithInputStream:inputStream boundary:@"sample_boundary"];
7575
__block NSInteger count = 0;
76-
BOOL success = [reader readAllParts:^(__unused NSDictionary *headers, __unused NSData *content, __unused BOOL done) {
76+
BOOL success = [reader readAllPartsWithCompletionCallback:^(__unused NSDictionary *headers, __unused NSData *content, __unused BOOL done) {
7777
count++;
78-
}];
78+
} progressCallback:nil];
7979
XCTAssertFalse(success);
8080
XCTAssertEqual(count, 0);
8181
}
@@ -93,9 +93,9 @@ - (void)testNoCloseDelimiter {
9393
NSInputStream *inputStream = [NSInputStream inputStreamWithData:[response dataUsingEncoding:NSUTF8StringEncoding]];
9494
RCTMultipartStreamReader *reader = [[RCTMultipartStreamReader alloc] initWithInputStream:inputStream boundary:@"sample_boundary"];
9595
__block NSInteger count = 0;
96-
BOOL success = [reader readAllParts:^(__unused NSDictionary *headers, __unused NSData *content, __unused BOOL done) {
96+
BOOL success = [reader readAllPartsWithCompletionCallback:^(__unused NSDictionary *headers, __unused NSData *content, __unused BOOL done) {
9797
count++;
98-
}];
98+
} progressCallback:nil];
9999
XCTAssertFalse(success);
100100
XCTAssertEqual(count, 1);
101101
}

React/Base/RCTJavaScriptLoader.mm

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,6 @@ static void attemptAsynchronousLoadOfBundleAtURL(NSURL *scriptURL, RCTSourceLoad
198198
return;
199199
}
200200

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

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

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

294+
static RCTLoadingProgress *progressEventFromDownloadProgress(NSNumber *total, NSNumber *done)
295+
{
296+
RCTLoadingProgress *progress = [RCTLoadingProgress new];
297+
progress.status = @"Downloading JavaScript bundle";
298+
// Progress values are in bytes transform them to kilobytes for smaller numbers.
299+
progress.done = done != nil ? @([done integerValue] / 1024) : nil;
300+
progress.total = total != nil ? @([total integerValue] / 1024) : nil;
301+
return progress;
302+
}
303+
290304
static NSDictionary *userInfoForRawResponse(NSString *rawText)
291305
{
292306
NSDictionary *parsedResponse = RCTJSONParse(rawText, nil);

React/Base/RCTMultipartDataTask.h

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@ typedef void (^RCTMultipartDataTaskCallback)(NSInteger statusCode, NSDictionary
1515

1616
@interface RCTMultipartDataTask : NSObject
1717

18-
- (instancetype)initWithURL:(NSURL *)url partHandler:(RCTMultipartDataTaskCallback)partHandler;
18+
- (instancetype)initWithURL:(NSURL *)url
19+
partHandler:(RCTMultipartDataTaskCallback)partHandler
20+
progressHandler:(RCTMultipartProgressCallback)progressHandler;
21+
1922
- (void)startTask;
2023

2124
@end

React/Base/RCTMultipartDataTask.m

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,17 +30,21 @@ static BOOL isStreamTaskSupported() {
3030
@implementation RCTMultipartDataTask {
3131
NSURL *_url;
3232
RCTMultipartDataTaskCallback _partHandler;
33+
RCTMultipartProgressCallback _progressHandler;
3334
NSInteger _statusCode;
3435
NSDictionary *_headers;
3536
NSString *_boundary;
3637
NSMutableData *_data;
3738
}
3839

39-
- (instancetype)initWithURL:(NSURL *)url partHandler:(RCTMultipartDataTaskCallback)partHandler
40+
- (instancetype)initWithURL:(NSURL *)url
41+
partHandler:(RCTMultipartDataTaskCallback)partHandler
42+
progressHandler:(RCTMultipartProgressCallback)progressHandler
4043
{
4144
if (self = [super init]) {
4245
_url = url;
4346
_partHandler = [partHandler copy];
47+
_progressHandler = [progressHandler copy];
4448
}
4549
return self;
4650
}
@@ -117,9 +121,9 @@ - (void)URLSession:(__unused NSURLSession *)session
117121
_partHandler = nil;
118122
NSInteger statusCode = _statusCode;
119123

120-
BOOL completed = [reader readAllParts:^(NSDictionary *headers, NSData *content, BOOL done) {
124+
BOOL completed = [reader readAllPartsWithCompletionCallback:^(NSDictionary *headers, NSData *content, BOOL done) {
121125
partHandler(statusCode, headers, content, nil, done);
122-
}];
126+
} progressCallback:_progressHandler];
123127
if (!completed) {
124128
partHandler(statusCode, nil, nil, [NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorCancelled userInfo:nil], YES);
125129
}

React/Base/RCTMultipartStreamReader.h

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,15 @@
1010
#import <Foundation/Foundation.h>
1111

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

1415

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

1920
- (instancetype)initWithInputStream:(NSInputStream *)stream boundary:(NSString *)boundary;
20-
- (BOOL)readAllParts:(RCTMultipartCallback)callback;
21+
- (BOOL)readAllPartsWithCompletionCallback:(RCTMultipartCallback)callback
22+
progressCallback:(RCTMultipartProgressCallback)progressCallback;
2123

2224
@end

React/Base/RCTMultipartStreamReader.m

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,22 @@
99

1010
#import "RCTMultipartStreamReader.h"
1111

12+
#import <QuartzCore/CAAnimation.h>
13+
1214
#define CRLF @"\r\n"
1315

1416
@implementation RCTMultipartStreamReader {
1517
__strong NSInputStream *_stream;
1618
__strong NSString *_boundary;
19+
CFTimeInterval _lastDownloadProgress;
1720
}
1821

1922
- (instancetype)initWithInputStream:(NSInputStream *)stream boundary:(NSString *)boundary
2023
{
2124
if (self = [super init]) {
2225
_stream = stream;
2326
_boundary = boundary;
27+
_lastDownloadProgress = CACurrentMediaTime();
2428
}
2529
return self;
2630
}
@@ -42,12 +46,17 @@ - (NSDictionary *)parseHeaders:(NSData *)data
4246
return headers;
4347
}
4448

45-
- (void)emitChunk:(NSData *)data callback:(RCTMultipartCallback)callback done:(BOOL)done
49+
- (void)emitChunk:(NSData *)data headers:(NSDictionary *)headers callback:(RCTMultipartCallback)callback done:(BOOL)done
4650
{
4751
NSData *marker = [CRLF CRLF dataUsingEncoding:NSUTF8StringEncoding];
4852
NSRange range = [data rangeOfData:marker options:0 range:NSMakeRange(0, data.length)];
4953
if (range.location == NSNotFound) {
5054
callback(nil, data, done);
55+
} else if (headers != nil) {
56+
// If headers were parsed already just use that to avoid doing it twice.
57+
NSInteger bodyStart = range.location + marker.length;
58+
NSData *bodyData = [data subdataWithRange:NSMakeRange(bodyStart, data.length - bodyStart)];
59+
callback(headers, bodyData, done);
5160
} else {
5261
NSData *headersData = [data subdataWithRange:NSMakeRange(0, range.location)];
5362
NSInteger bodyStart = range.location + marker.length;
@@ -56,14 +65,35 @@ - (void)emitChunk:(NSData *)data callback:(RCTMultipartCallback)callback done:(B
5665
}
5766
}
5867

59-
- (BOOL)readAllParts:(RCTMultipartCallback)callback
68+
- (void)emitProgress:(NSDictionary *)headers
69+
contentLength:(NSUInteger)contentLength
70+
final:(BOOL)final
71+
callback:(RCTMultipartProgressCallback)callback
72+
{
73+
if (headers == nil) {
74+
return;
75+
}
76+
// Throttle progress events so we don't send more that around 60 per second.
77+
CFTimeInterval currentTime = CACurrentMediaTime();
78+
79+
NSUInteger headersContentLength = headers[@"Content-Length"] != nil ? [headers[@"Content-Length"] unsignedIntValue] : 0;
80+
if (callback && (currentTime - _lastDownloadProgress > 0.016 || final)) {
81+
_lastDownloadProgress = currentTime;
82+
callback(headers, @(headersContentLength), @(contentLength));
83+
}
84+
}
85+
86+
- (BOOL)readAllPartsWithCompletionCallback:(RCTMultipartCallback)callback
87+
progressCallback:(RCTMultipartProgressCallback)progressCallback
6088
{
6189
NSInteger chunkStart = 0;
6290
NSInteger bytesSeen = 0;
6391

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

6898
const NSUInteger bufferLen = 4 * 1024;
6999
uint8_t buffer[bufferLen];
@@ -75,13 +105,32 @@ - (BOOL)readAllParts:(RCTMultipartCallback)callback
75105
// to allow for the edge case when the delimiter is cut by read call
76106
NSInteger searchStart = MAX(bytesSeen - (NSInteger)closeDelimiter.length, chunkStart);
77107
NSRange remainingBufferRange = NSMakeRange(searchStart, content.length - searchStart);
108+
109+
// Check for delimiters.
78110
NSRange range = [content rangeOfData:delimiter options:0 range:remainingBufferRange];
79111
if (range.location == NSNotFound) {
80112
isCloseDelimiter = YES;
81113
range = [content rangeOfData:closeDelimiter options:0 range:remainingBufferRange];
82114
}
83115

84116
if (range.location == NSNotFound) {
117+
if (currentHeaders == nil) {
118+
// Check for the headers delimiter.
119+
NSData *headersMarker = [CRLF CRLF dataUsingEncoding:NSUTF8StringEncoding];
120+
NSRange headersRange = [content rangeOfData:headersMarker options:0 range:remainingBufferRange];
121+
if (headersRange.location != NSNotFound) {
122+
NSData *headersData = [content subdataWithRange:NSMakeRange(chunkStart, headersRange.location - chunkStart)];
123+
currentHeadersLength = headersData.length;
124+
currentHeaders = [self parseHeaders:headersData];
125+
}
126+
} else {
127+
// When headers are loaded start sending progress callbacks.
128+
[self emitProgress:currentHeaders
129+
contentLength:content.length - currentHeadersLength
130+
final:NO
131+
callback:progressCallback];
132+
}
133+
85134
bytesSeen = content.length;
86135
NSInteger bytesRead = [_stream read:buffer maxLength:bufferLen];
87136
if (bytesRead <= 0 || _stream.streamError) {
@@ -98,7 +147,13 @@ - (BOOL)readAllParts:(RCTMultipartCallback)callback
98147
// Ignore preamble
99148
if (chunkStart > 0) {
100149
NSData *chunk = [content subdataWithRange:NSMakeRange(chunkStart, length)];
101-
[self emitChunk:chunk callback:callback done:isCloseDelimiter];
150+
[self emitProgress:currentHeaders
151+
contentLength:chunk.length - currentHeadersLength
152+
final:YES
153+
callback:progressCallback];
154+
[self emitChunk:chunk headers:currentHeaders callback:callback done:isCloseDelimiter];
155+
currentHeaders = nil;
156+
currentHeadersLength = 0;
102157
}
103158

104159
if (isCloseDelimiter) {

0 commit comments

Comments
 (0)