react-native/React/Base/RCTMultipartStreamReader.m
Janic Duplessis ef23d2bdcf 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 https://github.com/facebook/metro-bundler/pull/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 https://github.com/facebook/react-native/pull/15066

Differential Revision: D5449073

Pulled By: shergin

fbshipit-source-id: 43a8fb559393bbdc04f77916500e21898695bac5
2017-08-14 11:05:35 -07:00

168 lines
6.2 KiB
Objective-C

/**
* Copyright (c) 2015-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*/
#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;
}
- (NSDictionary *)parseHeaders:(NSData *)data
{
NSMutableDictionary *headers = [NSMutableDictionary new];
NSString *text = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
NSArray<NSString *> *lines = [text componentsSeparatedByString:CRLF];
for (NSString *line in lines) {
NSUInteger location = [line rangeOfString:@":"].location;
if (location == NSNotFound) {
continue;
}
NSString *key = [line substringToIndex:location];
NSString *value = [[line substringFromIndex:location + 1] stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
[headers setValue:value forKey:key];
}
return headers;
}
- (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;
NSData *bodyData = [data subdataWithRange:NSMakeRange(bodyStart, data.length - bodyStart)];
callback([self parseHeaders:headersData], bodyData, done);
}
}
- (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];
[_stream open];
while (true) {
BOOL isCloseDelimiter = NO;
// Search only a subset of chunk that we haven't seen before + few bytes
// 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) {
return NO;
}
[content appendBytes:buffer length:bytesRead];
continue;
}
NSInteger chunkEnd = range.location;
NSInteger length = chunkEnd - chunkStart;
bytesSeen = chunkEnd;
// Ignore preamble
if (chunkStart > 0) {
NSData *chunk = [content subdataWithRange:NSMakeRange(chunkStart, length)];
[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) {
return YES;
}
chunkStart = chunkEnd + delimiter.length;
}
}
@end