From 231bf7c68b0b925bb209d6b92af88baed38085b4 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Thu, 9 Mar 2017 10:30:51 -0800 Subject: [PATCH] Show bundle loading progress on Android Summary: This implements a loading banner like on iOS that shows the progress of the packager. ![](https://media.giphy.com/media/l4FGoepExkpOeXtTO/giphy.gif) **Test plan** - Tested that it displays similar messages as it does on iOS and also that is show the right message when waiting for the remote debugger. - Tested that errors are still shown properly. - Tested that it works with packagers that don't support multipart response (add && false in https://github.com/facebook/react-native/blob/master/packager/src/Server/MultipartResponse.js#L81). - Run new unit tests. - Tested that backgrounding / foregrounding the app hides / show the banner properly. Closes https://github.com/facebook/react-native/pull/12674 Differential Revision: D4673638 Pulled By: mkonicek fbshipit-source-id: b2a1163de3d0792cf481d7111231a065f80a9594 --- .../devsupport/DevLoadingViewController.java | 151 ++++++++++++++++++ .../react/devsupport/DevServerHelper.java | 123 +++++++++++--- .../devsupport/DevSupportManagerImpl.java | 86 +++++----- .../devsupport/MultipartStreamReader.java | 128 +++++++++++++++ .../devsupport/layout/dev_loading_view.xml | 14 ++ .../main/res/devsupport/values/strings.xml | 3 +- .../java/com/facebook/react/devsupport/BUCK | 1 + .../devsupport/MultipartStreamReaderTest.java | 143 +++++++++++++++++ 8 files changed, 580 insertions(+), 69 deletions(-) create mode 100644 ReactAndroid/src/main/java/com/facebook/react/devsupport/DevLoadingViewController.java create mode 100644 ReactAndroid/src/main/java/com/facebook/react/devsupport/MultipartStreamReader.java create mode 100644 ReactAndroid/src/main/res/devsupport/layout/dev_loading_view.xml create mode 100644 ReactAndroid/src/test/java/com/facebook/react/devsupport/MultipartStreamReaderTest.java diff --git a/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevLoadingViewController.java b/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevLoadingViewController.java new file mode 100644 index 000000000..2aef1b589 --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevLoadingViewController.java @@ -0,0 +1,151 @@ +/** + * 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 android.content.Context; +import android.graphics.Color; +import android.graphics.PixelFormat; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.WindowManager; +import android.widget.TextView; + +import com.facebook.common.logging.FLog; +import com.facebook.react.R; +import com.facebook.react.bridge.UiThreadUtil; +import com.facebook.react.common.ReactConstants; + +import java.net.MalformedURLException; +import java.net.URL; +import java.util.Locale; + +import javax.annotation.Nullable; + +/** + * Controller to display loading messages on top of the screen. All methods are thread safe. + */ +public class DevLoadingViewController { + private static final int COLOR_DARK_GREEN = Color.parseColor("#035900"); + + private static boolean sEnabled = true; + private final Context mContext; + private final WindowManager mWindowManager; + private TextView mDevLoadingView; + private boolean mIsVisible = false; + + public static void setDevLoadingEnabled(boolean enabled) { + sEnabled = enabled; + } + + public DevLoadingViewController(Context context) { + mContext = context; + mWindowManager = (WindowManager) mContext.getSystemService(Context.WINDOW_SERVICE); + LayoutInflater inflater = (LayoutInflater) mContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE); + mDevLoadingView = (TextView) inflater.inflate(R.layout.dev_loading_view, null); + } + + public void showMessage(final String message, final int color, final int backgroundColor) { + if (!sEnabled) { + return; + } + + UiThreadUtil.runOnUiThread(new Runnable() { + @Override + public void run() { + mDevLoadingView.setBackgroundColor(backgroundColor); + mDevLoadingView.setText(message); + mDevLoadingView.setTextColor(color); + + setVisible(true); + } + }); + } + + public void showForUrl(String url) { + URL parsedURL; + try { + parsedURL = new URL(url); + } catch (MalformedURLException e) { + FLog.e(ReactConstants.TAG, "Bundle url format is invalid. \n\n" + e.toString()); + return; + } + + showMessage( + mContext.getString(R.string.catalyst_loading_from_url, parsedURL.getHost() + ":" + parsedURL.getPort()), + Color.WHITE, + COLOR_DARK_GREEN); + } + + public void showForRemoteJSEnabled() { + showMessage(mContext.getString(R.string.catalyst_remotedbg_message), Color.WHITE, COLOR_DARK_GREEN); + } + + public void updateProgress(final @Nullable String status, final @Nullable Integer done, final @Nullable Integer total) { + if (!sEnabled) { + return; + } + + UiThreadUtil.runOnUiThread(new Runnable() { + @Override + public void run() { + StringBuilder message = new StringBuilder(); + message.append(status != null ? status : "Loading"); + if (done != null && total != null && total > 0) { + message.append(String.format(Locale.getDefault(), " %.1f%% (%d/%d)", (float) done / total * 100, done, total)); + } + message.append("\u2026"); // `...` character + + mDevLoadingView.setText(message); + } + }); + } + + public void show() { + if (!sEnabled) { + return; + } + + UiThreadUtil.runOnUiThread(new Runnable() { + @Override + public void run() { + setVisible(true); + } + }); + } + + public void hide() { + if (!sEnabled) { + return; + } + + UiThreadUtil.runOnUiThread(new Runnable() { + @Override + public void run() { + setVisible(false); + } + }); + } + + private void setVisible(boolean visible) { + if (visible && !mIsVisible) { + WindowManager.LayoutParams params = new WindowManager.LayoutParams( + WindowManager.LayoutParams.MATCH_PARENT, + WindowManager.LayoutParams.WRAP_CONTENT, + WindowManager.LayoutParams.TYPE_SYSTEM_OVERLAY, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, + PixelFormat.TRANSLUCENT); + params.gravity = Gravity.TOP; + mWindowManager.addView(mDevLoadingView, params); + } else if (!visible && mIsVisible) { + mWindowManager.removeView(mDevLoadingView); + } + mIsVisible = visible; + } +} 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 3f2b33943..5cebb1bc5 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevServerHelper.java +++ b/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevServerHelper.java @@ -17,6 +17,8 @@ import java.util.HashMap; 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; @@ -32,6 +34,9 @@ import com.facebook.react.devsupport.interfaces.PackagerStatusCallback; import com.facebook.react.modules.systeminfo.AndroidInfoHelpers; import com.facebook.react.packagerconnection.JSPackagerClient; +import org.json.JSONException; +import org.json.JSONObject; + import okhttp3.Call; import okhttp3.Callback; import okhttp3.ConnectionPool; @@ -39,6 +44,8 @@ import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.Response; import okhttp3.ResponseBody; +import okio.Buffer; +import okio.BufferedSource; import okio.Okio; import okio.Sink; @@ -78,6 +85,7 @@ public class DevServerHelper { public interface BundleDownloadCallback { void onSuccess(); + void onProgress(@Nullable String status, @Nullable Integer done, @Nullable Integer total); void onFailure(Exception cause); } @@ -297,6 +305,7 @@ public class DevServerHelper { 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() { @@ -316,7 +325,7 @@ public class DevServerHelper { } @Override - public void onResponse(Call call, Response response) throws IOException { + public void onResponse(Call call, final Response response) throws IOException { // ignore callback if call was cancelled if (mDownloadBundleFromURLCall == null || mDownloadBundleFromURLCall.isCanceled()) { mDownloadBundleFromURLCall = null; @@ -324,37 +333,101 @@ public class DevServerHelper { } mDownloadBundleFromURLCall = null; - // Check for server errors. If the server error has the expected form, fail with more info. - if (!response.isSuccessful()) { - String body = response.body().string(); - DebugServerException debugServerException = DebugServerException.parse(body); - if (debugServerException != null) { - callback.onFailure(debugServerException); - } else { - StringBuilder sb = new StringBuilder(); - sb.append("The development server returned response error code: ").append(response.code()).append("\n\n") - .append("URL: ").append(call.request().url().toString()).append("\n\n") - .append("Body:\n") - .append(body); - callback.onFailure(new DebugServerException(sb.toString())); - } - return; - } + final String url = response.request().url().toString(); - Sink output = null; - try { - output = Okio.sink(outputFile); - Okio.buffer(response.body().source()).readAll(output); - callback.onSuccess(); - } finally { - if (output != null) { - output.close(); + // 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(); 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 aa731bcb9..28ab65214 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevSupportManagerImpl.java +++ b/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevSupportManagerImpl.java @@ -9,19 +9,6 @@ package com.facebook.react.devsupport; -import javax.annotation.Nullable; - -import java.io.File; -import java.io.IOException; -import java.net.MalformedURLException; -import java.net.URL; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Locale; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; - import android.app.ActivityManager; import android.app.AlertDialog; import android.content.BroadcastReceiver; @@ -58,6 +45,19 @@ import com.facebook.react.devsupport.interfaces.StackFrame; import com.facebook.react.modules.debug.interfaces.DeveloperSettings; import com.facebook.react.packagerconnection.JSPackagerClient; +import java.io.File; +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import javax.annotation.Nullable; + import okhttp3.MediaType; import okhttp3.OkHttpClient; import okhttp3.Request; @@ -115,10 +115,12 @@ public class DevSupportManagerImpl implements private final @Nullable String mJSAppBundleName; private final File mJSBundleTempFile; private final DefaultNativeModuleCallExceptionHandler mDefaultNativeModuleCallExceptionHandler; + private final DevLoadingViewController mDevLoadingViewController; private @Nullable RedBoxDialog mRedBoxDialog; private @Nullable AlertDialog mDevOptionsDialog; private @Nullable DebugOverlayController mDebugOverlayController; + private boolean mDevLoadingViewVisible = false; private @Nullable ReactContext mCurrentContext; private DevInternalSettings mDevSettings; private boolean mIsReceiverRegistered = false; @@ -226,6 +228,7 @@ public class DevSupportManagerImpl implements setDevSupportEnabled(enableOnCreate); mRedBoxHandler = redBoxHandler; + mDevLoadingViewController = new DevLoadingViewController(applicationContext); } @Override @@ -641,7 +644,9 @@ public class DevSupportManagerImpl implements } if (mDevSettings.isRemoteJSDebugEnabled()) { - reloadJSInProxyMode(showProgressDialog()); + mDevLoadingViewController.showForRemoteJSEnabled(); + mDevLoadingViewVisible = true; + reloadJSInProxyMode(); } else { String bundleURL = mDevServerHelper.getDevServerBundleURL(Assertions.assertNotNull(mJSAppBundleName)); @@ -748,7 +753,7 @@ public class DevSupportManagerImpl implements mLastErrorType = errorType; } - private void reloadJSInProxyMode(final AlertDialog progressDialog) { + private void reloadJSInProxyMode() { // When using js proxy, there is no need to fetch JS bundle as proxy executor will do that // anyway mDevServerHelper.launchJSDevtools(); @@ -760,7 +765,7 @@ public class DevSupportManagerImpl implements SimpleSettableFuture future = new SimpleSettableFuture<>(); executor.connect( mDevServerHelper.getWebsocketProxyURL(), - getExecutorConnectCallback(progressDialog, future)); + getExecutorConnectCallback(future)); // TODO(t9349129) Don't use timeout try { future.get(90, TimeUnit.SECONDS); @@ -776,18 +781,19 @@ public class DevSupportManagerImpl implements } private WebsocketJavaScriptExecutor.JSExecutorConnectCallback getExecutorConnectCallback( - final AlertDialog progressDialog, final SimpleSettableFuture future) { return new WebsocketJavaScriptExecutor.JSExecutorConnectCallback() { @Override public void onSuccess() { future.set(true); - progressDialog.dismiss(); + mDevLoadingViewController.hide(); + mDevLoadingViewVisible = false; } @Override public void onFailure(final Throwable cause) { - progressDialog.dismiss(); + mDevLoadingViewController.hide(); + mDevLoadingViewVisible = false; FLog.e(ReactConstants.TAG, "Unable to connect to remote debugger", cause); future.setException( new IOException( @@ -796,27 +802,16 @@ public class DevSupportManagerImpl implements }; } - private AlertDialog showProgressDialog() { - AlertDialog dialog = new AlertDialog.Builder(mApplicationContext) - .setTitle(R.string.catalyst_jsload_title) - .setMessage(mApplicationContext.getString( - mDevSettings.isRemoteJSDebugEnabled() ? - R.string.catalyst_remotedbg_message : - R.string.catalyst_jsload_message)) - .create(); - dialog.getWindow().setType(WindowManager.LayoutParams.TYPE_SYSTEM_ALERT); - dialog.show(); - return dialog; - } - public void reloadJSFromServer(final String bundleURL) { - final AlertDialog progressDialog = showProgressDialog(); + mDevLoadingViewController.showForUrl(bundleURL); + mDevLoadingViewVisible = true; mDevServerHelper.downloadBundleFromURL( new DevServerHelper.BundleDownloadCallback() { @Override public void onSuccess() { - progressDialog.dismiss(); + mDevLoadingViewController.hide(); + mDevLoadingViewVisible = false; UiThreadUtil.runOnUiThread( new Runnable() { @Override @@ -826,9 +821,15 @@ public class DevSupportManagerImpl implements }); } + @Override + public void onProgress(@Nullable final String status, @Nullable final Integer done, @Nullable final Integer total) { + mDevLoadingViewController.updateProgress(status, done, total); + } + @Override public void onFailure(final Exception cause) { - progressDialog.dismiss(); + mDevLoadingViewController.hide(); + mDevLoadingViewVisible = false; FLog.e(ReactConstants.TAG, "Unable to download JS bundle", cause); UiThreadUtil.runOnUiThread( new Runnable() { @@ -848,13 +849,6 @@ public class DevSupportManagerImpl implements }, mJSBundleTempFile, bundleURL); - progressDialog.setOnCancelListener(new DialogInterface.OnCancelListener() { - @Override - public void onCancel(DialogInterface dialog) { - mDevServerHelper.cancelDownloadBundleFromURL(); - } - }); - progressDialog.setCancelable(true); } private void reload() { @@ -880,6 +874,11 @@ public class DevSupportManagerImpl implements mIsReceiverRegistered = true; } + // show the dev loading if it should be + if (mDevLoadingViewVisible) { + mDevLoadingViewController.show(); + } + mDevServerHelper.openPackagerConnection(this); mDevServerHelper.openInspectorConnection(); if (mDevSettings.isReloadOnJSChangeEnabled()) { @@ -921,6 +920,9 @@ public class DevSupportManagerImpl implements mDevOptionsDialog.dismiss(); } + // hide loading view + mDevLoadingViewController.hide(); + mDevServerHelper.closePackagerConnection(); mDevServerHelper.closeInspectorConnection(); mDevServerHelper.stopPollingOnChangeEndpoint(); diff --git a/ReactAndroid/src/main/java/com/facebook/react/devsupport/MultipartStreamReader.java b/ReactAndroid/src/main/java/com/facebook/react/devsupport/MultipartStreamReader.java new file mode 100644 index 000000000..efffbae8c --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/devsupport/MultipartStreamReader.java @@ -0,0 +1,128 @@ +/** + * 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 java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +import okio.Buffer; +import okio.BufferedSource; +import okio.ByteString; + +/** + * Utility class to parse the body of a response of type multipart/mixed. + */ +public class MultipartStreamReader { + // Standard line separator for HTTP. + private static final String CRLF = "\r\n"; + + private final BufferedSource mSource; + private final String mBoundary; + + public interface ChunkCallback { + void execute(Map headers, Buffer body, boolean done) throws IOException; + } + + public MultipartStreamReader(BufferedSource source, String boundary) { + mSource = source; + mBoundary = boundary; + } + + private Map parseHeaders(Buffer data) { + Map headers = new HashMap<>(); + + String text = data.readUtf8(); + String[] lines = text.split(CRLF); + for (String line : lines) { + int indexOfSeparator = line.indexOf(":"); + if (indexOfSeparator == -1) { + continue; + } + + String key = line.substring(0, indexOfSeparator).trim(); + String value = line.substring(indexOfSeparator + 1).trim(); + headers.put(key, value); + } + + return headers; + } + + private void emitChunk(Buffer chunk, boolean done, ChunkCallback callback) throws IOException { + ByteString marker = ByteString.encodeUtf8(CRLF + CRLF); + long indexOfMarker = chunk.indexOf(marker); + if (indexOfMarker == -1) { + callback.execute(null, chunk, done); + } else { + Buffer headers = new Buffer(); + Buffer body = new Buffer(); + chunk.read(headers, indexOfMarker); + chunk.skip(marker.size()); + chunk.readAll(body); + callback.execute(parseHeaders(headers), body, done); + } + } + + /** + * Reads all parts of the multipart response and execute the callback for each chunk received. + * @param callback Callback executed when a chunk is received + * @return If the read was successful + */ + public boolean readAllParts(ChunkCallback callback) throws IOException { + ByteString delimiter = ByteString.encodeUtf8(CRLF + "--" + mBoundary + CRLF); + ByteString closeDelimiter = ByteString.encodeUtf8(CRLF + "--" + mBoundary + "--" + CRLF); + + int bufferLen = 4 * 1024; + long chunkStart = 0; + long bytesSeen = 0; + Buffer content = new Buffer(); + + while (true) { + boolean isCloseDelimiter = false; + + // Search only a subset of chunk that we haven't seen before + few bytes + // to allow for the edge case when the delimiter is cut by read call. + long searchStart = Math.max(bytesSeen - closeDelimiter.size(), chunkStart); + long indexOfDelimiter = content.indexOf(delimiter, searchStart); + if (indexOfDelimiter == -1) { + isCloseDelimiter = true; + indexOfDelimiter = content.indexOf(closeDelimiter, searchStart); + } + + if (indexOfDelimiter == -1) { + bytesSeen = content.size(); + long bytesRead = mSource.read(content, bufferLen); + if (bytesRead <= 0) { + return false; + } + continue; + } + + long chunkEnd = indexOfDelimiter; + long length = chunkEnd - chunkStart; + + // Ignore preamble + if (chunkStart > 0) { + Buffer chunk = new Buffer(); + content.skip(chunkStart); + content.read(chunk, length); + emitChunk(chunk, isCloseDelimiter, callback); + } else { + content.skip(chunkEnd); + } + + if (isCloseDelimiter) { + return true; + } + + bytesSeen = chunkStart = delimiter.size(); + } + } +} diff --git a/ReactAndroid/src/main/res/devsupport/layout/dev_loading_view.xml b/ReactAndroid/src/main/res/devsupport/layout/dev_loading_view.xml new file mode 100644 index 000000000..c503c9c7d --- /dev/null +++ b/ReactAndroid/src/main/res/devsupport/layout/dev_loading_view.xml @@ -0,0 +1,14 @@ + + + diff --git a/ReactAndroid/src/main/res/devsupport/values/strings.xml b/ReactAndroid/src/main/res/devsupport/values/strings.xml index dbc3343ca..b6811017a 100644 --- a/ReactAndroid/src/main/res/devsupport/values/strings.xml +++ b/ReactAndroid/src/main/res/devsupport/values/strings.xml @@ -11,8 +11,6 @@ Hide Perf Monitor Dev Settings Catalyst Dev Settings - Please wait… - Fetching JS bundle Unable to download JS bundle. Did you forget to start the development server or connect your device? Connecting to remote debugger Unable to connect with remote debugger @@ -23,4 +21,5 @@ Start/Stop Sampling Profiler Copy Report + Loading from %1$s… diff --git a/ReactAndroid/src/test/java/com/facebook/react/devsupport/BUCK b/ReactAndroid/src/test/java/com/facebook/react/devsupport/BUCK index 0c1403ea8..5d0cf4b93 100644 --- a/ReactAndroid/src/test/java/com/facebook/react/devsupport/BUCK +++ b/ReactAndroid/src/test/java/com/facebook/react/devsupport/BUCK @@ -16,6 +16,7 @@ rn_robolectric_test( react_native_dep("third-party/java/mockito:mockito"), react_native_dep("third-party/java/okhttp:okhttp3"), react_native_dep("third-party/java/okhttp:okhttp3-ws"), + react_native_dep("third-party/java/okio:okio"), react_native_dep("third-party/java/robolectric3/robolectric:robolectric"), react_native_target("java/com/facebook/react:react"), react_native_target("java/com/facebook/react/bridge:bridge"), diff --git a/ReactAndroid/src/test/java/com/facebook/react/devsupport/MultipartStreamReaderTest.java b/ReactAndroid/src/test/java/com/facebook/react/devsupport/MultipartStreamReaderTest.java new file mode 100644 index 000000000..08089511f --- /dev/null +++ b/ReactAndroid/src/test/java/com/facebook/react/devsupport/MultipartStreamReaderTest.java @@ -0,0 +1,143 @@ +/** + * 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 org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +import java.io.IOException; +import java.util.Map; + +import okio.Buffer; +import okio.ByteString; + +import static org.fest.assertions.api.Assertions.assertThat; + +@RunWith(RobolectricTestRunner.class) +public class MultipartStreamReaderTest { + + class CallCountTrackingChunkCallback implements MultipartStreamReader.ChunkCallback { + private int mCount = 0; + + @Override + public void execute(Map headers, Buffer body, boolean done) throws IOException { + mCount++; + } + + public int getCallCount() { + return mCount; + } + } + + @Test + public void testSimpleCase() throws IOException { + ByteString response = ByteString.encodeUtf8( + "preable, should be ignored\r\n" + + "--sample_boundary\r\n" + + "Content-Type: application/json; charset=utf-8\r\n" + + "Content-Length: 2\r\n\r\n" + + "{}\r\n" + + "--sample_boundary--\r\n" + + "epilogue, should be ignored"); + + Buffer source = new Buffer(); + source.write(response); + + MultipartStreamReader reader = new MultipartStreamReader(source, "sample_boundary"); + + CallCountTrackingChunkCallback callback = new CallCountTrackingChunkCallback() { + @Override + public void execute(Map headers, Buffer body, boolean done) throws IOException { + super.execute(headers, body, done); + + assertThat(done).isTrue(); + assertThat(headers.get("Content-Type")).isEqualTo("application/json; charset=utf-8"); + assertThat(body.readUtf8()).isEqualTo("{}"); + } + }; + boolean success = reader.readAllParts(callback); + + assertThat(callback.getCallCount()).isEqualTo(1); + assertThat(success).isTrue(); + } + + @Test + public void testMultipleParts() throws IOException { + ByteString response = ByteString.encodeUtf8( + "preable, should be ignored\r\n" + + "--sample_boundary\r\n" + + "1\r\n" + + "--sample_boundary\r\n" + + "2\r\n" + + "--sample_boundary\r\n" + + "3\r\n" + + "--sample_boundary--\r\n" + + "epilogue, should be ignored"); + + Buffer source = new Buffer(); + source.write(response); + + MultipartStreamReader reader = new MultipartStreamReader(source, "sample_boundary"); + + CallCountTrackingChunkCallback callback = new CallCountTrackingChunkCallback() { + @Override + public void execute(Map headers, Buffer body, boolean done) throws IOException { + super.execute(headers, body, done); + + assertThat(done).isEqualTo(getCallCount() == 3); + assertThat(body.readUtf8()).isEqualTo(String.valueOf(getCallCount())); + } + }; + boolean success = reader.readAllParts(callback); + + assertThat(callback.getCallCount()).isEqualTo(3); + assertThat(success).isTrue(); + } + + @Test + public void testNoDelimiter() throws IOException { + ByteString response = ByteString.encodeUtf8("Yolo"); + + Buffer source = new Buffer(); + source.write(response); + + MultipartStreamReader reader = new MultipartStreamReader(source, "sample_boundary"); + + CallCountTrackingChunkCallback callback = new CallCountTrackingChunkCallback(); + boolean success = reader.readAllParts(callback); + + assertThat(callback.getCallCount()).isEqualTo(0); + assertThat(success).isFalse(); + } + + @Test + public void testNoCloseDelimiter() throws IOException { + ByteString response = ByteString.encodeUtf8( + "preable, should be ignored\r\n" + + "--sample_boundary\r\n" + + "Content-Type: application/json; charset=utf-8\r\n" + + "Content-Length: 2\r\n\r\n" + + "{}\r\n" + + "--sample_boundary\r\n" + + "incomplete message..."); + + Buffer source = new Buffer(); + source.write(response); + + MultipartStreamReader reader = new MultipartStreamReader(source, "sample_boundary"); + + CallCountTrackingChunkCallback callback = new CallCountTrackingChunkCallback(); + boolean success = reader.readAllParts(callback); + + assertThat(callback.getCallCount()).isEqualTo(1); + assertThat(success).isFalse(); + } +}