From 8eba05545104b206ca3747a3c0c01574dc033041 Mon Sep 17 00:00:00 2001 From: Philipp von Weitershausen Date: Fri, 24 Jun 2016 22:30:48 -0700 Subject: [PATCH] Add responseType as a concept to RCTNetworking, send binary data as base64 In preparation for Blob support (wherein binary XHR and WebSocket responses can be retained as native data blobs on the native side and JS receives a web-like opaque Blob object), this change makes RCTNetworking aware of the responseType that JS requests. A `xhr.responseType` of `''` or `'text'` translates to a native response type of `'text'`. A `xhr.responseType` of `arraybuffer` translates to a native response type of `base64`, as we currently lack an API to transmit TypedArrays directly to JS. This is analogous to how the WebSocket module already works, and it's a lot more versatile and much less brittle than converting a JS *string* back to a TypedArray, which is what's currently going on. Now that we don't always send text down to JS, JS consumers might still want to get progress updates about a binary download. This is what the `'progress'` event is designed for, so this change also implements that. This change also follows the XHR spec with regards to `xhr.response` and `xhr.responseText`: - if the response type is `'text'`, `xhr.responseText` can be peeked at by the JS consumer. It will be updated periodically as the download progresses, so long as there's either an `onreadystatechange` or `onprogress` handler on the XHR. - if the response type is not `'text'`, `xhr.responseText` can't be access and `xhr.response` remains `null` until the response is fully received. `'progress'` events containing response details (total bytes, downloaded so far) are dispatched if there's an `onprogress` handler. Once Blobs are landed, `xhr.responseType` of `'blob'` will correspond to the same native response type, which will cause RCTNetworking to only send a blob ID down to JS, which can then create a `Blob` object from that for consumers. **Test Plan:** This change comes with extensive changes and expansions to the XHRExamples in UIExplorer. `onprogress` handler, `onreadystatechange` handler, and `responseType = 'arraybuffer'` can be switched on independently. I tested all combinations on both iOS and Android. --- Examples/UIExplorer/XHRExample.android.js | 170 ++++++++++++++---- Examples/UIExplorer/XHRExample.ios.js | 149 ++++++++++++--- Examples/UIExplorer/XHRExampleCookies.js | 2 +- Libraries/Network/RCTNetworkTask.h | 2 +- Libraries/Network/RCTNetworkTask.m | 2 +- Libraries/Network/RCTNetworking.android.js | 22 ++- Libraries/Network/RCTNetworking.ios.js | 24 ++- Libraries/Network/RCTNetworking.m | 93 +++++++--- Libraries/Network/XMLHttpRequest.js | 168 +++++++++-------- .../Network/__tests__/XMLHttpRequest-test.js | 50 ++++-- .../modules/network/NetworkingModule.java | 80 +++++++-- ...estListener.java => ProgressListener.java} | 6 +- .../modules/network/ProgressRequestBody.java | 9 +- .../modules/network/ProgressResponseBody.java | 59 ++++++ .../modules/network/RequestBodyUtil.java | 2 +- .../react/modules/network/ResponseUtil.java | 28 +++ .../modules/network/NetworkingModuleTest.java | 97 +++++++--- 17 files changed, 723 insertions(+), 240 deletions(-) rename ReactAndroid/src/main/java/com/facebook/react/modules/network/{ProgressRequestListener.java => ProgressListener.java} (73%) create mode 100644 ReactAndroid/src/main/java/com/facebook/react/modules/network/ProgressResponseBody.java diff --git a/Examples/UIExplorer/XHRExample.android.js b/Examples/UIExplorer/XHRExample.android.js index 991f88d4528671..0d1357152b837b 100644 --- a/Examples/UIExplorer/XHRExample.android.js +++ b/Examples/UIExplorer/XHRExample.android.js @@ -18,14 +18,15 @@ var React = require('react'); var ReactNative = require('react-native'); var { + CameraRoll, + Image, ProgressBarAndroid, StyleSheet, + Switch, Text, TextInput, TouchableHighlight, View, - Image, - CameraRoll } = ReactNative; var XHRExampleHeaders = require('./XHRExampleHeaders'); @@ -33,6 +34,13 @@ var XHRExampleCookies = require('./XHRExampleCookies'); var XHRExampleFetch = require('./XHRExampleFetch'); var XHRExampleOnTimeOut = require('./XHRExampleOnTimeOut'); +/** + * Convert number of bytes to MB and round to the nearest 0.1 MB. + */ +function roundKilo(value: number): number { + return Math.round(value / 1000); +} + // TODO t7093728 This is a simplified XHRExample.ios.js. // Once we have Camera roll, Toast, Intent (for opening URLs) // we should make this consistent with iOS. @@ -47,8 +55,18 @@ class Downloader extends React.Component { this.cancelled = false; this.state = { status: '', - contentSize: 1, - downloaded: 0, + downloading: false, + + // set by onreadystatechange + contentLength: 1, + responseLength: 0, + // set by onprogress + progressTotal: 1, + progressLoaded: 0, + + readystateHandler: false, + progressHandler: true, + arraybuffer: false, }; } @@ -56,44 +74,66 @@ class Downloader extends React.Component { this.xhr && this.xhr.abort(); var xhr = this.xhr || new XMLHttpRequest(); - xhr.onreadystatechange = () => { + const onreadystatechange = () => { if (xhr.readyState === xhr.HEADERS_RECEIVED) { - var contentSize = parseInt(xhr.getResponseHeader('Content-Length'), 10); + const contentLength = parseInt(xhr.getResponseHeader('Content-Length'), 10); this.setState({ - contentSize: contentSize, - downloaded: 0, + contentLength, + responseLength: 0, }); - } else if (xhr.readyState === xhr.LOADING) { + } else if (xhr.readyState === xhr.LOADING && xhr.response) { this.setState({ - downloaded: xhr.responseText.length, + responseLength: xhr.response.length, }); - } else if (xhr.readyState === xhr.DONE) { - if (this.cancelled) { - this.cancelled = false; - return; - } - if (xhr.status === 200) { - this.setState({ - status: 'Download complete!', - }); - } else if (xhr.status !== 0) { - this.setState({ - status: 'Error: Server returned HTTP status of ' + xhr.status + ' ' + xhr.responseText, - }); - } else { - this.setState({ - status: 'Error: ' + xhr.responseText, - }); + } + }; + const onprogress = (event) => { + this.setState({ + progressTotal: event.total, + progressLoaded: event.loaded, + }); + }; + + if (this.state.readystateHandler) { + xhr.onreadystatechange = onreadystatechange; + } + if (this.state.progressHandler) { + xhr.onprogress = onprogress; + } + if (this.state.arraybuffer) { + xhr.responseType = 'arraybuffer'; + } + xhr.onload = () => { + this.setState({downloading: false}); + if (this.cancelled) { + this.cancelled = false; + return; + } + if (xhr.status === 200) { + let responseType = `Response is a string, ${xhr.response.length} characters long.`; + if (typeof ArrayBuffer !== 'undefined' && + xhr.response instanceof ArrayBuffer) { + responseType = `Response is an ArrayBuffer, ${xhr.response.byteLength} bytes long.`; } + this.setState({status: `Download complete! ${responseType}`}); + } else if (xhr.status !== 0) { + this.setState({ + status: 'Error: Server returned HTTP status of ' + xhr.status + ' ' + xhr.responseText + }); + } else { + this.setState({status: 'Error: ' + xhr.responseText}); } }; - xhr.open('GET', 'http://www.gutenberg.org/cache/epub/100/pg100.txt'); + xhr.open('GET', 'http://aleph.gutenberg.org/cache/epub/100/pg100.txt.utf8'); // Avoid gzip so we can actually show progress xhr.setRequestHeader('Accept-Encoding', ''); xhr.send(); this.xhr = xhr; - this.setState({status: 'Downloading...'}); + this.setState({ + downloading: true, + status: 'Downloading...', + }); } componentWillUnmount() { @@ -102,7 +142,7 @@ class Downloader extends React.Component { } render() { - var button = this.state.status === 'Downloading...' ? ( + var button = this.state.downloading ? ( ... @@ -118,11 +158,67 @@ class Downloader extends React.Component { ); + let readystate = null; + let progress = null; + if (this.state.readystateHandler && !this.state.arraybuffer) { + const { responseLength, contentLength } = this.state; + readystate = ( + + + responseText:{' '} + {roundKilo(responseLength)}/{roundKilo(contentLength)}k chars + + + + ); + } + if (this.state.progressHandler) { + const { progressLoaded, progressTotal } = this.state; + progress = ( + + + onprogress:{' '} + {roundKilo(progressLoaded)}/{roundKilo(progressTotal)} KB + + + + ); + } + return ( + + onreadystatechange handler + this.setState({readystateHandler}))} + /> + + + onprogress handler + this.setState({progressHandler}))} + /> + + + download as arraybuffer + this.setState({arraybuffer}))} + /> + {button} - + {readystate} + {progress} {this.state.status} ); @@ -357,6 +453,16 @@ var styles = StyleSheet.create({ backgroundColor: '#eeeeee', padding: 8, }, + progressBarLabel: { + marginTop: 12, + marginBottom: 8, + }, + configRow: { + flexDirection: 'row', + paddingVertical: 8, + alignItems: 'center', + justifyContent: 'space-between', + }, paramRow: { flexDirection: 'row', paddingVertical: 8, diff --git a/Examples/UIExplorer/XHRExample.ios.js b/Examples/UIExplorer/XHRExample.ios.js index 646c3f05bdab9c..5c49d96d0168c7 100644 --- a/Examples/UIExplorer/XHRExample.ios.js +++ b/Examples/UIExplorer/XHRExample.ios.js @@ -31,6 +31,7 @@ var { Linking, ProgressViewIOS, StyleSheet, + Switch, Text, TextInput, TouchableHighlight, @@ -41,6 +42,13 @@ var XHRExampleHeaders = require('./XHRExampleHeaders'); var XHRExampleFetch = require('./XHRExampleFetch'); var XHRExampleOnTimeOut = require('./XHRExampleOnTimeOut'); +/** + * Convert number of bytes to MB and round to the nearest 0.1 MB. + */ +function roundKilo(value: number): number { + return Math.round(value / 1000); +} + class Downloader extends React.Component { state: any; @@ -52,8 +60,16 @@ class Downloader extends React.Component { this.cancelled = false; this.state = { downloading: false, - contentSize: 1, - downloaded: 0, + // set by onreadystatechange + contentLength: 1, + responseLength: 0, + // set by onprogress + progressTotal: 1, + progressLoaded: 0, + + readystateHandler: false, + progressHandler: true, + arraybuffer: false, }; } @@ -61,41 +77,62 @@ class Downloader extends React.Component { this.xhr && this.xhr.abort(); var xhr = this.xhr || new XMLHttpRequest(); - xhr.onreadystatechange = () => { + const onreadystatechange = () => { if (xhr.readyState === xhr.HEADERS_RECEIVED) { - var contentSize = parseInt(xhr.getResponseHeader('Content-Length'), 10); + const contentLength = parseInt(xhr.getResponseHeader('Content-Length'), 10); this.setState({ - contentSize: contentSize, - downloaded: 0, + contentLength, + responseLength: 0, }); } else if (xhr.readyState === xhr.LOADING) { this.setState({ - downloaded: xhr.responseText.length, + responseLength: xhr.responseText.length, }); - } else if (xhr.readyState === xhr.DONE) { - this.setState({ - downloading: false, - }); - if (this.cancelled) { - this.cancelled = false; - return; - } - if (xhr.status === 200) { - alert('Download complete!'); - } else if (xhr.status !== 0) { - alert('Error: Server returned HTTP status of ' + xhr.status + ' ' + xhr.responseText); - } else { - alert('Error: ' + xhr.responseText); + } + }; + const onprogress = (event) => { + this.setState({ + progressTotal: event.total, + progressLoaded: event.loaded, + }); + }; + + if (this.state.readystateHandler) { + xhr.onreadystatechange = onreadystatechange; + } + if (this.state.progressHandler) { + xhr.onprogress = onprogress; + } + if (this.state.arraybuffer) { + xhr.responseType = 'arraybuffer'; + } + xhr.onload = () => { + this.setState({downloading: false}); + if (this.cancelled) { + this.cancelled = false; + return; + } + if (xhr.status === 200) { + let responseType = `Response is a string, ${xhr.response.length} characters long.`; + if (typeof ArrayBuffer !== 'undefined' && + xhr.response instanceof ArrayBuffer) { + responseType = `Response is an ArrayBuffer, ${xhr.response.byteLength} bytes long.`; } + alert(`Download complete! ${responseType}`); + } else if (xhr.status !== 0) { + alert('Error: Server returned HTTP status of ' + xhr.status + ' ' + xhr.responseText); + } else { + alert('Error: ' + xhr.responseText); } }; - xhr.open('GET', 'http://www.gutenberg.org/cache/epub/100/pg100.txt'); + xhr.open('GET', 'http://aleph.gutenberg.org/cache/epub/100/pg100.txt.utf8'); xhr.send(); this.xhr = xhr; this.setState({downloading: true}); } + componentWillUnmount() { this.cancelled = true; this.xhr && this.xhr.abort(); @@ -113,15 +150,68 @@ class Downloader extends React.Component { style={styles.wrapper} onPress={this.download.bind(this)}> - Download 5MB Text File + Download 5MB Text File ); + let readystate = null; + let progress = null; + if (this.state.readystateHandler && !this.state.arraybuffer) { + const { responseLength, contentLength } = this.state; + readystate = ( + + + responseText:{' '} + {roundKilo(responseLength)}/{roundKilo(contentLength)}k chars + + + + ); + } + if (this.state.progressHandler) { + const { progressLoaded, progressTotal } = this.state; + progress = ( + + + onprogress:{' '} + {roundKilo(progressLoaded)}/{roundKilo(progressTotal)} KB + + + + ); + } + return ( + + onreadystatechange handler + this.setState({readystateHandler}))} + /> + + + onprogress handler + this.setState({progressHandler}))} + /> + + + download as arraybuffer + this.setState({arraybuffer}))} + /> + {button} - + {readystate} + {progress} ); } @@ -232,7 +322,6 @@ class FormUploader extends React.Component { (param) => formdata.append(param.name, param.value) ); xhr.upload.onprogress = (event) => { - console.log('upload onprogress', event); if (event.lengthComputable) { this.setState({uploadProgress: event.loaded / event.total}); } @@ -354,6 +443,16 @@ var styles = StyleSheet.create({ backgroundColor: '#eeeeee', padding: 8, }, + progressBarLabel: { + marginTop: 12, + marginBottom: 8, + }, + configRow: { + flexDirection: 'row', + paddingVertical: 8, + alignItems: 'center', + justifyContent: 'space-between', + }, paramRow: { flexDirection: 'row', paddingVertical: 8, diff --git a/Examples/UIExplorer/XHRExampleCookies.js b/Examples/UIExplorer/XHRExampleCookies.js index d07c7af03d7bb9..2e18d982c8fc7a 100644 --- a/Examples/UIExplorer/XHRExampleCookies.js +++ b/Examples/UIExplorer/XHRExampleCookies.js @@ -69,7 +69,7 @@ class XHRExampleCookies extends React.Component { clearCookies() { RCTNetworking.clearCookies((cleared) => { - this.setStatus('Cookies cleared, had cookies=' + cleared); + this.setStatus('Cookies cleared, had cookies=' + cleared.toString()); this.refreshWebview(); }); } diff --git a/Libraries/Network/RCTNetworkTask.h b/Libraries/Network/RCTNetworkTask.h index 3f2de225ff8ce2..189471def2f591 100644 --- a/Libraries/Network/RCTNetworkTask.h +++ b/Libraries/Network/RCTNetworkTask.h @@ -14,7 +14,7 @@ typedef void (^RCTURLRequestCompletionBlock)(NSURLResponse *response, NSData *data, NSError *error); typedef void (^RCTURLRequestCancellationBlock)(void); -typedef void (^RCTURLRequestIncrementalDataBlock)(NSData *data); +typedef void (^RCTURLRequestIncrementalDataBlock)(NSData *data, int64_t progress, int64_t total); typedef void (^RCTURLRequestProgressBlock)(int64_t progress, int64_t total); typedef void (^RCTURLRequestResponseBlock)(NSURLResponse *response); diff --git a/Libraries/Network/RCTNetworkTask.m b/Libraries/Network/RCTNetworkTask.m index 8ce51e86240ffc..c8abb7ac0f668d 100644 --- a/Libraries/Network/RCTNetworkTask.m +++ b/Libraries/Network/RCTNetworkTask.m @@ -126,7 +126,7 @@ - (void)URLRequest:(id)requestToken didReceiveData:(NSData *)data } [_data appendData:data]; if (_incrementalDataBlock) { - _incrementalDataBlock(data); + _incrementalDataBlock(data, _data.length, _response.expectedContentLength); } if (_downloadProgressBlock && _response.expectedContentLength > 0) { _downloadProgressBlock(_data.length, _response.expectedContentLength); diff --git a/Libraries/Network/RCTNetworking.android.js b/Libraries/Network/RCTNetworking.android.js index 23aa9e8bf54199..4251930f65248d 100644 --- a/Libraries/Network/RCTNetworking.android.js +++ b/Libraries/Network/RCTNetworking.android.js @@ -7,6 +7,7 @@ * of patent rights can be found in the PATENTS file in the same directory. * * @providesModule RCTNetworking + * @flow */ 'use strict'; @@ -27,7 +28,7 @@ function convertHeadersMapToArray(headers: Object): Array
{ } let _requestId = 1; -function generateRequestId() { +function generateRequestId(): number { return _requestId++; } @@ -41,11 +42,21 @@ class RCTNetworking extends NativeEventEmitter { super(RCTNetworkingNative); } - sendRequest(method, url, headers, data, incrementalUpdates, timeout, callback) { + sendRequest( + method: string, + url: string, + headers: Object, + data: string | FormData | {uri: string}, + responseType: 'text' | 'base64', + incrementalUpdates: boolean, + timeout: number, + callback: (requestId: number) => any + ) { + let body = data; if (typeof data === 'string') { - data = {string: data}; + body = {string: body}; } else if (data instanceof FormData) { - data = { + body = { formData: data.getParts().map((part) => { part.headers = convertHeadersMapToArray(part.headers); return part; @@ -58,7 +69,8 @@ class RCTNetworking extends NativeEventEmitter { url, requestId, convertHeadersMapToArray(headers), - data, + body, + responseType, incrementalUpdates, timeout ); diff --git a/Libraries/Network/RCTNetworking.ios.js b/Libraries/Network/RCTNetworking.ios.js index 3e0e4c35284119..a6cb9163cc2090 100644 --- a/Libraries/Network/RCTNetworking.ios.js +++ b/Libraries/Network/RCTNetworking.ios.js @@ -7,6 +7,7 @@ * of patent rights can be found in the PATENTS file in the same directory. * * @providesModule RCTNetworking + * @flow */ 'use strict'; @@ -20,27 +21,38 @@ class RCTNetworking extends NativeEventEmitter { super(RCTNetworkingNative); } - sendRequest(method, url, headers, data, incrementalUpdates, timeout, callback) { + sendRequest( + method: string, + url: string, + headers: Object, + data: string | FormData | {uri: string}, + responseType: 'text' | 'base64', + incrementalUpdates: boolean, + timeout: number, + callback: (requestId: number) => any + ) { + let body = data; if (typeof data === 'string') { - data = {string: data}; + body = {string: data}; } else if (data instanceof FormData) { - data = {formData: data.getParts()}; + body = {formData: data.getParts()}; } RCTNetworkingNative.sendRequest({ method, url, - data, + body, headers, + responseType, incrementalUpdates, timeout }, callback); } - abortRequest(requestId) { + abortRequest(requestId: number) { RCTNetworkingNative.abortRequest(requestId); } - clearCookies(callback) { + clearCookies(callback: (result: boolean) => any) { console.warn('RCTNetworking.clearCookies is not supported on iOS'); } } diff --git a/Libraries/Network/RCTNetworking.m b/Libraries/Network/RCTNetworking.m index 07c21f095d3fad..7a92a161105323 100644 --- a/Libraries/Network/RCTNetworking.m +++ b/Libraries/Network/RCTNetworking.m @@ -138,6 +138,8 @@ @implementation RCTNetworking return @[@"didCompleteNetworkResponse", @"didReceiveNetworkResponse", @"didSendNetworkData", + @"didReceiveNetworkIncrementalData", + @"didReceiveNetworkDataProgress", @"didReceiveNetworkData"]; } @@ -313,26 +315,16 @@ - (RCTURLRequestCancellationBlock)processDataForHTTPQuery:(nullable NSDictionary return callback(nil, nil); } -- (void)sendData:(NSData *)data forTask:(RCTNetworkTask *)task ++ (NSString *)decodeTextData:(NSData *)data fromResponse:(NSURLResponse *)response { - RCTAssertThread(_methodQueue, @"sendData: must be called on method queue"); - - if (data.length == 0) { - return; - } - - // Get text encoding - NSURLResponse *response = task.response; NSStringEncoding encoding = NSUTF8StringEncoding; if (response.textEncodingName) { CFStringEncoding cfEncoding = CFStringConvertIANACharSetNameToEncoding((CFStringRef)response.textEncodingName); encoding = CFStringConvertEncodingToNSStringEncoding(cfEncoding); } - // Attempt to decode text - NSString *responseText = [[NSString alloc] initWithData:data encoding:encoding]; - if (!responseText && data.length) { - + NSString *encodedResponse = [[NSString alloc] initWithData:data encoding:encoding]; + if (!encodedResponse && data.length) { // We don't have an encoding, or the encoding is incorrect, so now we // try to guess (unfortunately, this feature is available in iOS 8+ only) if ([NSString respondsToSelector:@selector(stringEncodingForData: @@ -341,22 +333,43 @@ - (void)sendData:(NSData *)data forTask:(RCTNetworkTask *)task usedLossyConversion:)]) { [NSString stringEncodingForData:data encodingOptions:nil - convertedString:&responseText + convertedString:&encodedResponse usedLossyConversion:NULL]; } + } + return encodedResponse; +} - // If we still can't decode it, bail out - if (!responseText) { +- (void)sendData:(NSData *)data + responseType:(NSString *)responseType + forTask:(RCTNetworkTask *)task +{ + RCTAssertThread(_methodQueue, @"sendData: must be called on method queue"); + + if (data.length == 0) { + return; + } + + NSString *responseString; + if ([responseType isEqualToString:@"text"]) { + responseString = [RCTNetworking decodeTextData:data fromResponse:task.response]; + if (!responseString) { RCTLogWarn(@"Received data was not a string, or was not a recognised encoding."); return; } + } else if ([responseType isEqualToString:@"base64"]) { + responseString = [data base64EncodedStringWithOptions:0]; + } else { + RCTLogWarn(@"Invalid responseType: %@", responseType); + return; } - NSArray *responseJSON = @[task.requestID, responseText ?: @""]; + NSArray *responseJSON = @[task.requestID, responseString]; [self sendEventWithName:@"didReceiveNetworkData" body:responseJSON]; } - (void)sendRequest:(NSURLRequest *)request + responseType:(NSString *)responseType incrementalUpdates:(BOOL)incrementalUpdates responseSender:(RCTResponseSenderBlock)responseSender { @@ -371,7 +384,7 @@ - (void)sendRequest:(NSURLRequest *)request }); }; - void (^responseBlock)(NSURLResponse *) = ^(NSURLResponse *response) { + RCTURLRequestResponseBlock responseBlock = ^(NSURLResponse *response) { dispatch_async(_methodQueue, ^{ NSDictionary *headers; NSInteger status; @@ -389,17 +402,44 @@ - (void)sendRequest:(NSURLRequest *)request }); }; - void (^incrementalDataBlock)(NSData *) = incrementalUpdates ? ^(NSData *data) { - dispatch_async(_methodQueue, ^{ - [self sendData:data forTask:task]; - }); - } : nil; + // XHR does not allow you to peek at xhr.response before the response is + // finished. Only when xhr.responseType is set to ''/'text', consumers may + // peek at xhr.responseText. So unless the requested responseType is 'text', + // we only send progress updates and not incremental data updates to JS here. + RCTURLRequestIncrementalDataBlock incrementalDataBlock = nil; + RCTURLRequestProgressBlock downloadProgressBlock = nil; + if (incrementalUpdates) { + if ([responseType isEqualToString:@"text"]) { + incrementalDataBlock = ^(NSData *data, int64_t progress, int64_t total) { + dispatch_async(_methodQueue, ^{ + NSString *responseString = [RCTNetworking decodeTextData:data fromResponse:task.response]; + if (!responseString) { + RCTLogWarn(@"Received data was not a string, or was not a recognised encoding."); + return; + } + NSArray *responseJSON = @[task.requestID, responseString, @(progress), @(total)]; + [self sendEventWithName:@"didReceiveNetworkIncrementalData" body:responseJSON]; + }); + }; + } else { + downloadProgressBlock = ^(int64_t progress, int64_t total) { + dispatch_async(_methodQueue, ^{ + NSArray *responseJSON = @[task.requestID, @(progress), @(total)]; + [self sendEventWithName:@"didReceiveNetworkDataProgress" body:responseJSON]; + }); + }; + } + } RCTURLRequestCompletionBlock completionBlock = ^(NSURLResponse *response, NSData *data, NSError *error) { dispatch_async(_methodQueue, ^{ - if (!incrementalUpdates) { - [self sendData:data forTask:task]; + // Unless we were sending incremental (text) chunks to JS, all along, now + // is the time to send the request body to JS. + if (!(incrementalUpdates && [responseType isEqualToString:@"text"])) { + [self sendData:data + responseType:responseType + forTask:task]; } NSArray *responseJSON = @[task.requestID, RCTNullIfNil(error.localizedDescription), @@ -412,6 +452,7 @@ - (void)sendRequest:(NSURLRequest *)request }; task = [self networkTaskWithRequest:request completionBlock:completionBlock]; + task.downloadProgressBlock = downloadProgressBlock; task.incrementalDataBlock = incrementalDataBlock; task.responseBlock = responseBlock; task.uploadProgressBlock = uploadProgressBlock; @@ -453,8 +494,10 @@ - (RCTNetworkTask *)networkTaskWithRequest:(NSURLRequest *)request // loading a large file to build the request body [self buildRequest:query completionBlock:^(NSURLRequest *request) { + NSString *responseType = [RCTConvert NSString:query[@"responseType"]]; BOOL incrementalUpdates = [RCTConvert BOOL:query[@"incrementalUpdates"]]; [self sendRequest:request + responseType:responseType incrementalUpdates:incrementalUpdates responseSender:responseSender]; }]; diff --git a/Libraries/Network/XMLHttpRequest.js b/Libraries/Network/XMLHttpRequest.js index ed39d9fe450070..c165079316d5b9 100644 --- a/Libraries/Network/XMLHttpRequest.js +++ b/Libraries/Network/XMLHttpRequest.js @@ -14,8 +14,8 @@ const RCTNetworking = require('RCTNetworking'); const EventTarget = require('event-target-shim'); +const base64 = require('base64-js'); const invariant = require('fbjs/lib/invariant'); -const utf8 = require('utf8'); const warning = require('fbjs/lib/warning'); type ResponseType = '' | 'arraybuffer' | 'blob' | 'document' | 'json' | 'text'; @@ -102,7 +102,7 @@ class XMLHttpRequest extends EventTarget(...XHR_EVENTS) { _method: ?string = null; _response: string | ?Object; _responseType: ResponseType; - _responseText: string = ''; + _response: string = ''; _sent: boolean; _url: ?string = null; _timedOut: boolean = false; @@ -124,7 +124,7 @@ class XMLHttpRequest extends EventTarget(...XHR_EVENTS) { this._cachedResponse = undefined; this._hasError = false; this._headers = {}; - this._responseText = ''; + this._response = ''; this._responseType = ''; this._sent = false; this._lowerCaseResponseHeaders = {}; @@ -140,10 +140,10 @@ class XMLHttpRequest extends EventTarget(...XHR_EVENTS) { // $FlowIssue #10784535 set responseType(responseType: ResponseType): void { - if (this.readyState > HEADERS_RECEIVED) { + if (this._sent) { throw new Error( - "Failed to set the 'responseType' property on 'XMLHttpRequest': The " + - "response type cannot be set if the object's state is LOADING or DONE" + 'Failed to set the \'responseType\' property on \'XMLHttpRequest\': The ' + + 'response type cannot be set after the request has been sent.' ); } if (!SUPPORTED_RESPONSE_TYPES.hasOwnProperty(responseType)) { @@ -173,7 +173,7 @@ class XMLHttpRequest extends EventTarget(...XHR_EVENTS) { if (this.readyState < LOADING) { return ''; } - return this._responseText; + return this._response; } // $FlowIssue #10784535 @@ -182,7 +182,7 @@ class XMLHttpRequest extends EventTarget(...XHR_EVENTS) { if (responseType === '' || responseType === 'text') { return this.readyState < LOADING || this._hasError ? '' - : this._responseText; + : this._response; } if (this.readyState !== DONE) { @@ -193,26 +193,25 @@ class XMLHttpRequest extends EventTarget(...XHR_EVENTS) { return this._cachedResponse; } - switch (this._responseType) { + switch (responseType) { case 'document': this._cachedResponse = null; break; case 'arraybuffer': - this._cachedResponse = toArrayBuffer( - this._responseText, this.getResponseHeader('content-type') || ''); + this._cachedResponse = base64.toByteArray(this._response).buffer; break; case 'blob': this._cachedResponse = new global.Blob( - [this._responseText], + [base64.toByteArray(this._response).buffer], {type: this.getResponseHeader('content-type') || ''} ); break; case 'json': try { - this._cachedResponse = JSON.parse(this._responseText); + this._cachedResponse = JSON.parse(this._response); } catch (_) { this._cachedResponse = null; } @@ -231,7 +230,11 @@ class XMLHttpRequest extends EventTarget(...XHR_EVENTS) { } // exposed for testing - __didUploadProgress(requestId: number, progress: number, total: number): void { + __didUploadProgress( + requestId: number, + progress: number, + total: number + ): void { if (requestId === this._requestId) { this.upload.dispatchEvent({ type: 'progress', @@ -260,16 +263,47 @@ class XMLHttpRequest extends EventTarget(...XHR_EVENTS) { } } - __didReceiveData(requestId: number, responseText: string): void { - if (requestId === this._requestId) { - if (!this._responseText) { - this._responseText = responseText; - } else { - this._responseText += responseText; - } - this._cachedResponse = undefined; // force lazy recomputation - this.setReadyState(this.LOADING); + __didReceiveData(requestId: number, response: string): void { + if (requestId !== this._requestId) { + return; + } + this._response = response; + this._cachedResponse = undefined; // force lazy recomputation + this.setReadyState(this.LOADING); + } + + __didReceiveIncrementalData( + requestId: number, + responseText: string, + progress: number, + total: number + ) { + if (requestId !== this._requestId) { + return; + } + if (!this._response) { + this._response = responseText; + } else { + this._response += responseText; + } + this.setReadyState(this.LOADING); + this.__didReceiveDataProgress(requestId, progress, total); + } + + __didReceiveDataProgress( + requestId: number, + loaded: number, + total: number + ): void { + if (requestId !== this._requestId) { + return; } + this.dispatchEvent({ + type: 'progress', + lengthComputable: total >= 0, + loaded, + total, + }); } // exposed for testing @@ -280,7 +314,9 @@ class XMLHttpRequest extends EventTarget(...XHR_EVENTS) { ): void { if (requestId === this._requestId) { if (error) { - this._responseText = error; + if (this._responseType === '' || this._responseType === 'text') { + this._response = error; + } this._hasError = true; if (timeOutError) { this._timedOut = true; @@ -334,21 +370,24 @@ class XMLHttpRequest extends EventTarget(...XHR_EVENTS) { if (!url) { throw new Error('Cannot load an empty url'); } - this._reset(); this._method = method.toUpperCase(); this._url = url; this._aborted = false; this.setReadyState(this.OPENED); } - sendImpl( - method: ?string, - url: ?string, - headers: Object, - data: any, - useIncrementalUpdates: boolean, - timeout: number, - ): void { + send(data: any): void { + if (this.readyState !== this.OPENED) { + throw new Error('Request has not been opened'); + } + if (this._sent) { + throw new Error('Request has already been sent'); + } + this._sent = true; + const incrementalEvents = this._incrementalEvents || + !!this.onreadystatechange || + !!this.onprogress; + this._subscriptions.push(RCTNetworking.addListener( 'didSendNetworkData', (args) => this.__didUploadProgress(...args) @@ -359,39 +398,37 @@ class XMLHttpRequest extends EventTarget(...XHR_EVENTS) { )); this._subscriptions.push(RCTNetworking.addListener( 'didReceiveNetworkData', - (args) => this.__didReceiveData(...args) + (args) => this.__didReceiveData(...args) + )); + this._subscriptions.push(RCTNetworking.addListener( + 'didReceiveNetworkIncrementalData', + (args) => this.__didReceiveIncrementalData(...args) + )); + this._subscriptions.push(RCTNetworking.addListener( + 'didReceiveNetworkDataProgress', + (args) => this.__didReceiveDataProgress(...args) )); this._subscriptions.push(RCTNetworking.addListener( 'didCompleteNetworkResponse', (args) => this.__didCompleteResponse(...args) )); - RCTNetworking.sendRequest( - method, - url, - headers, - data, - useIncrementalUpdates, - timeout, - this.__didCreateRequest.bind(this), - ); - } - send(data: any): void { - if (this.readyState !== this.OPENED) { - throw new Error('Request has not been opened'); + let nativeResponseType = 'text'; + if (this._responseType === 'arraybuffer' || this._responseType === 'blob') { + nativeResponseType = 'base64'; } - if (this._sent) { - throw new Error('Request has already been sent'); - } - this._sent = true; - const incrementalEvents = this._incrementalEvents || !!this.onreadystatechange; - this.sendImpl( + + invariant(this._method, 'Request method needs to be defined.'); + invariant(this._url, 'Request URL needs to be defined.'); + RCTNetworking.sendRequest( this._method, this._url, this._headers, data, + nativeResponseType, incrementalEvents, - this.timeout + this.timeout, + this.__didCreateRequest.bind(this), ); } @@ -444,32 +481,11 @@ class XMLHttpRequest extends EventTarget(...XHR_EVENTS) { // have to send repeated LOADING events with incremental updates // to responseText, which will avoid a bunch of native -> JS // bridge traffic. - if (type === 'readystatechange') { + if (type === 'readystatechange' || type === 'progress') { this._incrementalEvents = true; } super.addEventListener(type, listener); } } - -function toArrayBuffer(text: string, contentType: string): ArrayBuffer { - const {length} = text; - if (length === 0) { - return new ArrayBuffer(0); - } - - const charsetMatch = contentType.match(/;\s*charset=([^;]*)/i); - const charset = charsetMatch ? charsetMatch[1].trim() : 'utf-8'; - - if (/^utf-?8$/i.test(charset)) { - return utf8.encode(text); - } else { //TODO: utf16 / ucs2 / utf32 - const array = new Uint8Array(length); - for (let i = 0; i < length; i++) { - array[i] = text.charCodeAt(i); // Uint8Array automatically masks with 0xff - } - return array.buffer; - } -} - module.exports = XMLHttpRequest; diff --git a/Libraries/Network/__tests__/XMLHttpRequest-test.js b/Libraries/Network/__tests__/XMLHttpRequest-test.js index afbae4780b080f..db82c60f736b4f 100644 --- a/Libraries/Network/__tests__/XMLHttpRequest-test.js +++ b/Libraries/Network/__tests__/XMLHttpRequest-test.js @@ -10,18 +10,22 @@ 'use strict'; jest - .disableAutomock() - .dontMock('event-target-shim') - .setMock('NativeModules', { + .disableAutomock() + .dontMock('event-target-shim') + .setMock('NativeModules', { Networking: { - addListener: function(){}, - removeListeners: function(){}, + addListener: function() {}, + removeListeners: function() {}, + sendRequest: (options, callback) => { + callback(1); + }, + abortRequest: function() {}, } }); const XMLHttpRequest = require('XMLHttpRequest'); -describe('XMLHttpRequest', function(){ +describe('XMLHttpRequest', function() { var xhr; var handleTimeout; var handleError; @@ -45,8 +49,6 @@ describe('XMLHttpRequest', function(){ xhr.addEventListener('error', handleError); xhr.addEventListener('load', handleLoad); xhr.addEventListener('readystatechange', handleReadyStateChange); - - xhr.__didCreateRequest(1); }); afterEach(() => { @@ -57,8 +59,7 @@ describe('XMLHttpRequest', function(){ }); it('should transition readyState correctly', function() { - - expect(xhr.readyState).toBe(xhr.UNSENT); + expect(xhr.readyState).toBe(xhr.UNSENT); xhr.open('GET', 'blabla'); @@ -78,7 +79,8 @@ describe('XMLHttpRequest', function(){ expect(xhr.responseType).toBe('arraybuffer'); // Can't change responseType after first data has been received. - xhr.__didReceiveData(1, 'Some data'); + xhr.open('GET', 'blabla'); + xhr.send(); expect(() => { xhr.responseType = 'text'; }).toThrow(); }); @@ -100,11 +102,16 @@ describe('XMLHttpRequest', function(){ expect(xhr.responseText).toBe(''); expect(xhr.response).toBe(''); + xhr.open('GET', 'blabla'); + xhr.send(); xhr.__didReceiveData(1, 'Some data'); expect(xhr.responseText).toBe('Some data'); }); - it('should call ontimeout function when the request times out', function(){ + it('should call ontimeout function when the request times out', function() { + xhr.open('GET', 'blabla'); + xhr.send(); + xhr.__didCompleteResponse(1, 'Timeout', true); xhr.__didCompleteResponse(1, 'Timeout', true); expect(xhr.readyState).toBe(xhr.DONE); @@ -118,39 +125,46 @@ describe('XMLHttpRequest', function(){ expect(handleLoad).not.toBeCalled(); }); - it('should call onerror function when the request times out', function(){ + it('should call onerror function when the request times out', function() { + xhr.open('GET', 'blabla'); + xhr.send(); xhr.__didCompleteResponse(1, 'Generic error'); expect(xhr.readyState).toBe(xhr.DONE); - expect(xhr.onreadystatechange.mock.calls.length).toBe(1); + expect(xhr.onreadystatechange.mock.calls.length).toBe(2); expect(xhr.onerror.mock.calls.length).toBe(1); expect(xhr.ontimeout).not.toBeCalled(); expect(xhr.onload).not.toBeCalled(); - expect(handleReadyStateChange.mock.calls.length).toBe(1); + expect(handleReadyStateChange.mock.calls.length).toBe(2); expect(handleError.mock.calls.length).toBe(1); expect(handleTimeout).not.toBeCalled(); expect(handleLoad).not.toBeCalled(); }); - it('should call onload function when there is no error', function(){ + it('should call onload function when there is no error', function() { + xhr.open('GET', 'blabla'); + xhr.send(); xhr.__didCompleteResponse(1, null); expect(xhr.readyState).toBe(xhr.DONE); - expect(xhr.onreadystatechange.mock.calls.length).toBe(1); + expect(xhr.onreadystatechange.mock.calls.length).toBe(2); expect(xhr.onload.mock.calls.length).toBe(1); expect(xhr.onerror).not.toBeCalled(); expect(xhr.ontimeout).not.toBeCalled(); - expect(handleReadyStateChange.mock.calls.length).toBe(1); + expect(handleReadyStateChange.mock.calls.length).toBe(2); expect(handleLoad.mock.calls.length).toBe(1); expect(handleError).not.toBeCalled(); expect(handleTimeout).not.toBeCalled(); }); it('should call onload function when there is no error', function() { + xhr.open('GET', 'blabla'); + xhr.send(); + xhr.upload.onprogress = jest.fn(); var handleProgress = jest.fn(); xhr.upload.addEventListener('progress', handleProgress); diff --git a/ReactAndroid/src/main/java/com/facebook/react/modules/network/NetworkingModule.java b/ReactAndroid/src/main/java/com/facebook/react/modules/network/NetworkingModule.java index 9c42dfcd59130e..f321dbf4e39300 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/modules/network/NetworkingModule.java +++ b/ReactAndroid/src/main/java/com/facebook/react/modules/network/NetworkingModule.java @@ -9,6 +9,8 @@ package com.facebook.react.modules.network; +import android.util.Base64; + import javax.annotation.Nullable; import java.io.IOException; @@ -34,6 +36,7 @@ import okhttp3.Call; import okhttp3.Callback; import okhttp3.Headers; +import okhttp3.Interceptor; import okhttp3.JavaNetCookieJar; import okhttp3.MediaType; import okhttp3.MultipartBody; @@ -157,6 +160,7 @@ public void sendRequest( final int requestId, ReadableArray headers, ReadableMap data, + final String responseType, final boolean useIncrementalUpdates, int timeout) { Request.Builder requestBuilder = new Request.Builder().url(url); @@ -165,18 +169,54 @@ public void sendRequest( requestBuilder.tag(requestId); } - OkHttpClient client = mClient; + final RCTDeviceEventEmitter eventEmitter = getEventEmitter(executorToken); + OkHttpClient.Builder clientBuilder = mClient.newBuilder(); + + // If JS is listening for progress updates, install a ProgressResponseBody that intercepts the + // response and counts bytes received. + if (useIncrementalUpdates) { + clientBuilder.addNetworkInterceptor(new Interceptor() { + @Override + public Response intercept(Interceptor.Chain chain) throws IOException { + Response originalResponse = chain.proceed(chain.request()); + ProgressResponseBody responseBody = new ProgressResponseBody( + originalResponse.body(), + new ProgressListener() { + long last = System.nanoTime(); + + @Override + public void onProgress(long bytesWritten, long contentLength, boolean done) { + long now = System.nanoTime(); + if (!done && !shouldDispatch(now, last)) { + return; + } + if (responseType.equals("text")) { + // For 'text' responses we continuously send response data with progress info to JS + // below, so no need to do anything here. + return; + } + ResponseUtil.onDataReceivedProgress( + eventEmitter, + requestId, + bytesWritten, + contentLength); + last = now; + } + }); + return originalResponse.newBuilder().body(responseBody).build(); + } + }); + } + // If the current timeout does not equal the passed in timeout, we need to clone the existing // client and set the timeout explicitly on the clone. This is cheap as everything else is // shared under the hood. // See https://github.com/square/okhttp/wiki/Recipes#per-call-configuration for more information if (timeout != mClient.connectTimeoutMillis()) { - client = mClient.newBuilder() - .readTimeout(timeout, TimeUnit.MILLISECONDS) - .build(); + clientBuilder.readTimeout(timeout, TimeUnit.MILLISECONDS); } + OkHttpClient client = clientBuilder.build(); - final RCTDeviceEventEmitter eventEmitter = getEventEmitter(executorToken); Headers requestHeaders = extractHeaders(headers, data); if (requestHeaders == null) { ResponseUtil.onRequestError(eventEmitter, requestId, "Unrecognized headers format", null); @@ -247,11 +287,11 @@ public void sendRequest( method, RequestBodyUtil.createProgressRequest( multipartBuilder.build(), - new ProgressRequestListener() { + new ProgressListener() { long last = System.nanoTime(); @Override - public void onRequestProgress(long bytesWritten, long contentLength, boolean done) { + public void onProgress(long bytesWritten, long contentLength, boolean done) { long now = System.nanoTime(); if (done || shouldDispatch(now, last)) { ResponseUtil.onDataSend(eventEmitter, requestId, bytesWritten, contentLength); @@ -292,13 +332,23 @@ public void onResponse(Call call, Response response) throws IOException { ResponseBody responseBody = response.body(); try { - if (useIncrementalUpdates) { + // If JS wants progress updates during the download, and it requested a text response, + // periodically send response data updates to JS. + if (useIncrementalUpdates && responseType.equals("text")) { readWithProgress(eventEmitter, requestId, responseBody); ResponseUtil.onRequestSuccess(eventEmitter, requestId); - } else { - ResponseUtil.onDataReceived(eventEmitter, requestId, responseBody.string()); - ResponseUtil.onRequestSuccess(eventEmitter, requestId); + return; } + + // Otherwise send the data in one big chunk, in the format that JS requested. + String responseString = ""; + if (responseType.equals("text")) { + responseString = responseBody.string(); + } else if (responseType.equals("base64")) { + responseString = Base64.encodeToString(responseBody.bytes(), Base64.NO_WRAP); + } + ResponseUtil.onDataReceived(eventEmitter, requestId, responseString); + ResponseUtil.onRequestSuccess(eventEmitter, requestId); } catch (IOException e) { ResponseUtil.onRequestError(eventEmitter, requestId, e.getMessage(), e); } @@ -311,11 +361,17 @@ private void readWithProgress( int requestId, ResponseBody responseBody) throws IOException { Reader reader = responseBody.charStream(); + ProgressResponseBody progressResponseBody = (ProgressResponseBody)responseBody; try { char[] buffer = new char[MAX_CHUNK_SIZE_BETWEEN_FLUSHES]; int read; while ((read = reader.read(buffer)) != -1) { - ResponseUtil.onDataReceived(eventEmitter, requestId, new String(buffer, 0, read)); + ResponseUtil.onIncrementalDataReceived( + eventEmitter, + requestId, + new String(buffer, 0, read), + progressResponseBody.totalBytesRead(), + progressResponseBody.contentLength()); } } finally { reader.close(); diff --git a/ReactAndroid/src/main/java/com/facebook/react/modules/network/ProgressRequestListener.java b/ReactAndroid/src/main/java/com/facebook/react/modules/network/ProgressListener.java similarity index 73% rename from ReactAndroid/src/main/java/com/facebook/react/modules/network/ProgressRequestListener.java rename to ReactAndroid/src/main/java/com/facebook/react/modules/network/ProgressListener.java index 10230e6dcb9c56..08a61797c86ae6 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/modules/network/ProgressRequestListener.java +++ b/ReactAndroid/src/main/java/com/facebook/react/modules/network/ProgressListener.java @@ -6,10 +6,10 @@ * 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. */ - + package com.facebook.react.modules.network; -public interface ProgressRequestListener { - void onRequestProgress(long bytesWritten, long contentLength, boolean done); +public interface ProgressListener { + void onProgress(long bytesWritten, long contentLength, boolean done); } diff --git a/ReactAndroid/src/main/java/com/facebook/react/modules/network/ProgressRequestBody.java b/ReactAndroid/src/main/java/com/facebook/react/modules/network/ProgressRequestBody.java index 0e511f926dda19..4955a2748bff6a 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/modules/network/ProgressRequestBody.java +++ b/ReactAndroid/src/main/java/com/facebook/react/modules/network/ProgressRequestBody.java @@ -12,22 +12,19 @@ import java.io.IOException; import okhttp3.MediaType; import okhttp3.RequestBody; -import okhttp3.internal.Util; import okio.BufferedSink; import okio.Buffer; import okio.Sink; import okio.ForwardingSink; -import okio.ByteString; import okio.Okio; -import okio.Source; public class ProgressRequestBody extends RequestBody { private final RequestBody mRequestBody; - private final ProgressRequestListener mProgressListener; + private final ProgressListener mProgressListener; private BufferedSink mBufferedSink; - public ProgressRequestBody(RequestBody requestBody, ProgressRequestListener progressListener) { + public ProgressRequestBody(RequestBody requestBody, ProgressListener progressListener) { mRequestBody = requestBody; mProgressListener = progressListener; } @@ -63,7 +60,7 @@ public void write(Buffer source, long byteCount) throws IOException { contentLength = contentLength(); } bytesWritten += byteCount; - mProgressListener.onRequestProgress(bytesWritten, contentLength, bytesWritten == contentLength); + mProgressListener.onProgress(bytesWritten, contentLength, bytesWritten == contentLength); } }; } diff --git a/ReactAndroid/src/main/java/com/facebook/react/modules/network/ProgressResponseBody.java b/ReactAndroid/src/main/java/com/facebook/react/modules/network/ProgressResponseBody.java new file mode 100644 index 00000000000000..2fdf23961b055d --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/modules/network/ProgressResponseBody.java @@ -0,0 +1,59 @@ +package com.facebook.react.modules.network; + +import java.io.IOException; + +import okhttp3.MediaType; +import okhttp3.ResponseBody; +import okio.Buffer; +import okio.BufferedSource; +import okio.ForwardingSource; +import okio.Okio; +import okio.Source; + +public class ProgressResponseBody extends ResponseBody { + + private final ResponseBody mResponseBody; + private final ProgressListener mProgressListener; + private BufferedSource mBufferedSource; + private long mTotalBytesRead; + + public ProgressResponseBody(ResponseBody responseBody, ProgressListener progressListener) { + this.mResponseBody = responseBody; + this.mProgressListener = progressListener; + mTotalBytesRead = 0L; + } + + + @Override + public MediaType contentType() { + return mResponseBody.contentType(); + } + + @Override + public long contentLength() { + return mResponseBody.contentLength(); + } + + public long totalBytesRead() { + return mTotalBytesRead; + } + + @Override public BufferedSource source() { + if (mBufferedSource == null) { + mBufferedSource = Okio.buffer(source(mResponseBody.source())); + } + return mBufferedSource; + } + + private Source source(Source source) { + return new ForwardingSource(source) { + @Override public long read(Buffer sink, long byteCount) throws IOException { + long bytesRead = super.read(sink, byteCount); + // read() returns the number of bytes read, or -1 if this source is exhausted. + mTotalBytesRead += bytesRead != -1 ? bytesRead : 0; + mProgressListener.onProgress(mTotalBytesRead, mResponseBody.contentLength(), bytesRead == -1); + return bytesRead; + } + }; + } +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/modules/network/RequestBodyUtil.java b/ReactAndroid/src/main/java/com/facebook/react/modules/network/RequestBodyUtil.java index 1d5a5e1d916a05..004b15616795d4 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/modules/network/RequestBodyUtil.java +++ b/ReactAndroid/src/main/java/com/facebook/react/modules/network/RequestBodyUtil.java @@ -117,7 +117,7 @@ public void writeTo(BufferedSink sink) throws IOException { /** * Creates a ProgressRequestBody that can be used for showing uploading progress */ - public static ProgressRequestBody createProgressRequest(RequestBody requestBody, ProgressRequestListener listener) { + public static ProgressRequestBody createProgressRequest(RequestBody requestBody, ProgressListener listener) { return new ProgressRequestBody(requestBody, listener); } diff --git a/ReactAndroid/src/main/java/com/facebook/react/modules/network/ResponseUtil.java b/ReactAndroid/src/main/java/com/facebook/react/modules/network/ResponseUtil.java index eb2b6e2bbb6a5d..5b589e195b0ccc 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/modules/network/ResponseUtil.java +++ b/ReactAndroid/src/main/java/com/facebook/react/modules/network/ResponseUtil.java @@ -33,6 +33,34 @@ public static void onDataSend( eventEmitter.emit("didSendNetworkData", args); } + public static void onIncrementalDataReceived( + RCTDeviceEventEmitter eventEmitter, + int requestId, + String data, + long progress, + long total) { + WritableArray args = Arguments.createArray(); + args.pushInt(requestId); + args.pushString(data); + args.pushInt((int) progress); + args.pushInt((int) total); + + eventEmitter.emit("didReceiveNetworkIncrementalData", args); + } + + public static void onDataReceivedProgress( + RCTDeviceEventEmitter eventEmitter, + int requestId, + long progress, + long total) { + WritableArray args = Arguments.createArray(); + args.pushInt(requestId); + args.pushInt((int) progress); + args.pushInt((int) total); + + eventEmitter.emit("didReceiveNetworkDataProgress", args); + } + public static void onDataReceived( RCTDeviceEventEmitter eventEmitter, int requestId, diff --git a/ReactAndroid/src/test/java/com/facebook/react/modules/network/NetworkingModuleTest.java b/ReactAndroid/src/test/java/com/facebook/react/modules/network/NetworkingModuleTest.java index c06a99f9adef47..3c61238c5d9c7f 100644 --- a/ReactAndroid/src/test/java/com/facebook/react/modules/network/NetworkingModuleTest.java +++ b/ReactAndroid/src/test/java/com/facebook/react/modules/network/NetworkingModuleTest.java @@ -61,11 +61,12 @@ Call.class, RequestBodyUtil.class, ProgressRequestBody.class, - ProgressRequestListener.class, + ProgressListener.class, MultipartBody.class, MultipartBody.Builder.class, NetworkingModule.class, OkHttpClient.class, + OkHttpClient.Builder.class, OkHttpCallUtil.class}) @RunWith(RobolectricTestRunner.class) @PowerMockIgnore({"org.mockito.*", "org.robolectric.*", "android.*"}) @@ -84,6 +85,9 @@ public Object answer(InvocationOnMock invocation) throws Throwable { return callMock; } }); + OkHttpClient.Builder clientBuilder = mock(OkHttpClient.Builder.class); + when(clientBuilder.build()).thenReturn(httpClient); + when(httpClient.newBuilder()).thenReturn(clientBuilder); NetworkingModule networkingModule = new NetworkingModule(mock(ReactApplicationContext.class), "", httpClient); @@ -91,11 +95,12 @@ public Object answer(InvocationOnMock invocation) throws Throwable { mock(ExecutorToken.class), "GET", "http://somedomain/foo", - 0, - JavaOnlyArray.of(), - null, - true, - 0); + /* requestId */ 0, + /* headers */ JavaOnlyArray.of(), + /* body */ null, + /* responseType */ "text", + /* useIncrementalUpdates*/ true, + /* timeout */ 0); ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(Request.class); verify(httpClient).newCall(argumentCaptor.capture()); @@ -112,6 +117,9 @@ public void testFailGetWithInvalidHeadersStruct() throws Exception { when(context.getJSModule(any(ExecutorToken.class), any(Class.class))).thenReturn(emitter); OkHttpClient httpClient = mock(OkHttpClient.class); + OkHttpClient.Builder clientBuilder = mock(OkHttpClient.Builder.class); + when(clientBuilder.build()).thenReturn(httpClient); + when(httpClient.newBuilder()).thenReturn(clientBuilder); NetworkingModule networkingModule = new NetworkingModule(context, "", httpClient); List invalidHeaders = Arrays.asList(JavaOnlyArray.of("foo")); @@ -122,11 +130,12 @@ public void testFailGetWithInvalidHeadersStruct() throws Exception { mock(ExecutorToken.class), "GET", "http://somedoman/foo", - 0, - JavaOnlyArray.from(invalidHeaders), - null, - true, - 0); + /* requestId */ 0, + /* headers */ JavaOnlyArray.from(invalidHeaders), + /* body */ null, + /* responseType */ "text", + /* useIncrementalUpdates*/ true, + /* timeout */ 0); verifyErrorEmit(emitter, 0); } @@ -138,6 +147,9 @@ public void testFailPostWithoutContentType() throws Exception { when(context.getJSModule(any(ExecutorToken.class), any(Class.class))).thenReturn(emitter); OkHttpClient httpClient = mock(OkHttpClient.class); + OkHttpClient.Builder clientBuilder = mock(OkHttpClient.Builder.class); + when(clientBuilder.build()).thenReturn(httpClient); + when(httpClient.newBuilder()).thenReturn(clientBuilder); NetworkingModule networkingModule = new NetworkingModule(context, "", httpClient); JavaOnlyMap body = new JavaOnlyMap(); @@ -152,8 +164,9 @@ public void testFailPostWithoutContentType() throws Exception { 0, JavaOnlyArray.of(), body, - true, - 0); + /* responseType */ "text", + /* useIncrementalUpdates*/ true, + /* timeout */ 0); verifyErrorEmit(emitter, 0); } @@ -196,6 +209,9 @@ public Object answer(InvocationOnMock invocation) throws Throwable { return callMock; } }); + OkHttpClient.Builder clientBuilder = mock(OkHttpClient.Builder.class); + when(clientBuilder.build()).thenReturn(httpClient); + when(httpClient.newBuilder()).thenReturn(clientBuilder); NetworkingModule networkingModule = new NetworkingModule(mock(ReactApplicationContext.class), "", httpClient); @@ -209,8 +225,9 @@ public Object answer(InvocationOnMock invocation) throws Throwable { 0, JavaOnlyArray.of(JavaOnlyArray.of("Content-Type", "text/plain")), body, - true, - 0); + /* responseType */ "text", + /* useIncrementalUpdates*/ true, + /* timeout */ 0); ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(Request.class); verify(httpClient).newCall(argumentCaptor.capture()); @@ -234,6 +251,9 @@ public Object answer(InvocationOnMock invocation) throws Throwable { return callMock; } }); + OkHttpClient.Builder clientBuilder = mock(OkHttpClient.Builder.class); + when(clientBuilder.build()).thenReturn(httpClient); + when(httpClient.newBuilder()).thenReturn(clientBuilder); NetworkingModule networkingModule = new NetworkingModule(mock(ReactApplicationContext.class), "", httpClient); @@ -248,8 +268,9 @@ public Object answer(InvocationOnMock invocation) throws Throwable { 0, JavaOnlyArray.from(headers), null, - true, - 0); + /* responseType */ "text", + /* useIncrementalUpdates*/ true, + /* timeout */ 0); ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(Request.class); verify(httpClient).newCall(argumentCaptor.capture()); Headers requestHeaders = argumentCaptor.getValue().headers(); @@ -265,7 +286,7 @@ public void testMultipartPostRequestSimple() throws Exception { .thenReturn(mock(InputStream.class)); when(RequestBodyUtil.create(any(MediaType.class), any(InputStream.class))) .thenReturn(mock(RequestBody.class)); - when(RequestBodyUtil.createProgressRequest(any(RequestBody.class), any(ProgressRequestListener.class))).thenCallRealMethod(); + when(RequestBodyUtil.createProgressRequest(any(RequestBody.class), any(ProgressListener.class))).thenCallRealMethod(); JavaOnlyMap body = new JavaOnlyMap(); JavaOnlyArray formData = new JavaOnlyArray(); @@ -288,6 +309,9 @@ public Object answer(InvocationOnMock invocation) throws Throwable { return callMock; } }); + OkHttpClient.Builder clientBuilder = mock(OkHttpClient.Builder.class); + when(clientBuilder.build()).thenReturn(httpClient); + when(httpClient.newBuilder()).thenReturn(clientBuilder); NetworkingModule networkingModule = new NetworkingModule(mock(ReactApplicationContext.class), "", httpClient); networkingModule.sendRequest( @@ -297,8 +321,9 @@ public Object answer(InvocationOnMock invocation) throws Throwable { 0, new JavaOnlyArray(), body, - true, - 0); + /* responseType */ "text", + /* useIncrementalUpdates*/ true, + /* timeout */ 0); // verify url, method, headers ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(Request.class); @@ -320,7 +345,7 @@ public void testMultipartPostRequestHeaders() throws Exception { .thenReturn(mock(InputStream.class)); when(RequestBodyUtil.create(any(MediaType.class), any(InputStream.class))) .thenReturn(mock(RequestBody.class)); - when(RequestBodyUtil.createProgressRequest(any(RequestBody.class), any(ProgressRequestListener.class))).thenCallRealMethod(); + when(RequestBodyUtil.createProgressRequest(any(RequestBody.class), any(ProgressListener.class))).thenCallRealMethod(); List headers = Arrays.asList( JavaOnlyArray.of("Accept", "text/plain"), @@ -348,6 +373,9 @@ public Object answer(InvocationOnMock invocation) throws Throwable { return callMock; } }); + OkHttpClient.Builder clientBuilder = mock(OkHttpClient.Builder.class); + when(clientBuilder.build()).thenReturn(httpClient); + when(httpClient.newBuilder()).thenReturn(clientBuilder); NetworkingModule networkingModule = new NetworkingModule(mock(ReactApplicationContext.class), "", httpClient); networkingModule.sendRequest( @@ -357,8 +385,9 @@ public Object answer(InvocationOnMock invocation) throws Throwable { 0, JavaOnlyArray.from(headers), body, - true, - 0); + /* responseType */ "text", + /* useIncrementalUpdates*/ true, + /* timeout */ 0); // verify url, method, headers ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(Request.class); @@ -383,7 +412,7 @@ public void testMultipartPostRequestBody() throws Exception { when(RequestBodyUtil.getFileInputStream(any(ReactContext.class), any(String.class))) .thenReturn(inputStream); when(RequestBodyUtil.create(any(MediaType.class), any(InputStream.class))).thenCallRealMethod(); - when(RequestBodyUtil.createProgressRequest(any(RequestBody.class), any(ProgressRequestListener.class))).thenCallRealMethod(); + when(RequestBodyUtil.createProgressRequest(any(RequestBody.class), any(ProgressListener.class))).thenCallRealMethod(); when(inputStream.available()).thenReturn("imageUri".length()); final MultipartBody.Builder multipartBuilder = mock(MultipartBody.Builder.class); @@ -445,6 +474,9 @@ public Object answer(InvocationOnMock invocation) throws Throwable { return callMock; } }); + OkHttpClient.Builder clientBuilder = mock(OkHttpClient.Builder.class); + when(clientBuilder.build()).thenReturn(httpClient); + when(httpClient.newBuilder()).thenReturn(clientBuilder); NetworkingModule networkingModule = new NetworkingModule(mock(ReactApplicationContext.class), "", httpClient); @@ -455,8 +487,9 @@ public Object answer(InvocationOnMock invocation) throws Throwable { 0, JavaOnlyArray.from(headers), body, - true, - 0); + /* responseType */ "text", + /* useIncrementalUpdates*/ true, + /* timeout */ 0); // verify RequestBodyPart for image PowerMockito.verifyStatic(times(1)); @@ -503,6 +536,9 @@ public Object answer(InvocationOnMock invocation) throws Throwable { return calls[(Integer) request.tag() - 1]; } }); + OkHttpClient.Builder clientBuilder = mock(OkHttpClient.Builder.class); + when(clientBuilder.build()).thenReturn(httpClient); + when(httpClient.newBuilder()).thenReturn(clientBuilder); NetworkingModule networkingModule = new NetworkingModule(mock(ReactApplicationContext.class), "", httpClient); networkingModule.initialize(); @@ -515,7 +551,8 @@ public Object answer(InvocationOnMock invocation) throws Throwable { idx + 1, JavaOnlyArray.of(), null, - true, + /* responseType */ "text", + /* useIncrementalUpdates*/ true, 0); } verify(httpClient, times(3)).newCall(any(Request.class)); @@ -550,6 +587,9 @@ public Object answer(InvocationOnMock invocation) throws Throwable { return calls[(Integer) request.tag() - 1]; } }); + OkHttpClient.Builder clientBuilder = mock(OkHttpClient.Builder.class); + when(clientBuilder.build()).thenReturn(httpClient); + when(httpClient.newBuilder()).thenReturn(clientBuilder); NetworkingModule networkingModule = new NetworkingModule(mock(ReactApplicationContext.class), "", httpClient); @@ -561,7 +601,8 @@ public Object answer(InvocationOnMock invocation) throws Throwable { idx + 1, JavaOnlyArray.of(), null, - true, + /* responseType */ "text", + /* useIncrementalUpdates*/ true, 0); } verify(httpClient, times(3)).newCall(any(Request.class));