Extract bundle download logic from DevServerHelper to BundleDownloader

Reviewed By: cwdick

Differential Revision: D4729058

fbshipit-source-id: 6ffbe4dbd916ac7ac6afffba2cf070aab9cb87b9
This commit is contained in:
Lukas Piatkowski 2017-05-10 03:46:47 -07:00 committed by Facebook Github Bot
parent 9468c5a733
commit 621148d724
3 changed files with 196 additions and 170 deletions

View File

@ -0,0 +1,187 @@
/**
* 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.
*/
package com.facebook.react.devsupport;
import javax.annotation.Nullable;
import java.io.File;
import java.io.IOException;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.facebook.common.logging.FLog;
import com.facebook.infer.annotation.Assertions;
import com.facebook.react.common.ReactConstants;
import org.json.JSONException;
import org.json.JSONObject;
import okhttp3.Call;
import okhttp3.Callback;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okio.Buffer;
import okio.BufferedSource;
import okio.Okio;
import okio.Sink;
public class BundleDownloader {
public interface DownloadCallback {
void onSuccess();
void onProgress(@Nullable String status, @Nullable Integer done, @Nullable Integer total);
void onFailure(Exception cause);
}
private final OkHttpClient mClient;
private @Nullable Call mDownloadBundleFromURLCall;
public BundleDownloader(OkHttpClient client) {
mClient = client;
}
public void downloadBundleFromURL(
final DownloadCallback callback,
final File outputFile,
final String bundleURL) {
final Request request = new Request.Builder()
.url(bundleURL)
.addHeader("Accept", "multipart/mixed")
.build();
mDownloadBundleFromURLCall = Assertions.assertNotNull(mClient.newCall(request));
mDownloadBundleFromURLCall.enqueue(new Callback() {
@Override
public void onFailure(Call call, IOException e) {
// ignore callback if call was cancelled
if (mDownloadBundleFromURLCall == null || mDownloadBundleFromURLCall.isCanceled()) {
mDownloadBundleFromURLCall = null;
return;
}
mDownloadBundleFromURLCall = null;
callback.onFailure(DebugServerException.makeGeneric(
"Could not connect to development server.",
"URL: " + call.request().url().toString(),
e));
}
@Override
public void onResponse(Call call, final Response response) throws IOException {
// ignore callback if call was cancelled
if (mDownloadBundleFromURLCall == null || mDownloadBundleFromURLCall.isCanceled()) {
mDownloadBundleFromURLCall = null;
return;
}
mDownloadBundleFromURLCall = null;
final String url = response.request().url().toString();
// Make sure the result is a multipart response and parse the boundary.
String contentType = response.header("content-type");
Pattern regex = Pattern.compile("multipart/mixed;.*boundary=\"([^\"]+)\"");
Matcher match = regex.matcher(contentType);
if (match.find()) {
String boundary = match.group(1);
MultipartStreamReader bodyReader = new MultipartStreamReader(response.body().source(), boundary);
boolean completed = bodyReader.readAllParts(new MultipartStreamReader.ChunkCallback() {
@Override
public void execute(Map<String, String> headers, Buffer body, boolean finished) throws IOException {
// This will get executed for every chunk of the multipart response. The last chunk
// (finished = true) will be the JS bundle, the other ones will be progress events
// encoded as JSON.
if (finished) {
// The http status code for each separate chunk is in the X-Http-Status header.
int status = response.code();
if (headers.containsKey("X-Http-Status")) {
status = Integer.parseInt(headers.get("X-Http-Status"));
}
processBundleResult(url, status, body, outputFile, callback);
} else {
if (!headers.containsKey("Content-Type") || !headers.get("Content-Type").equals("application/json")) {
return;
}
try {
JSONObject progress = new JSONObject(body.readUtf8());
String status = null;
if (progress.has("status")) {
status = progress.getString("status");
}
Integer done = null;
if (progress.has("done")) {
done = progress.getInt("done");
}
Integer total = null;
if (progress.has("total")) {
total = progress.getInt("total");
}
callback.onProgress(status, done, total);
} catch (JSONException e) {
FLog.e(ReactConstants.TAG, "Error parsing progress JSON. " + e.toString());
}
}
}
});
if (!completed) {
callback.onFailure(new DebugServerException(
"Error while reading multipart response.\n\nResponse code: " + response.code() + "\n\n" +
"URL: " + call.request().url().toString() + "\n\n"));
}
} else {
// In case the server doesn't support multipart/mixed responses, fallback to normal download.
processBundleResult(url, response.code(), Okio.buffer(response.body().source()), outputFile, callback);
}
}
});
}
public void cancelDownloadBundleFromURL() {
if (mDownloadBundleFromURLCall != null) {
mDownloadBundleFromURLCall.cancel();
mDownloadBundleFromURLCall = null;
}
}
private void processBundleResult(
String url,
int statusCode,
BufferedSource body,
File outputFile,
DownloadCallback callback) throws IOException {
// Check for server errors. If the server error has the expected form, fail with more info.
if (statusCode != 200) {
String bodyString = body.readUtf8();
DebugServerException debugServerException = DebugServerException.parse(bodyString);
if (debugServerException != null) {
callback.onFailure(debugServerException);
} else {
StringBuilder sb = new StringBuilder();
sb.append("The development server returned response error code: ").append(statusCode).append("\n\n")
.append("URL: ").append(url).append("\n\n")
.append("Body:\n")
.append(bodyString);
callback.onFailure(new DebugServerException(sb.toString()));
}
return;
}
Sink output = null;
try {
output = Okio.sink(outputFile);
body.readAll(output);
callback.onSuccess();
} finally {
if (output != null) {
output.close();
}
}
}
}

View File

@ -20,8 +20,6 @@ import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import android.content.Context;
import android.os.AsyncTask;
@ -56,8 +54,6 @@ import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import okhttp3.ResponseBody;
import okio.Buffer;
import okio.BufferedSource;
import okio.Okio;
import okio.Sink;
@ -96,12 +92,6 @@ public class DevServerHelper {
private static final int LONG_POLL_FAILURE_DELAY_MS = 5000;
private static final int HTTP_CONNECT_TIMEOUT_MS = 5000;
public interface BundleDownloadCallback {
void onSuccess();
void onProgress(@Nullable String status, @Nullable Integer done, @Nullable Integer total);
void onFailure(Exception cause);
}
public interface OnServerContentChangeListener {
void onServerContentChanged();
}
@ -119,13 +109,13 @@ public class DevServerHelper {
private final DevInternalSettings mSettings;
private final OkHttpClient mClient;
private final Handler mRestartOnChangePollingHandler;
private final BundleDownloader mBundleDownloader;
private boolean mOnChangePollingEnabled;
private @Nullable JSPackagerClient mPackagerClient;
private @Nullable InspectorPackagerConnection mInspectorPackagerConnection;
private @Nullable OkHttpClient mOnChangePollingClient;
private @Nullable OnServerContentChangeListener mOnServerContentChangeListener;
private @Nullable Call mDownloadBundleFromURLCall;
public DevServerHelper(DevInternalSettings settings) {
mSettings = settings;
@ -134,6 +124,7 @@ public class DevServerHelper {
.readTimeout(0, TimeUnit.MILLISECONDS)
.writeTimeout(0, TimeUnit.MILLISECONDS)
.build();
mBundleDownloader = new BundleDownloader(mClient);
mRestartOnChangePollingHandler = new Handler();
}
@ -146,8 +137,7 @@ public class DevServerHelper {
new AsyncTask<Void, Void, Void>() {
@Override
protected Void doInBackground(Void... backgroundParams) {
Map<String, RequestHandler> handlers =
new HashMap<String, RequestHandler>();
Map<String, RequestHandler> handlers = new HashMap<>();
handlers.put("reload", new NotificationOnlyHandler() {
@Override
public void onNotification(@Nullable Object params) {
@ -322,6 +312,10 @@ public class DevServerHelper {
AndroidInfoHelpers.getFriendlyDeviceName());
}
public BundleDownloader getBundleDownloader() {
return mBundleDownloader;
}
/**
* @return the host to use when connecting to the bundle server from the host itself.
*/
@ -380,161 +374,6 @@ public class DevServerHelper {
getJSMinifyMode());
}
public void downloadBundleFromURL(
final BundleDownloadCallback callback,
final File outputFile,
final String bundleURL) {
final Request request = new Request.Builder()
.url(bundleURL)
.addHeader("Accept", "multipart/mixed")
.build();
mDownloadBundleFromURLCall = Assertions.assertNotNull(mClient.newCall(request));
mDownloadBundleFromURLCall.enqueue(new Callback() {
@Override
public void onFailure(Call call, IOException e) {
// ignore callback if call was cancelled
if (mDownloadBundleFromURLCall == null || mDownloadBundleFromURLCall.isCanceled()) {
mDownloadBundleFromURLCall = null;
return;
}
mDownloadBundleFromURLCall = null;
callback.onFailure(DebugServerException.makeGeneric(
"Could not connect to development server.",
"URL: " + call.request().url().toString(),
e));
}
@Override
public void onResponse(Call call, final Response response) throws IOException {
// ignore callback if call was cancelled
if (mDownloadBundleFromURLCall == null || mDownloadBundleFromURLCall.isCanceled()) {
mDownloadBundleFromURLCall = null;
return;
}
mDownloadBundleFromURLCall = null;
final String url = response.request().url().toString();
// Make sure the result is a multipart response and parse the boundary.
String contentType = response.header("content-type");
Pattern regex = Pattern.compile("multipart/mixed;.*boundary=\"([^\"]+)\"");
Matcher match = regex.matcher(contentType);
if (match.find()) {
String boundary = match.group(1);
MultipartStreamReader bodyReader = new MultipartStreamReader(
response.body().source(),
boundary);
boolean completed = bodyReader.readAllParts(new MultipartStreamReader.ChunkCallback() {
@Override
public void execute(
Map<String, String> headers,
Buffer body,
boolean finished) throws IOException {
// This will get executed for every chunk of the multipart response. The last chunk
// (finished = true) will be the JS bundle, the other ones will be progress events
// encoded as JSON.
if (finished) {
// The http status code for each separate chunk is in the X-Http-Status header.
int status = response.code();
if (headers.containsKey("X-Http-Status")) {
status = Integer.parseInt(headers.get("X-Http-Status"));
}
processBundleResult(url, status, body, outputFile, callback);
} else {
if (!headers.containsKey("Content-Type") ||
!headers.get("Content-Type").equals("application/json")) {
return;
}
try {
JSONObject progress = new JSONObject(body.readUtf8());
String status = null;
if (progress.has("status")) {
status = progress.getString("status");
}
Integer done = null;
if (progress.has("done")) {
done = progress.getInt("done");
}
Integer total = null;
if (progress.has("total")) {
total = progress.getInt("total");
}
callback.onProgress(status, done, total);
} catch (JSONException e) {
FLog.e(ReactConstants.TAG, "Error parsing progress JSON. " + e.toString());
}
}
}
});
if (!completed) {
callback.onFailure(new DebugServerException(
"Error while reading multipart response.\n\nResponse code: " +
response.code() + "\n\n" + "URL: " + call.request().url().toString() +
"\n\n"));
}
} else {
/**
* In case the server doesn't support multipart/mixed responses,
* fallback to normal download.
*/
processBundleResult(
url,
response.code(),
Okio.buffer(response.body().source()),
outputFile,
callback);
}
}
});
}
private void processBundleResult(
String url,
int statusCode,
BufferedSource body,
File outputFile,
BundleDownloadCallback callback) throws IOException {
// Check for server errors. If the server error has the expected form, fail with more info.
if (statusCode != 200) {
String bodyString = body.readUtf8();
DebugServerException debugServerException = DebugServerException.parse(bodyString);
if (debugServerException != null) {
callback.onFailure(debugServerException);
} else {
StringBuilder sb = new StringBuilder();
sb.append("The development server returned response error code: ")
.append(statusCode)
.append("\n\n")
.append("URL: ")
.append(url)
.append("\n\n")
.append("Body:\n")
.append(bodyString);
callback.onFailure(new DebugServerException(sb.toString()));
}
return;
}
Sink output = null;
try {
output = Okio.sink(outputFile);
body.readAll(output);
callback.onSuccess();
} finally {
if (output != null) {
output.close();
}
}
}
public void cancelDownloadBundleFromURL() {
if (mDownloadBundleFromURLCall != null) {
mDownloadBundleFromURLCall.cancel();
mDownloadBundleFromURLCall = null;
}
}
public void isPackagerRunning(final PackagerStatusCallback callback) {
String statusURL = createPackagerStatusURL(
mSettings.getPackagerConnectionSettings().getDebugServerHost());

View File

@ -809,8 +809,8 @@ public class DevSupportManagerImpl implements
mDevLoadingViewController.showForUrl(bundleURL);
mDevLoadingViewVisible = true;
mDevServerHelper.downloadBundleFromURL(
new DevServerHelper.BundleDownloadCallback() {
mDevServerHelper.getBundleDownloader().downloadBundleFromURL(
new BundleDownloader.DownloadCallback() {
@Override
public void onSuccess() {
mDevLoadingViewController.hide();