From d797753111e21923645bc1b5de46d1f2a1338796 Mon Sep 17 00:00:00 2001 From: Dratwas Date: Wed, 12 Jun 2019 22:21:49 +0200 Subject: [PATCH 1/2] Support downloading of multiple bundles --- .../react/devsupport/BundleDownloader.java | 512 +++++++++++------- .../devsupport/CreateBundleURLLambda.java | 5 + .../react/devsupport/DevServerHelper.java | 19 +- 3 files changed, 336 insertions(+), 200 deletions(-) create mode 100644 ReactAndroid/src/main/java/com/facebook/react/devsupport/CreateBundleURLLambda.java 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 3651949a9bfc..bc17e822806c 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/devsupport/BundleDownloader.java +++ b/ReactAndroid/src/main/java/com/facebook/react/devsupport/BundleDownloader.java @@ -18,6 +18,7 @@ import java.io.File; import java.io.IOException; import java.util.Map; +import java.util.concurrent.CountDownLatch; import java.util.regex.Matcher; import java.util.regex.Pattern; import javax.annotation.Nullable; @@ -102,6 +103,17 @@ public int getFilesChangedCount() { } } + private interface DownloadListener { + void onSuccess(String sourceURL, @Nullable NativeDeltaClient nativeDeltaClient, @Nullable String[] additionalBundles); + void onProgress(@Nullable String status, @Nullable Integer done, @Nullable Integer total); + void onFailure(Exception cause); + } + + private interface MultiDownloadListener { + void onSuccess(); + void onFailure(Exception cause); + } + public BundleDownloader(OkHttpClient client) { mClient = client; } @@ -112,9 +124,18 @@ public void downloadBundleFromURL( final File bundlesContainerFile, final String bundleURL, final @Nullable BundleInfo bundleInfo, - final BundleDeltaClient.ClientType clientType) { + final BundleDeltaClient.ClientType clientType, + final CreateBundleURLLambda bundleURLCreator) { downloadBundleFromURL( - callback, outputFile, bundlesContainerFile, bundleURL, bundleInfo, clientType, new Request.Builder()); + callback, + outputFile, + bundlesContainerFile, + bundleURL, + bundleInfo, + clientType, + new Request.Builder(), + bundleURLCreator + ); } public void downloadBundleFromURL( @@ -124,73 +145,63 @@ public void downloadBundleFromURL( final String bundleURL, final @Nullable BundleInfo bundleInfo, final BundleDeltaClient.ClientType clientType, - Request.Builder requestBuilder) { + Request.Builder requestBuilder, + final CreateBundleURLLambda bundleURLCreator) { mDevBundlesContainer = new DevBundlesContainer(bundleURL); - final Request request = - requestBuilder - .url(formatBundleUrl(bundleURL, clientType)) - // 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 - public void onFailure(Call call, IOException e) { - // ignore callback if call was cancelled - if (mDownloadBundleFromURLCall == null || mDownloadBundleFromURLCall.isCanceled()) { - mDownloadBundleFromURLCall = null; - return; - } - mDownloadBundleFromURLCall = null; + // DOWNLOAD INITIAL BUNDLE + downloadBundle(new DownloadListener() { + @Override + public void onSuccess(String sourceURL, @Nullable NativeDeltaClient nativeDeltaClient, @Nullable String[] additionalBundles) { + // DOWNLOAD ADDITIONAL BUNDLES IF NEEDED + if(additionalBundles != null) { + String bundlesDirectory = outputFile.getParent(); + downloadAdditionalBundles(new MultiDownloadListener() { + @Override + public void onSuccess() { + try { + mDownloadBundleFromURLCall = null; + storeBundlesContainerInFile(bundlesContainerFile); + callback.onSuccess(bundleURL, mDevBundlesContainer, nativeDeltaClient); + } catch (IOException e) { + callback.onFailure(e); + } + } - callback.onFailure( - DebugServerException.makeGeneric( - "Could not connect to development server.", - "URL: " + call.request().url().toString(), - e)); + @Override + public void onFailure(Exception cause) { + mDownloadBundleFromURLCall = null; + callback.onFailure(cause); + } + }, + additionalBundles, + bundlesDirectory, + requestBuilder, + bundleURLCreator + ); + } else { + try { + mDownloadBundleFromURLCall = null; + storeBundlesContainerInFile(bundlesContainerFile); + callback.onSuccess(bundleURL, mDevBundlesContainer, nativeDeltaClient); + } catch (IOException e) { + callback.onFailure(e); } + } + } - @Override - public void onResponse(Call call, final Response response) throws IOException { - // ignore callback if call was cancelled - if (mDownloadBundleFromURLCall == null || mDownloadBundleFromURLCall.isCanceled()) { - mDownloadBundleFromURLCall = null; - return; - } - mDownloadBundleFromURLCall = null; + @Override + public void onProgress(@Nullable String status, @Nullable Integer done, @Nullable Integer total) { + callback.onProgress(status, done, total); + } + + @Override + public void onFailure(Exception cause) { + mDownloadBundleFromURLCall = null; + callback.onFailure(cause); + } + }, outputFile, bundleURL, bundleInfo, clientType, requestBuilder); - final String url = response.request().url().toString(); - - // 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); - try (Response r = response) { - if (match.find()) { - processMultipartResponse( - url, r, match.group(1), outputFile, bundlesContainerFile, bundleInfo, clientType, callback); - } else { - // In case the server doesn't support multipart/mixed responses, fallback to normal - // download. - processBundleResult( - url, - r.code(), - r.headers(), - Okio.buffer(r.body().source()), - outputFile, - bundlesContainerFile, - bundleInfo, - clientType, - callback); - } - } - } - }); } private String formatBundleUrl(String bundleURL, BundleDeltaClient.ClientType clientType) { @@ -199,96 +210,269 @@ private String formatBundleUrl(String bundleURL, BundleDeltaClient.ClientType cl : bundleURL; } + private BundleDeltaClient getBundleDeltaClient(BundleDeltaClient.ClientType clientType) { + if (mBundleDeltaClient == null || !mBundleDeltaClient.canHandle(clientType)) { + mBundleDeltaClient = BundleDeltaClient.create(clientType); + } + return mBundleDeltaClient; + } + + private static boolean storePlainJSInFile(BufferedSource body, File outputFile) + throws IOException { + Sink output = null; + try { + output = Okio.sink(outputFile); + body.readAll(output); + } finally { + if (output != null) { + output.close(); + } + } + + return true; + } + + private void storeBundlesContainerInFile(File outputFile) throws IOException { + Sink fileSink = null; + try { + String jsonString = mDevBundlesContainer.toJSON().toString(); + fileSink = Okio.sink(outputFile); + Buffer buffer = new Buffer(); + buffer.writeUtf8(jsonString); + BufferedSource source = Okio.buffer((Source) buffer); + source.readAll(fileSink); + } catch(Throwable e) { + throw new IOException("Couldn't save to " + outputFile); + } finally { + if (fileSink != null) { + fileSink.close(); + } + } + } + + private void downloadAdditionalBundles(MultiDownloadListener callback, + String[] bundles, + String bundlesDirectory, + Request.Builder requestBuilder, + CreateBundleURLLambda bundleURLCreator) { + CountDownLatch countDownLatch = new CountDownLatch(bundles.length); + final Exception[] mError = {null}; + for (String bundleName : bundles) { + File bundleFile = new File(bundlesDirectory + "/" + bundleName + ".bundle"); + String bundleURL = bundleURLCreator.createBundleUrlFromBundleName(bundleName); + downloadBundle(new DownloadListener() { + @Override + public void onSuccess(String sourceURL, @Nullable NativeDeltaClient nativeDeltaClient, @Nullable String[] additionalBundles) { + countDownLatch.countDown(); + } + @Override + public void onProgress(@Nullable String status, @Nullable Integer done, @Nullable Integer total) { + // TODO Maybe in future? + } + @Override + public void onFailure(Exception cause) { + mError[0] = cause; + countDownLatch.countDown(); + } + }, + bundleFile, + bundleURL, + null, + BundleDeltaClient.ClientType.NONE, + requestBuilder + ); + } + try { + countDownLatch.await(); + // Check if all bundles are downloaded and saved + if(mError[0] != null) { + callback.onFailure(mError[0]); + } else { + callback.onSuccess(); + } + } catch (Exception e) { + callback.onFailure(e); + } + } + + private String getBundleNameFromURL(String url) { + Pattern bundleNamePattern = Pattern.compile("^https?://[^/]+/([^.]+)"); + Matcher bundleNameMatcher = bundleNamePattern.matcher(url); + if (bundleNameMatcher.find()) { + return bundleNameMatcher.group(1); + } + return "index"; + } + + private void downloadBundle(final DownloadListener callback, + final File outputFile, + final String bundleURL, + final @Nullable BundleInfo bundleInfo, + final BundleDeltaClient.ClientType clientType, + Request.Builder requestBuilder) { + final Request request = requestBuilder.url(formatBundleUrl(bundleURL, clientType)) + // 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 + public void onFailure(Call call, IOException e) { + // ignore callback if call was cancelled + if (mDownloadBundleFromURLCall == null || mDownloadBundleFromURLCall.isCanceled()) { + return; + } + + callback.onFailure( + DebugServerException.makeGeneric( + "Could not connect to development server.", + "URL: " + call.request().url().toString(), + e)); + } + + @Override + public void onResponse(Call call, Response response) throws IOException { + // ignore callback if call was cancelled + if (mDownloadBundleFromURLCall == null || mDownloadBundleFromURLCall.isCanceled()) { + return; + } + + final String url = response.request().url().toString(); + String contentType = response.header("content-type"); + Pattern regex = Pattern.compile("multipart/mixed;.*boundary=\"([^\"]+)\""); + Matcher match = regex.matcher(contentType); + try (Response r = response) { + if (match.find()) { + processMultipartResponse( + url, + r, + match.group(1), + outputFile, + bundleInfo, + clientType, + callback + ); + } else { + // In case the server doesn't support multipart/mixed responses, fallback to normal + // download. + processBundle( + url, + r.code(), + r.headers(), + Okio.buffer(r.body().source()), + outputFile, + bundleInfo, + clientType, + callback + ); + } + r.body().close(); + } + } + }); + } + private void processMultipartResponse( final String url, final Response response, String boundary, final File outputFile, - final File bundlesContainerFile, @Nullable final BundleInfo bundleInfo, final BundleDeltaClient.ClientType clientType, - final DevBundleDownloadListener callback) + final DownloadListener callback) throws IOException { - - MultipartStreamReader bodyReader = - new MultipartStreamReader(response.body().source(), boundary); + MultipartStreamReader bodyReader = new MultipartStreamReader(response.body().source(), boundary); boolean completed = - bodyReader.readAllParts( - new MultipartStreamReader.ChunkListener() { - @Override - public void onChunkComplete( - Map headers, Buffer body, boolean isLastChunk) - throws IOException { - // This will get executed for every chunk of the multipart response. The last chunk - // (isLastChunk = true) will be the JS bundle, the other ones will be progress - // events - // encoded as JSON. - if (isLastChunk) { - // 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, Headers.of(headers), body, outputFile, bundlesContainerFile, bundleInfo, clientType, 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()); - } - } - } + bodyReader.readAllParts( + new MultipartStreamReader.ChunkListener() { + @Override + public void onChunkComplete( + Map headers, Buffer body, boolean isLastChunk) + throws IOException { + // This will get executed for every chunk of the multipart response. The last chunk + // (isLastChunk = true) will be the JS bundle, the other ones will be progress + // events + // encoded as JSON. + if (isLastChunk) { + // 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")); + } + processBundle( + url, + status, + Headers.of(headers), + body, + outputFile, + bundleInfo, + clientType, + callback + ); + } else { + if (!headers.containsKey("Content-Type") + || !headers.get("Content-Type").equals("application/json")) { + return; + } - @Override - public void onChunkProgress(Map headers, long loaded, long total) - throws IOException { - if ("application/javascript".equals(headers.get("Content-Type"))) { - callback.onProgress( - "Downloading JavaScript bundle", (int) (loaded / 1024), (int) (total / 1024)); - } + 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()); + } + } + } + + @Override + public void onChunkProgress(Map headers, long loaded, long total) + throws IOException { + if ("application/javascript".equals(headers.get("Content-Type"))) { + callback.onProgress( + "Downloading JavaScript bundle", (int) (loaded / 1024), (int) (total / 1024) + ); + } + } + } + ); if (!completed) { callback.onFailure( - new DebugServerException( - "Error while reading multipart response.\n\nResponse code: " - + response.code() - + "\n\n" - + "URL: " - + url.toString() - + "\n\n")); + new DebugServerException( + "Error while reading multipart response.\n\nResponse code: " + + response.code() + + "\n\n" + + "URL: " + + url.toString() + + "\n\n" + ) + ); } } - private void processBundleResult( + private void processBundle( String url, int statusCode, Headers headers, BufferedSource body, File outputFile, - File bundlesContainerFile, BundleInfo bundleInfo, BundleDeltaClient.ClientType clientType, - DevBundleDownloadListener callback) + DownloadListener callback) throws IOException { // Check for server errors. If the server error has the expected form, fail with more info. if (statusCode != 200) { @@ -329,70 +513,19 @@ private void processBundleResult( } if (bundleWritten) { - Pattern bundleNamePattern = Pattern.compile("^https?://[^/]+/([^.]+)"); - Matcher bundleNameMatcher = bundleNamePattern.matcher(url); - String bundleName = "index"; // fallback - if (bundleNameMatcher.find()) { - bundleName = bundleNameMatcher.group(1); + String bundleName = getBundleNameFromURL(url); // fallback + + if(mDevBundlesContainer != null) { + mDevBundlesContainer.pushBundle(bundleName, url, outputFile.getPath()); } - mDevBundlesContainer.pushBundle(bundleName, url, outputFile.getPath()); - // We need to save the mDevBundlesContainer in file because it is null - // after killing app. App doesn't request for new bundles if has up to date bundle in cache. - // In that case we need to read bundles container from file. - - // TODO this code should be moved to place where all bundles are downloaded (multibundle support). - JSONObject bundlesContainerJSON = mDevBundlesContainer.toJSON(); - storeBundlesContainerInFile(bundlesContainerJSON, bundlesContainerFile); - // 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(url, mDevBundlesContainer, nativeDeltaClient); - } - - private BundleDeltaClient getBundleDeltaClient(BundleDeltaClient.ClientType clientType) { - if (mBundleDeltaClient == null || !mBundleDeltaClient.canHandle(clientType)) { - mBundleDeltaClient = BundleDeltaClient.create(clientType); - } - return mBundleDeltaClient; - } - - private static boolean storePlainJSInFile(BufferedSource body, File outputFile) - throws IOException { - Sink output = null; - try { - output = Okio.sink(outputFile); - body.readAll(output); - } finally { - if (output != null) { - output.close(); - } - } - - return true; - } - - private boolean storeBundlesContainerInFile(JSONObject body, File outputFile) - throws IOException { - Sink fileSink = null; - try { - String jsonString = body.toString(); - fileSink = Okio.sink(outputFile); - Buffer buffer = new Buffer(); - buffer.writeUtf8(jsonString); - BufferedSource source = Okio.buffer((Source) buffer); - source.readAll(fileSink); - } catch(Throwable e) { - throw new IOException("Couldn't save to " + outputFile); - } finally { - if (fileSink != null) { - fileSink.close(); - } - } - - return true; + // TODO discuss about name of header + String restBundlesHeader = headers.get("x-bundles-meta"); + String[] restBundles = restBundlesHeader != null ? restBundlesHeader.split(",") : null; + callback.onSuccess(url, nativeDeltaClient, restBundles); } private static void populateBundleInfo(String url, Headers headers, BundleDeltaClient.ClientType clientType, BundleInfo bundleInfo) { @@ -409,3 +542,4 @@ private static void populateBundleInfo(String url, Headers headers, BundleDeltaC } } } + diff --git a/ReactAndroid/src/main/java/com/facebook/react/devsupport/CreateBundleURLLambda.java b/ReactAndroid/src/main/java/com/facebook/react/devsupport/CreateBundleURLLambda.java new file mode 100644 index 000000000000..9074824cb013 --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/devsupport/CreateBundleURLLambda.java @@ -0,0 +1,5 @@ +package com.facebook.react.devsupport; + +interface CreateBundleURLLambda { + String createBundleUrlFromBundleName(String bundleName); +} \ No newline at end of file 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 f4087bbd8abe..42d02d93336e 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevServerHelper.java +++ b/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevServerHelper.java @@ -380,18 +380,15 @@ private String getInspectorAttachUrl(String title) { public void downloadBundleFromURL( DevBundleDownloadListener callback, File outputFile, File bundlesContainerFile, String bundleURL, BundleDownloader.BundleInfo bundleInfo) { - mBundleDownloader.downloadBundleFromURL(callback, outputFile, bundlesContainerFile, bundleURL, bundleInfo, getDeltaClientType()); - } - - public void downloadBundleFromURL( - DevBundleDownloadListener callback, - File outputFile, - File bundlesContainerFile, - String bundleURL, - BundleDownloader.BundleInfo bundleInfo, - Request.Builder requestBuilder) { mBundleDownloader.downloadBundleFromURL( - callback, outputFile, bundlesContainerFile, bundleURL, bundleInfo, getDeltaClientType(), requestBuilder); + callback, + outputFile, + bundlesContainerFile, + bundleURL, + bundleInfo, + getDeltaClientType(), + bundleName -> createBundleURL(bundleName, BundleType.BUNDLE) + ); } private BundleDeltaClient.ClientType getDeltaClientType() { From d6ad09321a8accffc447e6bb91ed2100cf61f343 Mon Sep 17 00:00:00 2001 From: Dratwas Date: Wed, 12 Jun 2019 22:25:20 +0200 Subject: [PATCH 2/2] set name of header --- .../java/com/facebook/react/devsupport/BundleDownloader.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 bc17e822806c..8f48b031c807 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/devsupport/BundleDownloader.java +++ b/ReactAndroid/src/main/java/com/facebook/react/devsupport/BundleDownloader.java @@ -523,7 +523,7 @@ private void processBundle( } } // TODO discuss about name of header - String restBundlesHeader = headers.get("x-bundles-meta"); + String restBundlesHeader = headers.get("X-multi-bundle"); String[] restBundles = restBundlesHeader != null ? restBundlesHeader.split(",") : null; callback.onSuccess(url, nativeDeltaClient, restBundles); }