diff --git a/ReactAndroid/src/main/java/com/facebook/react/bridge/JSPackagerWebSocketClient.java b/ReactAndroid/src/main/java/com/facebook/react/bridge/JSPackagerWebSocketClient.java new file mode 100644 index 000000000..e3379017f --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/bridge/JSPackagerWebSocketClient.java @@ -0,0 +1,164 @@ +/** + * 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.bridge; + +import javax.annotation.Nullable; + +import java.io.IOException; +import java.util.Timer; +import java.util.TimerTask; +import java.util.concurrent.TimeUnit; + +import com.facebook.common.logging.FLog; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; +import okhttp3.ws.WebSocket; +import okhttp3.ws.WebSocketCall; +import okhttp3.ws.WebSocketListener; +import okio.Buffer; + +/** + * A wrapper around WebSocketClient that recognizes packager's message format. + */ +public class JSPackagerWebSocketClient implements WebSocketListener { + + private static final String TAG = "JSPackagerWebSocketClient"; + private final String mUrl; + + public interface JSPackagerCallback { + void onMessage(String target, String action); + } + + private @Nullable WebSocket mWebSocket; + private @Nullable JSPackagerCallback mCallback; + + public JSPackagerWebSocketClient(String url, JSPackagerCallback callback) { + super(); + mUrl = url; + mCallback = callback; + } + + public void connect() { + OkHttpClient httpClient = new OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .writeTimeout(10, TimeUnit.SECONDS) + .readTimeout(0, TimeUnit.MINUTES) // Disable timeouts for read + .build(); + + Request request = new Request.Builder().url(mUrl).build(); + WebSocketCall call = WebSocketCall.create(httpClient, request); + call.enqueue(this); + } + + private void reconnect() { + new Timer().schedule(new TimerTask() { + @Override + public void run() { + connect(); + } + }, 2000); + } + + public void closeQuietly() { + if (mWebSocket != null) { + try { + mWebSocket.close(1000, "End of session"); + } catch (IOException e) { + // swallow, no need to handle it here + } + mWebSocket = null; + } + } + + private void triggerMessageCallback(String target, String action) { + if (mCallback != null) { + mCallback.onMessage(target, action); + } + } + + @Override + public void onMessage(ResponseBody response) throws IOException { + if (response.contentType() != WebSocket.TEXT) { + FLog.w(TAG, "Websocket received unexpected message with payload of type " + response.contentType()); + return; + } + + String message = null; + try { + message = response.source().readUtf8(); + } finally { + response.close(); + } + + try { + JsonParser parser = new JsonFactory().createParser(message); + + Integer version = null; + String target = null; + String action = null; + + while (parser.nextToken() != JsonToken.END_OBJECT) { + String field = parser.getCurrentName(); + if ("version".equals(field)) { + parser.nextToken(); + version = parser.getIntValue(); + } else if ("target".equals(field)) { + parser.nextToken(); + target = parser.getText(); + } else if ("action".equals(field)) { + parser.nextToken(); + action = parser.getText(); + } + } + if (version != 1) { + return; + } + if (target == null || action == null) { + return; + } + + triggerMessageCallback(target, action); + } catch (IOException e) { + abort("Parsing response message from websocket failed", e); + } + } + + @Override + public void onFailure(IOException e, Response response) { + abort("Websocket exception", e); + reconnect(); + } + + @Override + public void onOpen(WebSocket webSocket, Response response) { + mWebSocket = webSocket; + } + + @Override + public void onClose(int code, String reason) { + mWebSocket = null; + reconnect(); + } + + @Override + public void onPong(Buffer payload) { + // ignore + } + + private void abort(String message, Throwable cause) { + FLog.e(TAG, "Error occurred, shutting down websocket connection: " + message, cause); + closeQuietly(); + } +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/devsupport/BUCK b/ReactAndroid/src/main/java/com/facebook/react/devsupport/BUCK index 0757fe379..085e17621 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/devsupport/BUCK +++ b/ReactAndroid/src/main/java/com/facebook/react/devsupport/BUCK @@ -9,6 +9,7 @@ android_library( react_native_dep('third-party/java/infer-annotations:infer-annotations'), react_native_dep('third-party/java/jsr-305:jsr-305'), 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_target('java/com/facebook/react/bridge:bridge'), react_native_target('java/com/facebook/react/common:common'), 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 a7c5565f5..7e0976d49 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevServerHelper.java +++ b/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevServerHelper.java @@ -15,6 +15,7 @@ import android.text.TextUtils; import com.facebook.common.logging.FLog; import com.facebook.infer.annotation.Assertions; +import com.facebook.react.bridge.JSPackagerWebSocketClient; import com.facebook.react.bridge.UiThreadUtil; import com.facebook.react.common.ReactConstants; import com.facebook.react.common.network.OkHttpCallUtil; @@ -59,6 +60,7 @@ public class DevServerHelper { private static final String ONCHANGE_ENDPOINT_URL_FORMAT = "http://%s/onchange"; private static final String WEBSOCKET_PROXY_URL_FORMAT = "ws://%s/debugger-proxy?role=client"; + private static final String PACKAGER_CONNECTION_URL_FORMAT = "ws://%s/message?role=shell"; private static final String PACKAGER_STATUS_URL_FORMAT = "http://%s/status"; private static final String PACKAGER_OK_STATUS = "packager-status:running"; @@ -76,12 +78,17 @@ public class DevServerHelper { void onServerContentChanged(); } + public interface PackagerCommandListener { + void onReload(); + } + public interface PackagerStatusCallback { void onPackagerStatusFetched(boolean packagerIsRunning); } private final DevInternalSettings mSettings; private final OkHttpClient mClient; + private final JSPackagerWebSocketClient mPackagerConnection; private final Handler mRestartOnChangePollingHandler; private boolean mOnChangePollingEnabled; @@ -89,7 +96,7 @@ public class DevServerHelper { private @Nullable OnServerContentChangeListener mOnServerContentChangeListener; private @Nullable Call mDownloadBundleFromURLCall; - public DevServerHelper(DevInternalSettings settings) { + public DevServerHelper(DevInternalSettings settings, final PackagerCommandListener commandListener) { mSettings = settings; mClient = new OkHttpClient.Builder() .connectTimeout(HTTP_CONNECT_TIMEOUT_MS, TimeUnit.MILLISECONDS) @@ -98,6 +105,16 @@ public class DevServerHelper { .build(); mRestartOnChangePollingHandler = new Handler(); + mPackagerConnection = new JSPackagerWebSocketClient(getPackagerConnectionURL(), + new JSPackagerWebSocketClient.JSPackagerCallback() { + @Override + public void onMessage(String target, String action) { + if (commandListener != null && "bridge".equals(target) && "reload".equals(action)) { + commandListener.onReload(); + } + } + }); + mPackagerConnection.connect(); } /** Intent action for reloading the JS */ @@ -109,6 +126,10 @@ public class DevServerHelper { return String.format(Locale.US, WEBSOCKET_PROXY_URL_FORMAT, getDebugServerHost()); } + private String getPackagerConnectionURL() { + return String.format(Locale.US, PACKAGER_CONNECTION_URL_FORMAT, getDebugServerHost()); + } + /** * @return the host to use when connecting to the bundle server from the host itself. */ 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 3a0bf3250..0355256e1 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevSupportManagerImpl.java +++ b/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevSupportManagerImpl.java @@ -135,7 +135,19 @@ public class DevSupportManagerImpl implements DevSupportManager { mApplicationContext = applicationContext; mJSAppBundleName = packagerPathForJSBundleName; mDevSettings = new DevInternalSettings(applicationContext, this); - mDevServerHelper = new DevServerHelper(mDevSettings); + mDevServerHelper = new DevServerHelper( + mDevSettings, + new DevServerHelper.PackagerCommandListener() { + @Override + public void onReload() { + UiThreadUtil.runOnUiThread(new Runnable() { + @Override + public void run() { + handleReloadJS(); + } + }); + } + }); // Prepare shake gesture detector (will be started/stopped from #reload) mShakeDetector = new ShakeDetector(new ShakeDetector.ShakeListener() {