Skip to content

Commit 231bf7c

Browse files
janicduplessisfacebook-github-bot
authored andcommitted
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 facebook#12674 Differential Revision: D4673638 Pulled By: mkonicek fbshipit-source-id: b2a1163de3d0792cf481d7111231a065f80a9594
1 parent e5ebdd8 commit 231bf7c

File tree

8 files changed

+580
-69
lines changed

8 files changed

+580
-69
lines changed
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
/**
2+
* Copyright (c) 2015-present, Facebook, Inc.
3+
* All rights reserved.
4+
*
5+
* This source code is licensed under the BSD-style license found in the
6+
* LICENSE file in the root directory of this source tree. An additional grant
7+
* of patent rights can be found in the PATENTS file in the same directory.
8+
*/
9+
10+
package com.facebook.react.devsupport;
11+
12+
import android.content.Context;
13+
import android.graphics.Color;
14+
import android.graphics.PixelFormat;
15+
import android.view.Gravity;
16+
import android.view.LayoutInflater;
17+
import android.view.WindowManager;
18+
import android.widget.TextView;
19+
20+
import com.facebook.common.logging.FLog;
21+
import com.facebook.react.R;
22+
import com.facebook.react.bridge.UiThreadUtil;
23+
import com.facebook.react.common.ReactConstants;
24+
25+
import java.net.MalformedURLException;
26+
import java.net.URL;
27+
import java.util.Locale;
28+
29+
import javax.annotation.Nullable;
30+
31+
/**
32+
* Controller to display loading messages on top of the screen. All methods are thread safe.
33+
*/
34+
public class DevLoadingViewController {
35+
private static final int COLOR_DARK_GREEN = Color.parseColor("#035900");
36+
37+
private static boolean sEnabled = true;
38+
private final Context mContext;
39+
private final WindowManager mWindowManager;
40+
private TextView mDevLoadingView;
41+
private boolean mIsVisible = false;
42+
43+
public static void setDevLoadingEnabled(boolean enabled) {
44+
sEnabled = enabled;
45+
}
46+
47+
public DevLoadingViewController(Context context) {
48+
mContext = context;
49+
mWindowManager = (WindowManager) mContext.getSystemService(Context.WINDOW_SERVICE);
50+
LayoutInflater inflater = (LayoutInflater) mContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
51+
mDevLoadingView = (TextView) inflater.inflate(R.layout.dev_loading_view, null);
52+
}
53+
54+
public void showMessage(final String message, final int color, final int backgroundColor) {
55+
if (!sEnabled) {
56+
return;
57+
}
58+
59+
UiThreadUtil.runOnUiThread(new Runnable() {
60+
@Override
61+
public void run() {
62+
mDevLoadingView.setBackgroundColor(backgroundColor);
63+
mDevLoadingView.setText(message);
64+
mDevLoadingView.setTextColor(color);
65+
66+
setVisible(true);
67+
}
68+
});
69+
}
70+
71+
public void showForUrl(String url) {
72+
URL parsedURL;
73+
try {
74+
parsedURL = new URL(url);
75+
} catch (MalformedURLException e) {
76+
FLog.e(ReactConstants.TAG, "Bundle url format is invalid. \n\n" + e.toString());
77+
return;
78+
}
79+
80+
showMessage(
81+
mContext.getString(R.string.catalyst_loading_from_url, parsedURL.getHost() + ":" + parsedURL.getPort()),
82+
Color.WHITE,
83+
COLOR_DARK_GREEN);
84+
}
85+
86+
public void showForRemoteJSEnabled() {
87+
showMessage(mContext.getString(R.string.catalyst_remotedbg_message), Color.WHITE, COLOR_DARK_GREEN);
88+
}
89+
90+
public void updateProgress(final @Nullable String status, final @Nullable Integer done, final @Nullable Integer total) {
91+
if (!sEnabled) {
92+
return;
93+
}
94+
95+
UiThreadUtil.runOnUiThread(new Runnable() {
96+
@Override
97+
public void run() {
98+
StringBuilder message = new StringBuilder();
99+
message.append(status != null ? status : "Loading");
100+
if (done != null && total != null && total > 0) {
101+
message.append(String.format(Locale.getDefault(), " %.1f%% (%d/%d)", (float) done / total * 100, done, total));
102+
}
103+
message.append("\u2026"); // `...` character
104+
105+
mDevLoadingView.setText(message);
106+
}
107+
});
108+
}
109+
110+
public void show() {
111+
if (!sEnabled) {
112+
return;
113+
}
114+
115+
UiThreadUtil.runOnUiThread(new Runnable() {
116+
@Override
117+
public void run() {
118+
setVisible(true);
119+
}
120+
});
121+
}
122+
123+
public void hide() {
124+
if (!sEnabled) {
125+
return;
126+
}
127+
128+
UiThreadUtil.runOnUiThread(new Runnable() {
129+
@Override
130+
public void run() {
131+
setVisible(false);
132+
}
133+
});
134+
}
135+
136+
private void setVisible(boolean visible) {
137+
if (visible && !mIsVisible) {
138+
WindowManager.LayoutParams params = new WindowManager.LayoutParams(
139+
WindowManager.LayoutParams.MATCH_PARENT,
140+
WindowManager.LayoutParams.WRAP_CONTENT,
141+
WindowManager.LayoutParams.TYPE_SYSTEM_OVERLAY,
142+
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
143+
PixelFormat.TRANSLUCENT);
144+
params.gravity = Gravity.TOP;
145+
mWindowManager.addView(mDevLoadingView, params);
146+
} else if (!visible && mIsVisible) {
147+
mWindowManager.removeView(mDevLoadingView);
148+
}
149+
mIsVisible = visible;
150+
}
151+
}

ReactAndroid/src/main/java/com/facebook/react/devsupport/DevServerHelper.java

Lines changed: 98 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
import java.util.Locale;
1818
import java.util.Map;
1919
import java.util.concurrent.TimeUnit;
20+
import java.util.regex.Matcher;
21+
import java.util.regex.Pattern;
2022

2123
import android.content.Context;
2224
import android.os.AsyncTask;
@@ -32,13 +34,18 @@
3234
import com.facebook.react.modules.systeminfo.AndroidInfoHelpers;
3335
import com.facebook.react.packagerconnection.JSPackagerClient;
3436

37+
import org.json.JSONException;
38+
import org.json.JSONObject;
39+
3540
import okhttp3.Call;
3641
import okhttp3.Callback;
3742
import okhttp3.ConnectionPool;
3843
import okhttp3.OkHttpClient;
3944
import okhttp3.Request;
4045
import okhttp3.Response;
4146
import okhttp3.ResponseBody;
47+
import okio.Buffer;
48+
import okio.BufferedSource;
4249
import okio.Okio;
4350
import okio.Sink;
4451

@@ -78,6 +85,7 @@ public class DevServerHelper {
7885

7986
public interface BundleDownloadCallback {
8087
void onSuccess();
88+
void onProgress(@Nullable String status, @Nullable Integer done, @Nullable Integer total);
8189
void onFailure(Exception cause);
8290
}
8391

@@ -297,6 +305,7 @@ public void downloadBundleFromURL(
297305
final String bundleURL) {
298306
final Request request = new Request.Builder()
299307
.url(bundleURL)
308+
.addHeader("Accept", "multipart/mixed")
300309
.build();
301310
mDownloadBundleFromURLCall = Assertions.assertNotNull(mClient.newCall(request));
302311
mDownloadBundleFromURLCall.enqueue(new Callback() {
@@ -316,45 +325,109 @@ public void onFailure(Call call, IOException e) {
316325
}
317326

318327
@Override
319-
public void onResponse(Call call, Response response) throws IOException {
328+
public void onResponse(Call call, final Response response) throws IOException {
320329
// ignore callback if call was cancelled
321330
if (mDownloadBundleFromURLCall == null || mDownloadBundleFromURLCall.isCanceled()) {
322331
mDownloadBundleFromURLCall = null;
323332
return;
324333
}
325334
mDownloadBundleFromURLCall = null;
326335

327-
// Check for server errors. If the server error has the expected form, fail with more info.
328-
if (!response.isSuccessful()) {
329-
String body = response.body().string();
330-
DebugServerException debugServerException = DebugServerException.parse(body);
331-
if (debugServerException != null) {
332-
callback.onFailure(debugServerException);
333-
} else {
334-
StringBuilder sb = new StringBuilder();
335-
sb.append("The development server returned response error code: ").append(response.code()).append("\n\n")
336-
.append("URL: ").append(call.request().url().toString()).append("\n\n")
337-
.append("Body:\n")
338-
.append(body);
339-
callback.onFailure(new DebugServerException(sb.toString()));
340-
}
341-
return;
342-
}
336+
final String url = response.request().url().toString();
343337

344-
Sink output = null;
345-
try {
346-
output = Okio.sink(outputFile);
347-
Okio.buffer(response.body().source()).readAll(output);
348-
callback.onSuccess();
349-
} finally {
350-
if (output != null) {
351-
output.close();
338+
// Make sure the result is a multipart response and parse the boundary.
339+
String contentType = response.header("content-type");
340+
Pattern regex = Pattern.compile("multipart/mixed;.*boundary=\"([^\"]+)\"");
341+
Matcher match = regex.matcher(contentType);
342+
if (match.find()) {
343+
String boundary = match.group(1);
344+
MultipartStreamReader bodyReader = new MultipartStreamReader(response.body().source(), boundary);
345+
boolean completed = bodyReader.readAllParts(new MultipartStreamReader.ChunkCallback() {
346+
@Override
347+
public void execute(Map<String, String> headers, Buffer body, boolean finished) throws IOException {
348+
// This will get executed for every chunk of the multipart response. The last chunk
349+
// (finished = true) will be the JS bundle, the other ones will be progress events
350+
// encoded as JSON.
351+
if (finished) {
352+
// The http status code for each separate chunk is in the X-Http-Status header.
353+
int status = response.code();
354+
if (headers.containsKey("X-Http-Status")) {
355+
status = Integer.parseInt(headers.get("X-Http-Status"));
356+
}
357+
processBundleResult(url, status, body, outputFile, callback);
358+
} else {
359+
if (!headers.containsKey("Content-Type") || !headers.get("Content-Type").equals("application/json")) {
360+
return;
361+
}
362+
try {
363+
JSONObject progress = new JSONObject(body.readUtf8());
364+
String status = null;
365+
if (progress.has("status")) {
366+
status = progress.getString("status");
367+
}
368+
Integer done = null;
369+
if (progress.has("done")) {
370+
done = progress.getInt("done");
371+
}
372+
Integer total = null;
373+
if (progress.has("total")) {
374+
total = progress.getInt("total");
375+
}
376+
callback.onProgress(status, done, total);
377+
} catch (JSONException e) {
378+
FLog.e(ReactConstants.TAG, "Error parsing progress JSON. " + e.toString());
379+
}
380+
}
381+
}
382+
});
383+
if (!completed) {
384+
callback.onFailure(new DebugServerException(
385+
"Error while reading multipart response.\n\nResponse code: " + response.code() + "\n\n" +
386+
"URL: " + call.request().url().toString() + "\n\n"));
352387
}
388+
} else {
389+
// In case the server doesn't support multipart/mixed responses, fallback to normal download.
390+
processBundleResult(url, response.code(), Okio.buffer(response.body().source()), outputFile, callback);
353391
}
354392
}
355393
});
356394
}
357395

396+
private void processBundleResult(
397+
String url,
398+
int statusCode,
399+
BufferedSource body,
400+
File outputFile,
401+
BundleDownloadCallback callback) throws IOException {
402+
// Check for server errors. If the server error has the expected form, fail with more info.
403+
if (statusCode != 200) {
404+
String bodyString = body.readUtf8();
405+
DebugServerException debugServerException = DebugServerException.parse(bodyString);
406+
if (debugServerException != null) {
407+
callback.onFailure(debugServerException);
408+
} else {
409+
StringBuilder sb = new StringBuilder();
410+
sb.append("The development server returned response error code: ").append(statusCode).append("\n\n")
411+
.append("URL: ").append(url).append("\n\n")
412+
.append("Body:\n")
413+
.append(bodyString);
414+
callback.onFailure(new DebugServerException(sb.toString()));
415+
}
416+
return;
417+
}
418+
419+
Sink output = null;
420+
try {
421+
output = Okio.sink(outputFile);
422+
body.readAll(output);
423+
callback.onSuccess();
424+
} finally {
425+
if (output != null) {
426+
output.close();
427+
}
428+
}
429+
}
430+
358431
public void cancelDownloadBundleFromURL() {
359432
if (mDownloadBundleFromURLCall != null) {
360433
mDownloadBundleFromURLCall.cancel();

0 commit comments

Comments
 (0)