From 231c7a03043b9fb3c4bf81251ad099bab0ba05c2 Mon Sep 17 00:00:00 2001 From: Rafael Oleza Date: Fri, 17 Nov 2017 07:30:13 -0800 Subject: [PATCH] Add end to end Delta support to Android devices Reviewed By: davidaurelio Differential Revision: D6338677 fbshipit-source-id: 8fa8f618bf8d6cb2291ce4405093cad23bd47fc3 --- Libraries/Utilities/HMRClient.js | 4 +- .../react/devsupport/BundleDownloader.java | 183 +++++++++++++++--- .../react/devsupport/DevInternalSettings.java | 24 ++- .../react/devsupport/DevServerHelper.java | 36 ++-- .../main/res/devsupport/xml/preferences.xml | 6 + 5 files changed, 202 insertions(+), 51 deletions(-) diff --git a/Libraries/Utilities/HMRClient.js b/Libraries/Utilities/HMRClient.js index c0443b069787e7..f6b9ce0822bc16 100644 --- a/Libraries/Utilities/HMRClient.js +++ b/Libraries/Utilities/HMRClient.js @@ -33,10 +33,12 @@ const HMRClient = { ? `${host}:${port}` : host; + bundleEntry = bundleEntry.replace(/\.(bundle|delta)/, '.js'); + // Build the websocket url const wsUrl = `ws://${wsHostPort}/hot?` + `platform=${platform}&` + - `bundleEntry=${bundleEntry.replace('.bundle', '.js')}`; + `bundleEntry=${bundleEntry}`; const activeWS = new WebSocket(wsUrl); activeWS.onerror = (e) => { diff --git a/ReactAndroid/src/main/java/com/facebook/react/devsupport/BundleDownloader.java b/ReactAndroid/src/main/java/com/facebook/react/devsupport/BundleDownloader.java index 5f61917cc9e5fe..3218d1e9de2402 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/devsupport/BundleDownloader.java +++ b/ReactAndroid/src/main/java/com/facebook/react/devsupport/BundleDownloader.java @@ -9,24 +9,23 @@ package com.facebook.react.devsupport; +import android.util.JsonReader; +import android.util.JsonToken; import android.util.Log; -import javax.annotation.Nullable; - +import com.facebook.common.logging.FLog; +import com.facebook.infer.annotation.Assertions; +import com.facebook.react.common.DebugServerException; +import com.facebook.react.common.ReactConstants; +import com.facebook.react.devsupport.interfaces.DevBundleDownloadListener; import java.io.File; +import java.io.FileOutputStream; import java.io.IOException; +import java.io.InputStreamReader; +import java.util.LinkedHashMap; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; - -import com.facebook.common.logging.FLog; -import com.facebook.infer.annotation.Assertions; -import com.facebook.react.common.ReactConstants; -import com.facebook.react.devsupport.interfaces.DevBundleDownloadListener; -import com.facebook.react.common.DebugServerException; - -import org.json.JSONException; -import org.json.JSONObject; - +import javax.annotation.Nullable; import okhttp3.Call; import okhttp3.Callback; import okhttp3.OkHttpClient; @@ -36,6 +35,8 @@ import okio.BufferedSource; import okio.Okio; import okio.Sink; +import org.json.JSONException; +import org.json.JSONObject; public class BundleDownloader { private static final String TAG = "BundleDownloader"; @@ -45,6 +46,11 @@ public class BundleDownloader { private final OkHttpClient mClient; + private final LinkedHashMap mPreModules = new LinkedHashMap<>(); + private final LinkedHashMap mDeltaModules = new LinkedHashMap<>(); + private final LinkedHashMap mPostModules = new LinkedHashMap<>(); + + private @Nullable String mDeltaId; private @Nullable Call mDownloadBundleFromURLCall; public static class BundleInfo { @@ -102,13 +108,22 @@ public void downloadBundleFromURL( final File outputFile, final String bundleURL, final @Nullable BundleInfo bundleInfo) { - final Request request = new Request.Builder() - .url(bundleURL) - // FIXME: there is a bug that makes MultipartStreamReader to never find the end of the - // multipart message. This temporarily disables the multipart mode to work around it, but - // it means there is no progress bar displayed in the React Native overlay anymore. - //.addHeader("Accept", "multipart/mixed") - .build(); + + String finalUrl = bundleURL; + + if (isDeltaUrl(bundleURL) && mDeltaId != null) { + finalUrl += "&deltaBundleId=" + mDeltaId; + } + + final Request request = + new Request.Builder() + .url(finalUrl) + // FIXME: there is a bug that makes MultipartStreamReader to never find the end of the + // multipart message. This temporarily disables the multipart mode to work around it, + // but + // it means there is no progress bar displayed in the React Native overlay anymore. + // .addHeader("Accept", "multipart/mixed") + .build(); mDownloadBundleFromURLCall = Assertions.assertNotNull(mClient.newCall(request)); mDownloadBundleFromURLCall.enqueue(new Callback() { @Override @@ -161,6 +176,7 @@ public void execute(Map headers, Buffer body, boolean finished) if (!headers.containsKey("Content-Type") || !headers.get("Content-Type").equals("application/json")) { return; } + try { JSONObject progress = new JSONObject(body.readUtf8()); String status = null; @@ -202,14 +218,15 @@ public void cancelDownloadBundleFromURL() { } } - private static void processBundleResult( + private void processBundleResult( String url, int statusCode, okhttp3.Headers headers, BufferedSource body, File outputFile, BundleInfo bundleInfo, - DevBundleDownloadListener callback) throws IOException { + DevBundleDownloadListener 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(); @@ -232,9 +249,32 @@ private static void processBundleResult( } File tmpFile = new File(outputFile.getPath() + ".tmp"); + + boolean bundleUpdated; + + if (isDeltaUrl(url)) { + // If the bundle URL has the delta extension, we need to use the delta patching logic. + bundleUpdated = storeDeltaInFile(body, tmpFile); + } else { + resetDeltaCache(); + bundleUpdated = storePlainJSInFile(body, tmpFile); + } + + if (bundleUpdated) { + // If we have received a new bundle from the server, move it to its final destination. + if (!tmpFile.renameTo(outputFile)) { + throw new IOException("Couldn't rename " + tmpFile + " to " + outputFile); + } + } + + callback.onSuccess(); + } + + private static boolean storePlainJSInFile(BufferedSource body, File outputFile) + throws IOException { Sink output = null; try { - output = Okio.sink(tmpFile); + output = Okio.sink(outputFile); body.readAll(output); } finally { if (output != null) { @@ -242,11 +282,102 @@ private static void processBundleResult( } } - if (tmpFile.renameTo(outputFile)) { - callback.onSuccess(); - } else { - throw new IOException("Couldn't rename " + tmpFile + " to " + outputFile); + return true; + } + + private boolean storeDeltaInFile(BufferedSource body, File outputFile) throws IOException { + + JsonReader jsonReader = new JsonReader(new InputStreamReader(body.inputStream())); + + jsonReader.beginObject(); + + int numChangedModules = 0; + + while (jsonReader.hasNext()) { + String name = jsonReader.nextName(); + if (name.equals("id")) { + mDeltaId = jsonReader.nextString(); + } else if (name.equals("pre")) { + numChangedModules += patchDelta(jsonReader, mPreModules); + } else if (name.equals("post")) { + numChangedModules += patchDelta(jsonReader, mPostModules); + } else if (name.equals("delta")) { + numChangedModules += patchDelta(jsonReader, mDeltaModules); + } else { + jsonReader.skipValue(); + } + } + + jsonReader.endObject(); + jsonReader.close(); + + if (numChangedModules == 0) { + // If we receive an empty delta, we don't need to save the file again (it'll have the + // same content). + return false; } + + FileOutputStream fileOutputStream = new FileOutputStream(outputFile); + + try { + for (byte[] code : mPreModules.values()) { + fileOutputStream.write(code); + fileOutputStream.write('\n'); + } + + for (byte[] code : mDeltaModules.values()) { + fileOutputStream.write(code); + fileOutputStream.write('\n'); + } + + for (byte[] code : mPostModules.values()) { + fileOutputStream.write(code); + fileOutputStream.write('\n'); + } + } finally { + fileOutputStream.flush(); + fileOutputStream.close(); + } + + return true; + } + + private static int patchDelta(JsonReader jsonReader, LinkedHashMap map) + throws IOException { + jsonReader.beginArray(); + + int numModules = 0; + while (jsonReader.hasNext()) { + jsonReader.beginArray(); + + int moduleId = jsonReader.nextInt(); + + if (jsonReader.peek() == JsonToken.NULL) { + jsonReader.skipValue(); + map.remove(moduleId); + } else { + map.put(moduleId, jsonReader.nextString().getBytes()); + } + + jsonReader.endArray(); + numModules++; + } + + jsonReader.endArray(); + + return numModules; + } + + private void resetDeltaCache() { + mDeltaId = null; + + mDeltaModules.clear(); + mPreModules.clear(); + mPostModules.clear(); + } + + private static boolean isDeltaUrl(String bundleUrl) { + return bundleUrl.indexOf(".delta?") != -1; } private static void populateBundleInfo(String url, okhttp3.Headers headers, BundleInfo bundleInfo) { diff --git a/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevInternalSettings.java b/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevInternalSettings.java index bfd8d0403f0e05..53fb08e5b04cbb 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevInternalSettings.java +++ b/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevInternalSettings.java @@ -9,12 +9,10 @@ package com.facebook.react.devsupport; -import javax.annotation.Nullable; - +import android.annotation.SuppressLint; import android.content.Context; import android.content.SharedPreferences; import android.preference.PreferenceManager; - import com.facebook.react.common.annotations.VisibleForTesting; import com.facebook.react.modules.debug.interfaces.DeveloperSettings; import com.facebook.react.packagerconnection.PackagerConnectionSettings; @@ -32,6 +30,7 @@ public class DevInternalSettings implements private static final String PREFS_FPS_DEBUG_KEY = "fps_debug"; private static final String PREFS_JS_DEV_MODE_DEBUG_KEY = "js_dev_mode_debug"; private static final String PREFS_JS_MINIFY_DEBUG_KEY = "js_minify_debug"; + private static final String PREFS_JS_BUNDLE_DELTAS_KEY = "js_bundle_deltas"; private static final String PREFS_ANIMATIONS_DEBUG_KEY = "animations_debug"; private static final String PREFS_RELOAD_ON_JS_CHANGE_KEY = "reload_on_js_change"; private static final String PREFS_INSPECTOR_DEBUG_KEY = "inspector_debug"; @@ -81,10 +80,11 @@ public boolean isJSMinifyEnabled() { public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { if (mListener != null) { - if (PREFS_FPS_DEBUG_KEY.equals(key) || - PREFS_RELOAD_ON_JS_CHANGE_KEY.equals(key) || - PREFS_JS_DEV_MODE_DEBUG_KEY.equals(key) || - PREFS_JS_MINIFY_DEBUG_KEY.equals(key)) { + if (PREFS_FPS_DEBUG_KEY.equals(key) + || PREFS_RELOAD_ON_JS_CHANGE_KEY.equals(key) + || PREFS_JS_DEV_MODE_DEBUG_KEY.equals(key) + || PREFS_JS_BUNDLE_DELTAS_KEY.equals(key) + || PREFS_JS_MINIFY_DEBUG_KEY.equals(key)) { mListener.onInternalSettingsChanged(); } } @@ -114,6 +114,16 @@ public void setElementInspectorEnabled(boolean enabled) { mPreferences.edit().putBoolean(PREFS_INSPECTOR_DEBUG_KEY, enabled).apply(); } + @SuppressLint("SharedPreferencesUse") + public boolean isBundleDeltasEnabled() { + return mPreferences.getBoolean(PREFS_JS_BUNDLE_DELTAS_KEY, false); + } + + @SuppressLint("SharedPreferencesUse") + public void setBundleDeltasEnabled(boolean enabled) { + mPreferences.edit().putBoolean(PREFS_JS_BUNDLE_DELTAS_KEY, enabled).apply(); + } + @Override public boolean isRemoteJSDebugEnabled() { return mPreferences.getBoolean(PREFS_REMOTE_JS_DEBUG_KEY, false); 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 16204c6eeb377b..0bea52b3fa8be6 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevServerHelper.java +++ b/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevServerHelper.java @@ -63,10 +63,8 @@ public class DevServerHelper { private static final String RELOAD_APP_ACTION_SUFFIX = ".RELOAD_APP_ACTION"; private static final String BUNDLE_URL_FORMAT = - "http://%s/%s.bundle?platform=android&dev=%s&minify=%s"; + "http://%s/%s.%s?platform=android&dev=%s&minify=%s"; private static final String RESOURCE_URL_FORMAT = "http://%s/%s"; - private static final String SOURCE_MAP_URL_FORMAT = - BUNDLE_URL_FORMAT.replaceFirst("\\.bundle", ".map"); private static final String LAUNCH_JS_DEVTOOLS_COMMAND_URL_FORMAT = "http://%s/launch-js-devtools"; private static final String ONCHANGE_ENDPOINT_URL_FORMAT = @@ -357,11 +355,15 @@ private boolean getJSMinifyMode() { } private static String createBundleURL( - String host, - String jsModulePath, - boolean devMode, - boolean jsMinify) { - return String.format(Locale.US, BUNDLE_URL_FORMAT, host, jsModulePath, devMode, jsMinify); + String host, String jsModulePath, boolean devMode, boolean jsMinify, boolean useDeltas) { + return String.format( + Locale.US, + BUNDLE_URL_FORMAT, + host, + jsModulePath, + useDeltas ? "delta" : "bundle", + devMode, + jsMinify); } private static String createResourceURL(String host, String resourcePath) { @@ -378,10 +380,11 @@ private static String createOpenStackFrameURL(String host) { public String getDevServerBundleURL(final String jsModulePath) { return createBundleURL( - mSettings.getPackagerConnectionSettings().getDebugServerHost(), - jsModulePath, - getDevMode(), - getJSMinifyMode()); + mSettings.getPackagerConnectionSettings().getDebugServerHost(), + jsModulePath, + getDevMode(), + getJSMinifyMode(), + mSettings.isBundleDeltasEnabled()); } public void isPackagerRunning(final PackagerStatusCallback callback) { @@ -540,9 +543,10 @@ public void onResponse(Call call, Response response) throws IOException { public String getSourceMapUrl(String mainModuleName) { return String.format( Locale.US, - SOURCE_MAP_URL_FORMAT, + BUNDLE_URL_FORMAT, mSettings.getPackagerConnectionSettings().getDebugServerHost(), mainModuleName, + "map", getDevMode(), getJSMinifyMode()); } @@ -553,6 +557,7 @@ public String getSourceUrl(String mainModuleName) { BUNDLE_URL_FORMAT, mSettings.getPackagerConnectionSettings().getDebugServerHost(), mainModuleName, + mSettings.isBundleDeltasEnabled() ? "delta" : "bundle", getDevMode(), getJSMinifyMode()); } @@ -562,10 +567,7 @@ public String getJSBundleURLForRemoteDebugging(String mainModuleName) { // same as the one needed to connect to the same server from the JavaScript proxy running on the // host itself. return createBundleURL( - getHostForJSProxy(), - mainModuleName, - getDevMode(), - getJSMinifyMode()); + getHostForJSProxy(), mainModuleName, getDevMode(), getJSMinifyMode(), false); } /** diff --git a/ReactAndroid/src/main/res/devsupport/xml/preferences.xml b/ReactAndroid/src/main/res/devsupport/xml/preferences.xml index 4e69a0e5a648ed..b334730b57282a 100644 --- a/ReactAndroid/src/main/res/devsupport/xml/preferences.xml +++ b/ReactAndroid/src/main/res/devsupport/xml/preferences.xml @@ -19,6 +19,12 @@ android:summary="Load JavaScript bundle with minify=true for debugging minification issues." android:defaultValue="false" /> +