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
This commit is contained in:
Janic Duplessis
2017-03-09 10:30:51 -08:00
committed by Facebook Github Bot
parent e5ebdd8458
commit 231bf7c68b
8 changed files with 580 additions and 69 deletions

View File

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

View File

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

View File

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

View File

@@ -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<String, String> headers, Buffer body, boolean done) throws IOException;
}
public MultipartStreamReader(BufferedSource source, String boundary) {
mSource = source;
mBoundary = boundary;
}
private Map<String, String> parseHeaders(Buffer data) {
Map<String, String> 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();
}
}
}