From 18184a83f18942436e647891335d56ce79a59742 Mon Sep 17 00:00:00 2001 From: Alexander Blom Date: Tue, 15 Nov 2016 08:55:38 -0800 Subject: [PATCH] Make Android connect to the inspector proxy Summary: Maintains a single persistent connection to the packager for the inspector. It supports getting the available pages and connecting to them. Reviewed By: foghina Differential Revision: D4088690 fbshipit-source-id: 0c445225f5a3de573b199e7868c8693b78f45729 --- .../com/facebook/react/bridge/Inspector.java | 17 +- .../react/devsupport/DevServerHelper.java | 40 ++- .../devsupport/DevSupportManagerImpl.java | 2 + .../InspectorPackagerConnection.java | 295 ++++++++++++++++++ .../systeminfo/AndroidInfoHelpers.java | 11 + 5 files changed, 362 insertions(+), 3 deletions(-) create mode 100644 ReactAndroid/src/main/java/com/facebook/react/devsupport/InspectorPackagerConnection.java diff --git a/ReactAndroid/src/main/java/com/facebook/react/bridge/Inspector.java b/ReactAndroid/src/main/java/com/facebook/react/bridge/Inspector.java index 9d117c80642..18e9cfa984a 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/bridge/Inspector.java +++ b/ReactAndroid/src/main/java/com/facebook/react/bridge/Inspector.java @@ -3,10 +3,13 @@ package com.facebook.react.bridge; import java.util.Arrays; +import java.util.Collections; import java.util.List; +import com.facebook.common.logging.FLog; import com.facebook.jni.HybridData; import com.facebook.proguard.annotations.DoNotStrip; +import com.facebook.react.common.ReactConstants; @DoNotStrip public class Inspector { @@ -17,11 +20,21 @@ public class Inspector { private final HybridData mHybridData; public static List getPages() { - return Arrays.asList(instance().getPagesNative()); + try { + return Arrays.asList(instance().getPagesNative()); + } catch (UnsatisfiedLinkError e) { + FLog.e(ReactConstants.TAG, "Inspector doesn't work in open source yet", e); + return Collections.emptyList(); + } } public static LocalConnection connect(int pageId, RemoteConnection remote) { - return instance().connectNative(pageId, remote); + try { + return instance().connectNative(pageId, remote); + } catch (UnsatisfiedLinkError e) { + FLog.e(ReactConstants.TAG, "Inspector doesn't work in open source yet", e); + throw new RuntimeException(e); + } } private static native Inspector instance(); 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 cb0f509cfe0..407612ea5cd 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevServerHelper.java +++ b/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevServerHelper.java @@ -64,6 +64,7 @@ public class DevServerHelper { 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 HEAP_CAPTURE_UPLOAD_URL_FORMAT = "http://%s/jscheapcaptureupload"; + private static final String INSPECTOR_DEVICE_URL_FORMAT = "http://%s/inspector/device?name=%s"; private static final String PACKAGER_OK_STATUS = "packager-status:running"; @@ -94,6 +95,7 @@ public class DevServerHelper { private boolean mOnChangePollingEnabled; private @Nullable JSPackagerWebSocketClient mPackagerConnection; + private @Nullable InspectorPackagerConnection mInspectorPackagerConnection; private @Nullable OkHttpClient mOnChangePollingClient; private @Nullable OnServerContentChangeListener mOnServerContentChangeListener; private @Nullable Call mDownloadBundleFromURLCall; @@ -145,7 +147,35 @@ public class DevServerHelper { }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); } - /** Intent action for reloading the JS */ + public void openInspectorConnection() { + if (mInspectorPackagerConnection != null) { + FLog.w(ReactConstants.TAG, "Inspector connection already open, nooping."); + return; + } + new AsyncTask() { + @Override + protected Void doInBackground(Void... params) { + mInspectorPackagerConnection = new InspectorPackagerConnection(getInspectorDeviceUrl()); + mInspectorPackagerConnection.connect(); + return null; + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + + public void closeInspectorConnection() { + new AsyncTask() { + @Override + protected Void doInBackground(Void... params) { + if (mInspectorPackagerConnection != null) { + mInspectorPackagerConnection.closeQuietly(); + mInspectorPackagerConnection = null; + } + return null; + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + + /** Intent action for reloading the JS */ public static String getReloadAppAction(Context context) { return context.getPackageName() + RELOAD_APP_ACTION_SUFFIX; } @@ -162,6 +192,14 @@ public class DevServerHelper { return String.format(Locale.US, HEAP_CAPTURE_UPLOAD_URL_FORMAT, getDebugServerHost()); } + public String getInspectorDeviceUrl() { + return String.format( + Locale.US, + INSPECTOR_DEVICE_URL_FORMAT, + getDebugServerHost(), + AndroidInfoHelpers.getFriendlyDeviceName()); + } + /** * @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 7453c73f197..9a86c15e83c 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevSupportManagerImpl.java +++ b/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevSupportManagerImpl.java @@ -821,6 +821,7 @@ public class DevSupportManagerImpl implements DevSupportManager, PackagerCommand } mDevServerHelper.openPackagerConnection(this); + mDevServerHelper.openInspectorConnection(); if (mDevSettings.isReloadOnJSChangeEnabled()) { mDevServerHelper.startPollingOnChangeEndpoint( new DevServerHelper.OnServerContentChangeListener() { @@ -861,6 +862,7 @@ public class DevSupportManagerImpl implements DevSupportManager, PackagerCommand } mDevServerHelper.closePackagerConnection(); + mDevServerHelper.closeInspectorConnection(); mDevServerHelper.stopPollingOnChangeEndpoint(); } } diff --git a/ReactAndroid/src/main/java/com/facebook/react/devsupport/InspectorPackagerConnection.java b/ReactAndroid/src/main/java/com/facebook/react/devsupport/InspectorPackagerConnection.java new file mode 100644 index 00000000000..9a35e0f67be --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/devsupport/InspectorPackagerConnection.java @@ -0,0 +1,295 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +package com.facebook.react.devsupport; + +import javax.annotation.Nullable; + +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import android.os.Handler; +import android.os.Looper; + +import com.facebook.common.logging.FLog; +import com.facebook.react.bridge.Inspector; + +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.ResponseBody; +import okhttp3.ws.WebSocket; +import okhttp3.ws.WebSocketCall; +import okhttp3.ws.WebSocketListener; +import okio.Buffer; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +public class InspectorPackagerConnection { + private static final String TAG = "InspectorPackagerConnection"; + + private final Connection mConnection; + private final Map mInspectorConnections; + + public InspectorPackagerConnection(String url) { + mConnection = new Connection(url); + mInspectorConnections = new HashMap<>(); + } + + public void connect() { + mConnection.connect(); + } + + public void closeQuietly() { + mConnection.close(); + } + + void handleProxyMessage(JSONObject message) + throws JSONException, IOException { + String event = message.getString("event"); + switch (event) { + case "getPages": + sendEvent("getPages", getPages()); + break; + case "wrappedEvent": + handleWrappedEvent(message.getJSONObject("payload")); + break; + case "connect": + handleConnect(message.getJSONObject("payload")); + break; + case "disconnect": + handleDisconnect(message.getJSONObject("payload")); + break; + default: + throw new IllegalArgumentException("Unknown event: " + event); + } + } + + void closeAllConnections() { + for (Map.Entry entry : mInspectorConnections.entrySet()) { + entry.getValue().disconnect(); + } + mInspectorConnections.clear(); + } + + private void handleConnect(JSONObject payload) throws JSONException, IOException { + final String pageId = payload.getString("pageId"); + Inspector.LocalConnection inspectorConnection = mInspectorConnections.remove(pageId); + if (inspectorConnection != null) { + throw new IllegalStateException("Already connected: " + pageId); + } + + try { + // TODO: Use strings for id's too + inspectorConnection = Inspector.connect(Integer.parseInt(pageId), new Inspector.RemoteConnection() { + @Override + public void onMessage(String message) { + try { + sendWrappedEvent(pageId, message); + } catch (IOException | JSONException e) { + FLog.w(TAG, "Couldn't send event to packager", e); + } + } + + @Override + public void onDisconnect() { + try { + mInspectorConnections.remove(pageId); + sendEvent("disconnect", makePageIdPayload(pageId)); + } catch (IOException | JSONException e) { + FLog.w(TAG, "Couldn't send event to packager", e); + } + } + }); + mInspectorConnections.put(pageId, inspectorConnection); + } catch (Exception e) { + FLog.w(TAG, "Failed to open page: " + pageId, e); + sendEvent("disconnect", makePageIdPayload(pageId)); + } + } + + private void handleDisconnect(JSONObject payload) throws JSONException { + final String pageId = payload.getString("pageId"); + Inspector.LocalConnection inspectorConnection = mInspectorConnections.remove(pageId); + if (inspectorConnection == null) { + return; + } + + inspectorConnection.disconnect(); + } + + private void handleWrappedEvent(JSONObject payload) throws JSONException, IOException { + final String pageId = payload.getString("pageId"); + String wrappedEvent = payload.getString("wrappedEvent"); + Inspector.LocalConnection inspectorConnection = mInspectorConnections.get(pageId); + if (inspectorConnection == null) { + throw new IllegalStateException("Not connected: " + pageId); + } + inspectorConnection.sendMessage(wrappedEvent); + } + + private JSONArray getPages() throws JSONException { + List pages = Inspector.getPages(); + JSONArray array = new JSONArray(); + for (Inspector.Page page : pages) { + JSONObject jsonPage = new JSONObject(); + jsonPage.put("id", String.valueOf(page.getId())); + jsonPage.put("title", page.getTitle()); + array.put(jsonPage); + } + return array; + } + + private void sendWrappedEvent(String pageId, String message) throws IOException, JSONException { + JSONObject payload = new JSONObject(); + payload.put("pageId", pageId); + payload.put("wrappedEvent", message); + sendEvent("wrappedEvent", payload); + } + + private void sendEvent(String name, Object payload) + throws JSONException, IOException { + JSONObject jsonMessage = new JSONObject(); + jsonMessage.put("event", name); + jsonMessage.put("payload", payload); + mConnection.send(jsonMessage); + } + + private JSONObject makePageIdPayload(String pageId) throws JSONException { + JSONObject payload = new JSONObject(); + payload.put("pageId", pageId); + return payload; + } + + private class Connection implements WebSocketListener { + private static final int RECONNECT_DELAY_MS = 2000; + + private final String mUrl; + + private @Nullable WebSocket mWebSocket; + private final Handler mHandler; + private boolean mClosed; + private boolean mSuppressConnectionErrors; + + public Connection(String url) { + mUrl = url; + mHandler = new Handler(Looper.getMainLooper()); + } + + @Override + public void onOpen(WebSocket webSocket, Response response) { + mWebSocket = webSocket; + } + + @Override + public void onFailure(IOException e, Response response) { + if (mWebSocket != null) { + abort("Websocket exception", e); + } + if (!mClosed) { + reconnect(); + } + } + + @Override + public void onMessage(ResponseBody message) throws IOException { + try { + handleProxyMessage(new JSONObject(message.string())); + } catch (JSONException e) { + throw new IOException(e); + } finally { + message.close(); + } + } + + @Override + public void onPong(Buffer payload) { + } + + @Override + public void onClose(int code, String reason) { + mWebSocket = null; + closeAllConnections(); + if (!mClosed) { + reconnect(); + } + } + + public void connect() { + if (mClosed) { + throw new IllegalStateException("Can't connect closed client"); + } + 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() { + if (mClosed) { + throw new IllegalStateException("Can't reconnect closed client"); + } + if (!mSuppressConnectionErrors) { + FLog.w(TAG, "Couldn't connect to packager, will silently retry"); + mSuppressConnectionErrors = true; + } + mHandler.postDelayed( + new Runnable() { + @Override + public void run() { + // check that we haven't been closed in the meantime + if (!mClosed) { + connect(); + } + } + }, + RECONNECT_DELAY_MS); + } + + public void close() { + mClosed = true; + if (mWebSocket != null) { + try { + mWebSocket.close(1000, "End of session"); + } catch (IOException e) { + // swallow, no need to handle it here + } + mWebSocket = null; + } + } + + public void send(JSONObject object) throws IOException { + if (mWebSocket == null) { + return; + } + + mWebSocket.sendMessage(RequestBody.create(WebSocket.TEXT, object.toString())); + } + + private void abort(String message, Throwable cause) { + FLog.e(TAG, "Error occurred, shutting down websocket connection: " + message, cause); + closeAllConnections(); + closeWebSocketQuietly(); + } + + private void closeWebSocketQuietly() { + if (mWebSocket != null) { + try { + mWebSocket.close(1000, "End of session"); + } catch (IOException e) { + // swallow, no need to handle it here + } + mWebSocket = null; + } + } + } +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/modules/systeminfo/AndroidInfoHelpers.java b/ReactAndroid/src/main/java/com/facebook/react/modules/systeminfo/AndroidInfoHelpers.java index 9f61648e391..977b4824daf 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/modules/systeminfo/AndroidInfoHelpers.java +++ b/ReactAndroid/src/main/java/com/facebook/react/modules/systeminfo/AndroidInfoHelpers.java @@ -1,3 +1,5 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + package com.facebook.react.modules.systeminfo; import android.os.Build; @@ -30,4 +32,13 @@ public class AndroidInfoHelpers { return DEVICE_LOCALHOST; } + + public static String getFriendlyDeviceName() { + if (isRunningOnGenymotion()) { + // Genymotion already has a friendly name by default + return Build.MODEL; + } else { + return Build.MODEL + " - " + Build.VERSION.RELEASE + " - API " + Build.VERSION.SDK_INT; + } + } }