Send HEADERS_RECEIVED and LOADING events on Android

Summary: Send part of the response body every 100 ms if the client has set onreadystatechange. This
is done by using the same events as the iOS code and removing the callback that Android previously
used.

Reconsolidate iOS and Android implementations.

Closes #3772

public

Reviewed By: mkonicek

Differential Revision: D2647005

fb-gh-sync-id: d006e566867fa47d5f8dff71219cb390bcb8e15a
This commit is contained in:
Alexander Blom 2015-11-13 07:25:29 -08:00 committed by facebook-github-bot-3
parent a99fabdfea
commit 4d4c48d32b
7 changed files with 246 additions and 164 deletions

View File

@ -18,6 +18,7 @@
var React = require('react-native');
var {
PixelRatio,
ProgressBarAndroid,
StyleSheet,
Text,
TextInput,
@ -61,7 +62,6 @@ class Downloader extends React.Component {
this.setState({
downloaded: xhr.responseText.length,
});
console.log(xhr.responseText.length);
} else if (xhr.readyState === xhr.DONE) {
if (this.cancelled) {
this.cancelled = false;
@ -83,6 +83,8 @@ class Downloader extends React.Component {
}
};
xhr.open('GET', 'http://www.gutenberg.org/cache/epub/100/pg100.txt');
// Avoid gzip so we can actually show progress
xhr.setRequestHeader('Accept-Encoding', '');
xhr.send();
this.xhr = xhr;
@ -114,6 +116,8 @@ class Downloader extends React.Component {
return (
<View>
{button}
<ProgressBarAndroid progress={(this.state.downloaded / this.state.contentSize)}
styleAttr="Horizontal" indeterminate={false} />
<Text>{this.state.status}</Text>
</View>
);

View File

@ -25,7 +25,7 @@ var generateRequestId = function() {
*/
class RCTNetworking {
static sendRequest(method, url, headers, data, callback) {
static sendRequest(method, url, headers, data, useIncrementalUpdates) {
var requestId = generateRequestId();
RCTNetworkingNative.sendRequest(
method,
@ -33,7 +33,7 @@ class RCTNetworking {
requestId,
headers,
data,
callback);
useIncrementalUpdates);
return requestId;
}

View File

@ -0,0 +1,13 @@
/**
* Copyright (c) 2015-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*
* @providesModule RCTNetworking
*/
'use strict';
module.exports = require('NativeModules').Networking;

View File

@ -26,14 +26,6 @@ function convertHeadersMapToArray(headers: Object): Array<Header> {
}
class XMLHttpRequest extends XMLHttpRequestBase {
_requestId: ?number;
constructor() {
super();
this._requestId = null;
}
sendImpl(method: ?string, url: ?string, headers: Object, data: any): void {
var body;
if (typeof data === 'string') {
@ -49,17 +41,15 @@ class XMLHttpRequest extends XMLHttpRequestBase {
body = data;
}
this._requestId = RCTNetworking.sendRequest(
var useIncrementalUpdates = this.onreadystatechange ? true : false;
var requestId = RCTNetworking.sendRequest(
method,
url,
convertHeadersMapToArray(headers),
body,
this.callback.bind(this)
useIncrementalUpdates
);
}
abortImpl(): void {
this._requestId && RCTNetworking.abortRequest(this._requestId);
this.didCreateRequest(requestId);
}
}

View File

@ -12,95 +12,18 @@
'use strict';
var FormData = require('FormData');
var RCTNetworking = require('NativeModules').Networking;
var RCTNetworking = require('RCTNetworking');
var RCTDeviceEventEmitter = require('RCTDeviceEventEmitter');
var XMLHttpRequestBase = require('XMLHttpRequestBase');
class XMLHttpRequest extends XMLHttpRequestBase {
_requestId: ?number;
_subscriptions: [any];
upload: {
onprogress?: (event: Object) => void;
};
constructor() {
super();
this._requestId = null;
this._subscriptions = [];
// iOS supports upload
this.upload = {};
}
_didCreateRequest(requestId: number): void {
this._requestId = requestId;
this._subscriptions.push(RCTDeviceEventEmitter.addListener(
'didSendNetworkData',
(args) => this._didUploadProgress.call(this, args[0], args[1], args[2])
));
this._subscriptions.push(RCTDeviceEventEmitter.addListener(
'didReceiveNetworkResponse',
(args) => this._didReceiveResponse.call(this, args[0], args[1], args[2])
));
this._subscriptions.push(RCTDeviceEventEmitter.addListener(
'didReceiveNetworkData',
(args) => this._didReceiveData.call(this, args[0], args[1])
));
this._subscriptions.push(RCTDeviceEventEmitter.addListener(
'didCompleteNetworkResponse',
(args) => this._didCompleteResponse.call(this, args[0], args[1])
));
}
_didUploadProgress(requestId: number, progress: number, total: number): void {
if (requestId === this._requestId && this.upload.onprogress) {
var event = {
lengthComputable: true,
loaded: progress,
total,
};
this.upload.onprogress(event);
}
}
_didReceiveResponse(requestId: number, status: number, responseHeaders: ?Object): void {
if (requestId === this._requestId) {
this.status = status;
this.setResponseHeaders(responseHeaders);
this.setReadyState(this.HEADERS_RECEIVED);
}
}
_didReceiveData(requestId: number, responseText: string): void {
if (requestId === this._requestId) {
if (!this.responseText) {
this.responseText = responseText;
} else {
this.responseText += responseText;
}
this.setReadyState(this.LOADING);
}
}
_didCompleteResponse(requestId: number, error: string): void {
if (requestId === this._requestId) {
if (error) {
this.responseText = error;
}
this._clearSubscriptions();
this._requestId = null;
this.setReadyState(this.DONE);
}
}
_clearSubscriptions(): void {
for (var i = 0; i < this._subscriptions.length; i++) {
var sub = this._subscriptions[i];
sub.remove();
}
this._subscriptions = [];
}
sendImpl(method: ?string, url: ?string, headers: Object, data: any): void {
if (typeof data === 'string') {
data = {string: data};
@ -115,17 +38,9 @@ class XMLHttpRequest extends XMLHttpRequestBase {
headers,
incrementalUpdates: this.onreadystatechange ? true : false,
},
this._didCreateRequest.bind(this)
this.didCreateRequest.bind(this)
);
}
abortImpl(): void {
if (this._requestId) {
RCTNetworking.cancelRequest(this._requestId);
this._clearSubscriptions();
this._requestId = null;
}
}
}
module.exports = XMLHttpRequest;

View File

@ -11,6 +11,9 @@
*/
'use strict';
var RCTNetworking = require('RCTNetworking');
var RCTDeviceEventEmitter = require('RCTDeviceEventEmitter');
/**
* Shared base for platform-specific XMLHttpRequest implementations.
*/
@ -30,6 +33,13 @@ class XMLHttpRequestBase {
responseText: ?string;
status: number;
upload: ?{
onprogress?: (event: Object) => void;
};
_requestId: ?number;
_subscriptions: [any];
_method: ?string;
_url: ?string;
_headers: Object;
@ -60,9 +70,81 @@ class XMLHttpRequestBase {
this.responseText = '';
this.status = 0;
this._requestId = null;
this._headers = {};
this._sent = false;
this._lowerCaseResponseHeaders = {};
this._clearSubscriptions();
}
didCreateRequest(requestId: number): void {
this._requestId = requestId;
this._subscriptions.push(RCTDeviceEventEmitter.addListener(
'didSendNetworkData',
(args) => this._didUploadProgress.call(this, ...args)
));
this._subscriptions.push(RCTDeviceEventEmitter.addListener(
'didReceiveNetworkResponse',
(args) => this._didReceiveResponse.call(this, ...args)
));
this._subscriptions.push(RCTDeviceEventEmitter.addListener(
'didReceiveNetworkData',
(args) => this._didReceiveData.call(this, ...args)
));
this._subscriptions.push(RCTDeviceEventEmitter.addListener(
'didCompleteNetworkResponse',
(args) => this._didCompleteResponse.call(this, ...args)
));
}
_didUploadProgress(requestId: number, progress: number, total: number): void {
if (requestId === this._requestId && this.upload && this.upload.onprogress) {
var event = {
lengthComputable: true,
loaded: progress,
total,
};
this.upload.onprogress(event);
}
}
_didReceiveResponse(requestId: number, status: number, responseHeaders: ?Object): void {
if (requestId === this._requestId) {
this.status = status;
this.setResponseHeaders(responseHeaders);
this.setReadyState(this.HEADERS_RECEIVED);
}
}
_didReceiveData(requestId: number, responseText: string): void {
if (requestId === this._requestId) {
if (!this.responseText) {
this.responseText = responseText;
} else {
this.responseText += responseText;
}
this.setReadyState(this.LOADING);
}
}
_didCompleteResponse(requestId: number, error: string): void {
if (requestId === this._requestId) {
if (error) {
this.responseText = error;
}
this._clearSubscriptions();
this._requestId = null;
this.setReadyState(this.DONE);
}
}
_clearSubscriptions(): void {
(this._subscriptions || []).forEach(sub => {
sub.remove();
});
this._subscriptions = [];
}
getAllResponseHeaders(): ?string {
@ -108,10 +190,6 @@ class XMLHttpRequestBase {
throw new Error('Subclass must define sendImpl method');
}
abortImpl(): void {
throw new Error('Subclass must define abortImpl method');
}
send(data: any): void {
if (this.readyState !== this.OPENED) {
throw new Error('Request has not been opened');
@ -125,7 +203,10 @@ class XMLHttpRequestBase {
abort(): void {
this._aborted = true;
this.abortImpl();
if (this._requestId) {
console.log('calling abort', this._requestId);
RCTNetworking.abortRequest(this._requestId);
}
// only call onreadystatechange if there is something to abort,
// below logic is per spec
if (!(this.readyState === this.UNSENT ||
@ -138,16 +219,6 @@ class XMLHttpRequestBase {
this._reset();
}
callback(status: number, responseHeaders: ?Object, responseText: string): void {
if (this._aborted) {
return;
}
this.status = status;
this.setResponseHeaders(responseHeaders || {});
this.responseText = responseText;
this.setReadyState(this.DONE);
}
setResponseHeaders(responseHeaders: ?Object): void {
this.responseHeaders = responseHeaders || null;
var headers = responseHeaders || {};

View File

@ -13,17 +13,20 @@ import javax.annotation.Nullable;
import java.io.IOException;
import java.io.InputStream;
import java.io.Reader;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.Callback;
import com.facebook.react.bridge.GuardedAsyncTask;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.ReadableArray;
import com.facebook.react.bridge.ReadableMap;
import com.facebook.react.bridge.WritableArray;
import com.facebook.react.bridge.WritableMap;
import com.facebook.react.modules.core.DeviceEventManagerModule;
import com.squareup.okhttp.Callback;
import com.squareup.okhttp.Headers;
import com.squareup.okhttp.MediaType;
import com.squareup.okhttp.MultipartBuilder;
@ -31,6 +34,9 @@ import com.squareup.okhttp.OkHttpClient;
import com.squareup.okhttp.Request;
import com.squareup.okhttp.RequestBody;
import com.squareup.okhttp.Response;
import com.squareup.okhttp.ResponseBody;
import static java.lang.Math.min;
/**
* Implements the XMLHttpRequest JavaScript interface.
@ -44,6 +50,11 @@ public final class NetworkingModule extends ReactContextBaseJavaModule {
private static final String REQUEST_BODY_KEY_FORMDATA = "formData";
private static final String USER_AGENT_HEADER_NAME = "user-agent";
private static final int MIN_BUFFER_SIZE = 8 * 1024; // 8kb
private static final int MAX_BUFFER_SIZE = 512 * 1024; // 512kb
private static final int CHUNK_TIMEOUT_NS = 100 * 1000000; // 100ms
private final OkHttpClient mClient;
private final @Nullable String mDefaultUserAgent;
private boolean mShuttingDown;
@ -93,15 +104,10 @@ public final class NetworkingModule extends ReactContextBaseJavaModule {
public void sendRequest(
String method,
String url,
int requestId,
final int requestId,
ReadableArray headers,
ReadableMap data,
final Callback callback) {
// We need to call the callback to avoid leaking memory on JS even when input for sending
// request is erroneous or insufficient. For non-http based failures we use code 0, which is
// interpreted as a transport error.
// Callback accepts following arguments: responseCode, headersString, responseBody
final boolean useIncrementalUpdates) {
Request.Builder requestBuilder = new Request.Builder().url(url);
if (requestId != 0) {
@ -110,7 +116,7 @@ public final class NetworkingModule extends ReactContextBaseJavaModule {
Headers requestHeaders = extractHeaders(headers, data);
if (requestHeaders == null) {
callback.invoke(0, null, "Unrecognized headers format");
onRequestError(requestId, "Unrecognized headers format");
return;
}
String contentType = requestHeaders.get(CONTENT_TYPE_HEADER_NAME);
@ -121,7 +127,7 @@ public final class NetworkingModule extends ReactContextBaseJavaModule {
requestBuilder.method(method, null);
} else if (data.hasKey(REQUEST_BODY_KEY_STRING)) {
if (contentType == null) {
callback.invoke(0, null, "Payload is set but no content-type header specified");
onRequestError(requestId, "Payload is set but no content-type header specified");
return;
}
String body = data.getString(REQUEST_BODY_KEY_STRING);
@ -129,7 +135,7 @@ public final class NetworkingModule extends ReactContextBaseJavaModule {
if (RequestBodyUtil.isGzipEncoding(contentEncoding)) {
RequestBody requestBody = RequestBodyUtil.createGzip(contentMediaType, body);
if (requestBody == null) {
callback.invoke(0, null, "Failed to gzip request body");
onRequestError(requestId, "Failed to gzip request body");
return;
}
requestBuilder.method(method, requestBody);
@ -138,14 +144,14 @@ public final class NetworkingModule extends ReactContextBaseJavaModule {
}
} else if (data.hasKey(REQUEST_BODY_KEY_URI)) {
if (contentType == null) {
callback.invoke(0, null, "Payload is set but no content-type header specified");
onRequestError(requestId, "Payload is set but no content-type header specified");
return;
}
String uri = data.getString(REQUEST_BODY_KEY_URI);
InputStream fileInputStream =
RequestBodyUtil.getFileInputStream(getReactApplicationContext(), uri);
if (fileInputStream == null) {
callback.invoke(0, null, "Could not retrieve file for uri " + uri);
onRequestError(requestId, "Could not retrieve file for uri " + uri);
return;
}
requestBuilder.method(
@ -156,7 +162,7 @@ public final class NetworkingModule extends ReactContextBaseJavaModule {
contentType = "multipart/form-data";
}
ReadableArray parts = data.getArray(REQUEST_BODY_KEY_FORMDATA);
MultipartBuilder multipartBuilder = constructMultipartBody(parts, contentType, callback);
MultipartBuilder multipartBuilder = constructMultipartBody(parts, contentType, requestId);
if (multipartBuilder == null) {
return;
}
@ -168,16 +174,13 @@ public final class NetworkingModule extends ReactContextBaseJavaModule {
}
mClient.newCall(requestBuilder.build()).enqueue(
new com.squareup.okhttp.Callback() {
new Callback() {
@Override
public void onFailure(Request request, IOException e) {
if (mShuttingDown) {
return;
}
// We need to call the callback to avoid leaking memory on JS even when input for
// sending request is erronous or insufficient. For non-http based failures we use
// code 0, which is interpreted as a transport error
callback.invoke(0, null, e.getMessage());
onRequestError(requestId, e.getMessage());
}
@Override
@ -185,34 +188,115 @@ public final class NetworkingModule extends ReactContextBaseJavaModule {
if (mShuttingDown) {
return;
}
String responseBody;
// Before we touch the body send headers to JS
onResponseReceived(requestId, response);
ResponseBody responseBody = response.body();
try {
responseBody = response.body().string();
} catch (IOException e) {
// The stream has been cancelled or closed, nothing we can do
callback.invoke(0, null, e.getMessage());
return;
}
WritableMap responseHeaders = Arguments.createMap();
Headers headers = response.headers();
for (int i = 0; i < headers.size(); i++) {
String headerName = headers.name(i);
// multiple values for the same header
if (responseHeaders.hasKey(headerName)) {
responseHeaders.putString(
headerName,
responseHeaders.getString(headerName) + ", " + headers.value(i));
if (useIncrementalUpdates) {
readWithProgress(requestId, responseBody);
onRequestSuccess(requestId);
} else {
responseHeaders.putString(headerName, headers.value(i));
onDataReceived(requestId, responseBody.string());
onRequestSuccess(requestId);
}
} catch (IOException e) {
onRequestError(requestId, e.getMessage());
}
callback.invoke(response.code(), responseHeaders, responseBody);
}
});
}
private void readWithProgress(int requestId, ResponseBody responseBody) throws IOException {
Reader reader = responseBody.charStream();
try {
StringBuilder sb = new StringBuilder(getBufferSize(responseBody));
char[] buffer = new char[MIN_BUFFER_SIZE];
int read;
long last = System.nanoTime();
while ((read = reader.read(buffer)) != -1) {
sb.append(buffer, 0, read);
long now = System.nanoTime();
if (shouldDispatch(now, last)) {
onDataReceived(requestId, sb.toString());
sb.setLength(0);
last = now;
}
}
if (sb.length() > 0) {
onDataReceived(requestId, sb.toString());
}
} finally {
reader.close();
}
}
private static boolean shouldDispatch(long now, long last) {
return last + CHUNK_TIMEOUT_NS < now;
}
private static int getBufferSize(ResponseBody responseBody) throws IOException {
long length = responseBody.contentLength();
if (length == -1) {
return MIN_BUFFER_SIZE;
} else {
return (int) min(length, MAX_BUFFER_SIZE);
}
}
private void onDataReceived(int requestId, String data) {
WritableArray args = Arguments.createArray();
args.pushInt(requestId);
args.pushString(data);
getEventEmitter().emit("didReceiveNetworkData", args);
}
private void onRequestError(int requestId, String error) {
WritableArray args = Arguments.createArray();
args.pushInt(requestId);
args.pushString(error);
getEventEmitter().emit("didCompleteNetworkResponse", args);
}
private void onRequestSuccess(int requestId) {
WritableArray args = Arguments.createArray();
args.pushInt(requestId);
args.pushNull();
getEventEmitter().emit("didCompleteNetworkResponse", args);
}
private void onResponseReceived(int requestId, Response response) {
WritableMap headers = translateHeaders(response.headers());
WritableArray args = Arguments.createArray();
args.pushInt(requestId);
args.pushInt(response.code());
args.pushMap(headers);
getEventEmitter().emit("didReceiveNetworkResponse", args);
}
private static WritableMap translateHeaders(Headers headers) {
WritableMap responseHeaders = Arguments.createMap();
for (int i = 0; i < headers.size(); i++) {
String headerName = headers.name(i);
// multiple values for the same header
if (responseHeaders.hasKey(headerName)) {
responseHeaders.putString(
headerName,
responseHeaders.getString(headerName) + ", " + headers.value(i));
} else {
responseHeaders.putString(headerName, headers.value(i));
}
}
return responseHeaders;
}
@ReactMethod
public void abortRequest(final int requestId) {
// We have to use AsyncTask since this might trigger a NetworkOnMainThreadException, this is an
@ -228,7 +312,7 @@ public final class NetworkingModule extends ReactContextBaseJavaModule {
private @Nullable MultipartBuilder constructMultipartBody(
ReadableArray body,
String contentType,
Callback callback) {
int requestId) {
MultipartBuilder multipartBuilder = new MultipartBuilder();
multipartBuilder.type(MediaType.parse(contentType));
@ -239,7 +323,7 @@ public final class NetworkingModule extends ReactContextBaseJavaModule {
ReadableArray headersArray = bodyPart.getArray("headers");
Headers headers = extractHeaders(headersArray, null);
if (headers == null) {
callback.invoke(0, null, "Missing or invalid header format for FormData part.");
onRequestError(requestId, "Missing or invalid header format for FormData part.");
return null;
}
MediaType partContentType = null;
@ -256,19 +340,19 @@ public final class NetworkingModule extends ReactContextBaseJavaModule {
multipartBuilder.addPart(headers, RequestBody.create(partContentType, bodyValue));
} else if (bodyPart.hasKey(REQUEST_BODY_KEY_URI)) {
if (partContentType == null) {
callback.invoke(0, null, "Binary FormData part needs a content-type header.");
onRequestError(requestId, "Binary FormData part needs a content-type header.");
return null;
}
String fileContentUriStr = bodyPart.getString(REQUEST_BODY_KEY_URI);
InputStream fileInputStream =
RequestBodyUtil.getFileInputStream(getReactApplicationContext(), fileContentUriStr);
if (fileInputStream == null) {
callback.invoke(0, null, "Could not retrieve file for uri " + fileContentUriStr);
onRequestError(requestId, "Could not retrieve file for uri " + fileContentUriStr);
return null;
}
multipartBuilder.addPart(headers, RequestBodyUtil.create(partContentType, fileInputStream));
} else {
callback.invoke(0, null, "Unrecognized FormData part.");
onRequestError(requestId, "Unrecognized FormData part.");
}
}
return multipartBuilder;
@ -305,4 +389,9 @@ public final class NetworkingModule extends ReactContextBaseJavaModule {
return headersBuilder.build();
}
private DeviceEventManagerModule.RCTDeviceEventEmitter getEventEmitter() {
return getReactApplicationContext()
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class);
}
}