Add responseType as a concept to RCTNetworking, send binary data as base64

Summary:
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 accessed 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.

Closes https://github.com/facebook/react-native/pull/8324

Reviewed By: javache

Differential Revision: D3508822

Pulled By: davidaurelio

fbshipit-source-id: 441b2d4d40265b6036559c3ccb9fa962999fa5df
This commit is contained in:
Philipp von Weitershausen 2016-07-13 04:53:54 -07:00 коммит произвёл Facebook Github Bot 0
Родитель c65eb4ef19
Коммит 08c375f828
18 изменённых файлов: 849 добавлений и 368 удалений

Просмотреть файл

@ -25,14 +25,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');
@ -40,6 +41,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.
@ -54,8 +62,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,
};
}
@ -63,44 +81,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,
});
}
}
};
xhr.open('GET', 'http://www.gutenberg.org/cache/epub/100/pg100.txt');
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://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() {
@ -109,7 +149,7 @@ class Downloader extends React.Component {
}
render() {
var button = this.state.status === 'Downloading...' ? (
var button = this.state.downloading ? (
<View style={styles.wrapper}>
<View style={styles.button}>
<Text>...</Text>
@ -125,11 +165,67 @@ class Downloader extends React.Component {
</TouchableHighlight>
);
let readystate = null;
let progress = null;
if (this.state.readystateHandler && !this.state.arraybuffer) {
const { responseLength, contentLength } = this.state;
readystate = (
<View>
<Text style={styles.progressBarLabel}>
responseText:{' '}
{roundKilo(responseLength)}/{roundKilo(contentLength)}k chars
</Text>
<ProgressBarAndroid
progress={(responseLength / contentLength)}
styleAttr="Horizontal"
indeterminate={false}
/>
</View>
);
}
if (this.state.progressHandler) {
const { progressLoaded, progressTotal } = this.state;
progress = (
<View>
<Text style={styles.progressBarLabel}>
onprogress:{' '}
{roundKilo(progressLoaded)}/{roundKilo(progressTotal)} KB
</Text>
<ProgressBarAndroid
progress={(progressLoaded / progressTotal)}
styleAttr="Horizontal"
indeterminate={false}
/>
</View>
);
}
return (
<View>
<View style={styles.configRow}>
<Text>onreadystatechange handler</Text>
<Switch
value={this.state.readystateHandler}
onValueChange={(readystateHandler => this.setState({readystateHandler}))}
/>
</View>
<View style={styles.configRow}>
<Text>onprogress handler</Text>
<Switch
value={this.state.progressHandler}
onValueChange={(progressHandler => this.setState({progressHandler}))}
/>
</View>
<View style={styles.configRow}>
<Text>download as arraybuffer</Text>
<Switch
value={this.state.arraybuffer}
onValueChange={(arraybuffer => this.setState({arraybuffer}))}
/>
</View>
{button}
<ProgressBarAndroid progress={(this.state.downloaded / this.state.contentSize)}
styleAttr="Horizontal" indeterminate={false} />
{readystate}
{progress}
<Text>{this.state.status}</Text>
</View>
);
@ -364,6 +460,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,

Просмотреть файл

@ -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);
}
}
};
xhr.open('GET', 'http://www.gutenberg.org/cache/epub/100/pg100.txt');
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://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)}>
<View style={styles.button}>
<Text>Download 5MB Text File</Text>
<Text>Download 5MB Text File</Text>
</View>
</TouchableHighlight>
);
let readystate = null;
let progress = null;
if (this.state.readystateHandler && !this.state.arraybuffer) {
const { responseLength, contentLength } = this.state;
readystate = (
<View>
<Text style={styles.progressBarLabel}>
responseText:{' '}
{roundKilo(responseLength)}/{roundKilo(contentLength)}k chars
</Text>
<ProgressViewIOS
progress={(responseLength / contentLength)}
/>
</View>
);
}
if (this.state.progressHandler) {
const { progressLoaded, progressTotal } = this.state;
progress = (
<View>
<Text style={styles.progressBarLabel}>
onprogress:{' '}
{roundKilo(progressLoaded)}/{roundKilo(progressTotal)} KB
</Text>
<ProgressViewIOS
progress={(progressLoaded / progressTotal)}
/>
</View>
);
}
return (
<View>
<View style={styles.configRow}>
<Text>onreadystatechange handler</Text>
<Switch
value={this.state.readystateHandler}
onValueChange={(readystateHandler => this.setState({readystateHandler}))}
/>
</View>
<View style={styles.configRow}>
<Text>onprogress handler</Text>
<Switch
value={this.state.progressHandler}
onValueChange={(progressHandler => this.setState({progressHandler}))}
/>
</View>
<View style={styles.configRow}>
<Text>download as arraybuffer</Text>
<Switch
value={this.state.arraybuffer}
onValueChange={(arraybuffer => this.setState({arraybuffer}))}
/>
</View>
{button}
<ProgressViewIOS progress={(this.state.downloaded / this.state.contentSize)}/>
{readystate}
{progress}
</View>
);
}
@ -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,

Просмотреть файл

@ -76,7 +76,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();
});
}

Просмотреть файл

@ -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);

Просмотреть файл

@ -126,7 +126,7 @@ RCT_NOT_IMPLEMENTED(- (instancetype)init)
}
[_data appendData:data];
if (_incrementalDataBlock) {
_incrementalDataBlock(data);
_incrementalDataBlock(data, _data.length, _response.expectedContentLength);
}
if (_downloadProgressBlock && _response.expectedContentLength > 0) {
_downloadProgressBlock(_data.length, _response.expectedContentLength);

Просмотреть файл

@ -7,6 +7,7 @@
* of patent rights can be found in the PATENTS file in the same directory.
*
* @providesModule RCTNetworking
* @flow
*/
'use strict';
@ -42,33 +43,28 @@ class RCTNetworking extends NativeEventEmitter {
}
sendRequest(
method: ?string,
method: string,
trackingName: string,
url: ?string,
url: string,
headers: Object,
data: string | FormData | Object,
data: string | FormData | {uri: string},
responseType: 'text' | 'base64',
incrementalUpdates: boolean,
timeout: number,
callback: (requestId: number) => void,
callback: (requestId: number) => any
) {
if (typeof data === 'string') {
data = {string: data};
} else if (data instanceof FormData) {
data = {
formData: data.getParts().map((part) => {
part.headers = convertHeadersMapToArray(part.headers);
return part;
}),
};
}
data = {...data, trackingName};
const body =
typeof data === 'string' ? {string: data} :
data instanceof FormData ? {formData: getParts(data)} :
data;
const requestId = generateRequestId();
RCTNetworkingNative.sendRequest(
method,
url,
requestId,
convertHeadersMapToArray(headers),
data,
{...body, trackingName},
responseType,
incrementalUpdates,
timeout
);
@ -79,9 +75,16 @@ class RCTNetworking extends NativeEventEmitter {
RCTNetworkingNative.abortRequest(requestId);
}
clearCookies(callback: number) {
clearCookies(callback: (result: boolean) => any) {
RCTNetworkingNative.clearCookies(callback);
}
}
function getParts(data) {
return data.getParts().map((part) => {
part.headers = convertHeadersMapToArray(part.headers);
return part;
});
}
module.exports = new RCTNetworking();

Просмотреть файл

@ -7,6 +7,7 @@
* of patent rights can be found in the PATENTS file in the same directory.
*
* @providesModule RCTNetworking
* @flow
*/
'use strict';
@ -21,26 +22,26 @@ class RCTNetworking extends NativeEventEmitter {
}
sendRequest(
method: ?string,
method: string,
trackingName: string,
url: ?string,
url: string,
headers: Object,
data: string | FormData | Object,
data: string | FormData | {uri: string},
responseType: 'text' | 'base64',
incrementalUpdates: boolean,
timeout: number,
callback: (requestId: number) => void,
callback: (requestId: number) => any
) {
if (typeof data === 'string') {
data = {string: data};
} else if (data instanceof FormData) {
data = {formData: data.getParts()};
}
data = {...data, trackingName};
const body =
typeof data === 'string' ? {string: data} :
data instanceof FormData ? {formData: data.getParts()} :
data;
RCTNetworkingNative.sendRequest({
method,
url,
data,
data: {...body, trackingName},
headers,
responseType,
incrementalUpdates,
timeout
}, callback);
@ -50,7 +51,7 @@ class RCTNetworking extends NativeEventEmitter {
RCTNetworkingNative.abortRequest(requestId);
}
clearCookies(callback: number) {
clearCookies(callback: (result: boolean) => any) {
console.warn('RCTNetworking.clearCookies is not supported on iOS');
}
}

Просмотреть файл

@ -138,6 +138,8 @@ RCT_EXPORT_MODULE()
return @[@"didCompleteNetworkResponse",
@"didReceiveNetworkResponse",
@"didSendNetworkData",
@"didReceiveNetworkIncrementalData",
@"didReceiveNetworkDataProgress",
@"didReceiveNetworkData"];
}
@ -313,26 +315,16 @@ RCT_EXPORT_MODULE()
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 @@ RCT_EXPORT_MODULE()
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<id> *responseJSON = @[task.requestID, responseText ?: @""];
NSArray<id> *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 @@ RCT_EXPORT_MODULE()
});
};
void (^responseBlock)(NSURLResponse *) = ^(NSURLResponse *response) {
RCTURLRequestResponseBlock responseBlock = ^(NSURLResponse *response) {
dispatch_async(self->_methodQueue, ^{
NSDictionary<NSString *, NSString *> *headers;
NSInteger status;
@ -389,17 +402,44 @@ RCT_EXPORT_MODULE()
});
};
void (^incrementalDataBlock)(NSData *) = incrementalUpdates ? ^(NSData *data) {
dispatch_async(self->_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(self->_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<id> *responseJSON = @[task.requestID, responseString, @(progress), @(total)];
[self sendEventWithName:@"didReceiveNetworkIncrementalData" body:responseJSON];
});
};
} else {
downloadProgressBlock = ^(int64_t progress, int64_t total) {
dispatch_async(self->_methodQueue, ^{
NSArray<id> *responseJSON = @[task.requestID, @(progress), @(total)];
[self sendEventWithName:@"didReceiveNetworkDataProgress" body:responseJSON];
});
};
}
}
RCTURLRequestCompletionBlock completionBlock =
^(NSURLResponse *response, NSData *data, NSError *error) {
dispatch_async(self->_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 @@ RCT_EXPORT_MODULE()
};
task = [self networkTaskWithRequest:request completionBlock:completionBlock];
task.downloadProgressBlock = downloadProgressBlock;
task.incrementalDataBlock = incrementalDataBlock;
task.responseBlock = responseBlock;
task.uploadProgressBlock = uploadProgressBlock;
@ -453,8 +494,10 @@ RCT_EXPORT_METHOD(sendRequest:(NSDictionary *)query
// 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];
}];

Просмотреть файл

@ -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;
@ -125,7 +125,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 = {};
@ -141,10 +141,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)) {
@ -174,7 +174,7 @@ class XMLHttpRequest extends EventTarget(...XHR_EVENTS) {
if (this.readyState < LOADING) {
return '';
}
return this._responseText;
return this._response;
}
// $FlowIssue #10784535
@ -183,7 +183,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) {
@ -194,26 +194,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;
}
@ -232,7 +231,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',
@ -261,16 +264,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
@ -281,7 +315,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;
@ -343,49 +379,12 @@ 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 {
this._subscriptions.push(RCTNetworking.addListener(
'didSendNetworkData',
(args) => this.__didUploadProgress(...args)
));
this._subscriptions.push(RCTNetworking.addListener(
'didReceiveNetworkResponse',
(args) => this.__didReceiveResponse(...args)
));
this._subscriptions.push(RCTNetworking.addListener(
'didReceiveNetworkData',
(args) => this.__didReceiveData(...args)
));
this._subscriptions.push(RCTNetworking.addListener(
'didCompleteNetworkResponse',
(args) => this.__didCompleteResponse(...args)
));
RCTNetworking.sendRequest(
method,
this._trackingName,
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');
@ -394,14 +393,52 @@ class XMLHttpRequest extends EventTarget(...XHR_EVENTS) {
throw new Error('Request has already been sent');
}
this._sent = true;
const incrementalEvents = this._incrementalEvents || !!this.onreadystatechange;
this.sendImpl(
const incrementalEvents = this._incrementalEvents ||
!!this.onreadystatechange ||
!!this.onprogress;
this._subscriptions.push(RCTNetworking.addListener(
'didSendNetworkData',
(args) => this.__didUploadProgress(...args)
));
this._subscriptions.push(RCTNetworking.addListener(
'didReceiveNetworkResponse',
(args) => this.__didReceiveResponse(...args)
));
this._subscriptions.push(RCTNetworking.addListener(
'didReceiveNetworkData',
(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)
));
let nativeResponseType = 'text';
if (this._responseType === 'arraybuffer' || this._responseType === 'blob') {
nativeResponseType = 'base64';
}
invariant(this._method, 'Request method needs to be defined.');
invariant(this._url, 'Request URL needs to be defined.');
RCTNetworking.sendRequest(
this._method,
this._trackingName,
this._url,
this._headers,
data,
nativeResponseType,
incrementalEvents,
this.timeout
this.timeout,
this.__didCreateRequest.bind(this),
);
}
@ -454,32 +491,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;

Просмотреть файл

@ -10,160 +10,174 @@
'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(){
var xhr;
var handleTimeout;
var handleError;
var handleLoad;
var handleReadyStateChange;
describe('XMLHttpRequest', function() {
var xhr;
var handleTimeout;
var handleError;
var handleLoad;
var handleReadyStateChange;
beforeEach(() => {
xhr = new XMLHttpRequest();
beforeEach(() => {
xhr = new XMLHttpRequest();
xhr.ontimeout = jest.fn();
xhr.onerror = jest.fn();
xhr.onload = jest.fn();
xhr.onreadystatechange = jest.fn();
xhr.ontimeout = jest.fn();
xhr.onerror = jest.fn();
xhr.onload = jest.fn();
xhr.onreadystatechange = jest.fn();
handleTimeout = jest.fn();
handleError = jest.fn();
handleLoad = jest.fn();
handleReadyStateChange = jest.fn();
handleTimeout = jest.fn();
handleError = jest.fn();
handleLoad = jest.fn();
handleReadyStateChange = jest.fn();
xhr.addEventListener('timeout', handleTimeout);
xhr.addEventListener('error', handleError);
xhr.addEventListener('load', handleLoad);
xhr.addEventListener('readystatechange', handleReadyStateChange);
xhr.addEventListener('timeout', handleTimeout);
xhr.addEventListener('error', handleError);
xhr.addEventListener('load', handleLoad);
xhr.addEventListener('readystatechange', handleReadyStateChange);
});
xhr.__didCreateRequest(1);
});
afterEach(() => {
xhr = null;
handleTimeout = null;
handleError = null;
handleLoad = null;
});
afterEach(() => {
xhr = null;
handleTimeout = null;
handleError = null;
handleLoad = null;
});
it('should transition readyState correctly', function() {
expect(xhr.readyState).toBe(xhr.UNSENT);
it('should transition readyState correctly', function() {
xhr.open('GET', 'blabla');
expect(xhr.readyState).toBe(xhr.UNSENT);
expect(xhr.onreadystatechange.mock.calls.length).toBe(1);
expect(handleReadyStateChange.mock.calls.length).toBe(1);
expect(xhr.readyState).toBe(xhr.OPENED);
});
xhr.open('GET', 'blabla');
it('should expose responseType correctly', function() {
expect(xhr.responseType).toBe('');
expect(xhr.onreadystatechange.mock.calls.length).toBe(1);
expect(handleReadyStateChange.mock.calls.length).toBe(1);
expect(xhr.readyState).toBe(xhr.OPENED);
});
// Setting responseType to an unsupported value has no effect.
xhr.responseType = 'arrayblobbuffertextfile';
expect(xhr.responseType).toBe('');
it('should expose responseType correctly', function() {
expect(xhr.responseType).toBe('');
xhr.responseType = 'arraybuffer';
expect(xhr.responseType).toBe('arraybuffer');
// Setting responseType to an unsupported value has no effect.
xhr.responseType = 'arrayblobbuffertextfile';
expect(xhr.responseType).toBe('');
// Can't change responseType after first data has been received.
xhr.open('GET', 'blabla');
xhr.send();
expect(() => { xhr.responseType = 'text'; }).toThrow();
});
xhr.responseType = 'arraybuffer';
expect(xhr.responseType).toBe('arraybuffer');
it('should expose responseText correctly', function() {
xhr.responseType = '';
expect(xhr.responseText).toBe('');
expect(xhr.response).toBe('');
// Can't change responseType after first data has been received.
xhr.__didReceiveData(1, 'Some data');
expect(() => { xhr.responseType = 'text'; }).toThrow();
});
xhr.responseType = 'arraybuffer';
expect(() => xhr.responseText).toThrow();
expect(xhr.response).toBe(null);
it('should expose responseText correctly', function() {
xhr.responseType = '';
expect(xhr.responseText).toBe('');
expect(xhr.response).toBe('');
xhr.responseType = 'text';
expect(xhr.responseText).toBe('');
expect(xhr.response).toBe('');
xhr.responseType = 'arraybuffer';
expect(() => xhr.responseText).toThrow();
expect(xhr.response).toBe(null);
// responseText is read-only.
expect(() => { xhr.responseText = 'hi'; }).toThrow();
expect(xhr.responseText).toBe('');
expect(xhr.response).toBe('');
xhr.responseType = 'text';
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');
});
// responseText is read-only.
expect(() => { xhr.responseText = 'hi'; }).toThrow();
expect(xhr.responseText).toBe('');
expect(xhr.response).toBe('');
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);
xhr.__didReceiveData(1, 'Some data');
expect(xhr.responseText).toBe('Some data');
});
expect(xhr.readyState).toBe(xhr.DONE);
it('should call ontimeout function when the request times out', function(){
xhr.__didCompleteResponse(1, 'Timeout', true);
expect(xhr.ontimeout.mock.calls.length).toBe(1);
expect(xhr.onerror).not.toBeCalled();
expect(xhr.onload).not.toBeCalled();
expect(xhr.readyState).toBe(xhr.DONE);
expect(handleTimeout.mock.calls.length).toBe(1);
expect(handleError).not.toBeCalled();
expect(handleLoad).not.toBeCalled();
});
expect(xhr.ontimeout.mock.calls.length).toBe(1);
expect(xhr.onerror).not.toBeCalled();
expect(xhr.onload).not.toBeCalled();
it('should call onerror function when the request times out', function() {
xhr.open('GET', 'blabla');
xhr.send();
xhr.__didCompleteResponse(1, 'Generic error');
expect(handleTimeout.mock.calls.length).toBe(1);
expect(handleError).not.toBeCalled();
expect(handleLoad).not.toBeCalled();
});
expect(xhr.readyState).toBe(xhr.DONE);
it('should call onerror function when the request times out', function(){
xhr.__didCompleteResponse(1, 'Generic error');
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(xhr.readyState).toBe(xhr.DONE);
expect(handleReadyStateChange.mock.calls.length).toBe(2);
expect(handleError.mock.calls.length).toBe(1);
expect(handleTimeout).not.toBeCalled();
expect(handleLoad).not.toBeCalled();
});
expect(xhr.onreadystatechange.mock.calls.length).toBe(1);
expect(xhr.onerror.mock.calls.length).toBe(1);
expect(xhr.ontimeout).not.toBeCalled();
expect(xhr.onload).not.toBeCalled();
it('should call onload function when there is no error', function() {
xhr.open('GET', 'blabla');
xhr.send();
xhr.__didCompleteResponse(1, null);
expect(handleReadyStateChange.mock.calls.length).toBe(1);
expect(handleError.mock.calls.length).toBe(1);
expect(handleTimeout).not.toBeCalled();
expect(handleLoad).not.toBeCalled();
});
expect(xhr.readyState).toBe(xhr.DONE);
it('should call onload function when there is no error', function(){
xhr.__didCompleteResponse(1, null);
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(xhr.readyState).toBe(xhr.DONE);
expect(handleReadyStateChange.mock.calls.length).toBe(2);
expect(handleLoad.mock.calls.length).toBe(1);
expect(handleError).not.toBeCalled();
expect(handleTimeout).not.toBeCalled();
});
expect(xhr.onreadystatechange.mock.calls.length).toBe(1);
expect(xhr.onload.mock.calls.length).toBe(1);
expect(xhr.onerror).not.toBeCalled();
expect(xhr.ontimeout).not.toBeCalled();
it('should call onload function when there is no error', function() {
xhr.open('GET', 'blabla');
xhr.send();
expect(handleReadyStateChange.mock.calls.length).toBe(1);
expect(handleLoad.mock.calls.length).toBe(1);
expect(handleError).not.toBeCalled();
expect(handleTimeout).not.toBeCalled();
});
xhr.upload.onprogress = jest.fn();
var handleProgress = jest.fn();
xhr.upload.addEventListener('progress', handleProgress);
it('should call onload function when there is no error', function() {
xhr.upload.onprogress = jest.fn();
var handleProgress = jest.fn();
xhr.upload.addEventListener('progress', handleProgress);
xhr.__didUploadProgress(1, 42, 100);
xhr.__didUploadProgress(1, 42, 100);
expect(xhr.upload.onprogress.mock.calls.length).toBe(1);
expect(handleProgress.mock.calls.length).toBe(1);
expect(xhr.upload.onprogress.mock.calls.length).toBe(1);
expect(handleProgress.mock.calls.length).toBe(1);
expect(xhr.upload.onprogress.mock.calls[0][0].loaded).toBe(42);
expect(xhr.upload.onprogress.mock.calls[0][0].total).toBe(100);
expect(handleProgress.mock.calls[0][0].loaded).toBe(42);
expect(handleProgress.mock.calls[0][0].total).toBe(100);
});
expect(xhr.upload.onprogress.mock.calls[0][0].loaded).toBe(42);
expect(xhr.upload.onprogress.mock.calls[0][0].total).toBe(100);
expect(handleProgress.mock.calls[0][0].loaded).toBe(42);
expect(handleProgress.mock.calls[0][0].total).toBe(100);
});
});

Просмотреть файл

@ -83,6 +83,7 @@ public class NetworkRecordingModuleMock extends ReactContextBaseJavaModule {
int requestId,
ReadableArray headers,
ReadableMap data,
final String responseType,
boolean incrementalUpdates,
int timeout) {
mLastRequestId = requestId;

Просмотреть файл

@ -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 com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEm
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 final class NetworkingModule extends ReactContextBaseJavaModule {
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 final class NetworkingModule extends ReactContextBaseJavaModule {
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 final class NetworkingModule extends ReactContextBaseJavaModule {
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 final class NetworkingModule extends ReactContextBaseJavaModule {
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);
}
@ -310,12 +360,27 @@ public final class NetworkingModule extends ReactContextBaseJavaModule {
RCTDeviceEventEmitter eventEmitter,
int requestId,
ResponseBody responseBody) throws IOException {
long totalBytesRead = -1;
long contentLength = -1;
try {
ProgressResponseBody progressResponseBody = (ProgressResponseBody) responseBody;
totalBytesRead = progressResponseBody.totalBytesRead();
contentLength = progressResponseBody.contentLength();
} catch (ClassCastException e) {
// Ignore
}
Reader reader = responseBody.charStream();
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),
totalBytesRead,
contentLength);
}
} finally {
reader.close();

Просмотреть файл

@ -6,10 +6,9 @@
* 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);
}

Просмотреть файл

@ -12,22 +12,19 @@ package com.facebook.react.modules.network;
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,8 @@ public class ProgressRequestBody extends RequestBody {
contentLength = contentLength();
}
bytesWritten += byteCount;
mProgressListener.onRequestProgress(bytesWritten, contentLength, bytesWritten == contentLength);
mProgressListener.onProgress(
bytesWritten, contentLength, bytesWritten == contentLength);
}
};
}

Просмотреть файл

@ -0,0 +1,62 @@
// Copyright 2004-present Facebook. All Rights Reserved.
package com.facebook.react.modules.network;
import java.io.IOException;
import javax.annotation.Nullable;
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 @Nullable 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;
}
};
}
}

Просмотреть файл

@ -117,7 +117,9 @@ import okio.Source;
/**
* 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);
}

Просмотреть файл

@ -33,6 +33,34 @@ public class ResponseUtil {
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,

Просмотреть файл

@ -61,11 +61,12 @@ import static org.mockito.Mockito.when;
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 class NetworkingModuleTest {
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 class NetworkingModuleTest {
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<Request> argumentCaptor = ArgumentCaptor.forClass(Request.class);
verify(httpClient).newCall(argumentCaptor.capture());
@ -112,6 +117,9 @@ public class NetworkingModuleTest {
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<JavaOnlyArray> invalidHeaders = Arrays.asList(JavaOnlyArray.of("foo"));
@ -122,11 +130,12 @@ public class NetworkingModuleTest {
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 class NetworkingModuleTest {
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 class NetworkingModuleTest {
0,
JavaOnlyArray.of(),
body,
true,
0);
/* responseType */ "text",
/* useIncrementalUpdates*/ true,
/* timeout */ 0);
verifyErrorEmit(emitter, 0);
}
@ -196,6 +209,9 @@ public class NetworkingModuleTest {
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 class NetworkingModuleTest {
0,
JavaOnlyArray.of(JavaOnlyArray.of("Content-Type", "text/plain")),
body,
true,
0);
/* responseType */ "text",
/* useIncrementalUpdates*/ true,
/* timeout */ 0);
ArgumentCaptor<Request> argumentCaptor = ArgumentCaptor.forClass(Request.class);
verify(httpClient).newCall(argumentCaptor.capture());
@ -234,6 +251,9 @@ public class NetworkingModuleTest {
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 class NetworkingModuleTest {
0,
JavaOnlyArray.from(headers),
null,
true,
0);
/* responseType */ "text",
/* useIncrementalUpdates*/ true,
/* timeout */ 0);
ArgumentCaptor<Request> argumentCaptor = ArgumentCaptor.forClass(Request.class);
verify(httpClient).newCall(argumentCaptor.capture());
Headers requestHeaders = argumentCaptor.getValue().headers();
@ -265,7 +286,8 @@ public class NetworkingModuleTest {
.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 +310,9 @@ public class NetworkingModuleTest {
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 +322,9 @@ public class NetworkingModuleTest {
0,
new JavaOnlyArray(),
body,
true,
0);
/* responseType */ "text",
/* useIncrementalUpdates*/ true,
/* timeout */ 0);
// verify url, method, headers
ArgumentCaptor<Request> argumentCaptor = ArgumentCaptor.forClass(Request.class);
@ -320,7 +346,8 @@ public class NetworkingModuleTest {
.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<JavaOnlyArray> headers = Arrays.asList(
JavaOnlyArray.of("Accept", "text/plain"),
@ -348,6 +375,9 @@ public class NetworkingModuleTest {
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 +387,9 @@ public class NetworkingModuleTest {
0,
JavaOnlyArray.from(headers),
body,
true,
0);
/* responseType */ "text",
/* useIncrementalUpdates*/ true,
/* timeout */ 0);
// verify url, method, headers
ArgumentCaptor<Request> argumentCaptor = ArgumentCaptor.forClass(Request.class);
@ -383,7 +414,8 @@ public class NetworkingModuleTest {
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 +477,9 @@ public class NetworkingModuleTest {
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 +490,9 @@ public class NetworkingModuleTest {
0,
JavaOnlyArray.from(headers),
body,
true,
0);
/* responseType */ "text",
/* useIncrementalUpdates*/ true,
/* timeout */ 0);
// verify RequestBodyPart for image
PowerMockito.verifyStatic(times(1));
@ -503,6 +539,9 @@ public class NetworkingModuleTest {
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 +554,8 @@ public class NetworkingModuleTest {
idx + 1,
JavaOnlyArray.of(),
null,
true,
/* responseType */ "text",
/* useIncrementalUpdates*/ true,
0);
}
verify(httpClient, times(3)).newCall(any(Request.class));
@ -550,6 +590,9 @@ public class NetworkingModuleTest {
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 +604,8 @@ public class NetworkingModuleTest {
idx + 1,
JavaOnlyArray.of(),
null,
true,
/* responseType */ "text",
/* useIncrementalUpdates*/ true,
0);
}
verify(httpClient, times(3)).newCall(any(Request.class));