diff --git a/ReactAndroid/src/main/java/com/facebook/react/devsupport/BundleDownloader.java b/ReactAndroid/src/main/java/com/facebook/react/devsupport/BundleDownloader.java new file mode 100644 index 000000000..85dc5c236 --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/devsupport/BundleDownloader.java @@ -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 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(); + } + } + } +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevServerHelper.java b/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevServerHelper.java index 9be5277da..e9889ebd4 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevServerHelper.java +++ b/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevServerHelper.java @@ -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() { @Override protected Void doInBackground(Void... backgroundParams) { - Map handlers = - new HashMap(); + Map 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 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()); diff --git a/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevSupportManagerImpl.java b/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevSupportManagerImpl.java index b65cc8cd8..84c799e9f 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevSupportManagerImpl.java +++ b/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevSupportManagerImpl.java @@ -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();