From 7ab48d2a73dbd2fc562253bc56eb189b49adcb4a Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Tue, 25 Jul 2017 00:34:13 -0400 Subject: [PATCH 1/4] Bundle download progress on Android --- .../react/devsupport/BundleDownloader.java | 10 +++++ .../devsupport/MultipartStreamReader.java | 40 ++++++++++++++++++- 2 files changed, 49 insertions(+), 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 6cf6cb14eba3bc..97790442ef8ba1 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/devsupport/BundleDownloader.java +++ b/ReactAndroid/src/main/java/com/facebook/react/devsupport/BundleDownloader.java @@ -184,6 +184,16 @@ public void execute(Map headers, Buffer body, boolean finished) } } } + }, new MultipartStreamReader.ProgressCallback() { + @Override + public void execute(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( diff --git a/ReactAndroid/src/main/java/com/facebook/react/devsupport/MultipartStreamReader.java b/ReactAndroid/src/main/java/com/facebook/react/devsupport/MultipartStreamReader.java index efffbae8cccabe..e8247f400c1336 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/devsupport/MultipartStreamReader.java +++ b/ReactAndroid/src/main/java/com/facebook/react/devsupport/MultipartStreamReader.java @@ -26,11 +26,16 @@ public class MultipartStreamReader { private final BufferedSource mSource; private final String mBoundary; + private long mLastProgressEvent; public interface ChunkCallback { void execute(Map headers, Buffer body, boolean done) throws IOException; } + public interface ProgressCallback { + void execute(Map headers, long loaded, long total) throws IOException; + } + public MultipartStreamReader(BufferedSource source, String boundary) { mSource = source; mBoundary = boundary; @@ -70,19 +75,35 @@ private void emitChunk(Buffer chunk, boolean done, ChunkCallback callback) throw } } + private void emitProgress(Map headers, long contentLength, boolean isFinal, ProgressCallback callback) throws IOException { + if (headers == null || callback == null) { + return; + } + + long currentTime = System.currentTimeMillis(); + if (currentTime - mLastProgressEvent > 16 || isFinal) { + mLastProgressEvent = currentTime; + long headersContentLength = headers.get("Content-Length") != null ? Long.parseLong(headers.get("Content-Length")) : 0; + callback.execute(headers, contentLength, headersContentLength); + } + } + /** * Reads all parts of the multipart response and execute the callback for each chunk received. * @param callback Callback executed when a chunk is received * @return If the read was successful */ - public boolean readAllParts(ChunkCallback callback) throws IOException { + public boolean readAllParts(ChunkCallback callback, ProgressCallback progressCallback) throws IOException { ByteString delimiter = ByteString.encodeUtf8(CRLF + "--" + mBoundary + CRLF); ByteString closeDelimiter = ByteString.encodeUtf8(CRLF + "--" + mBoundary + "--" + CRLF); + ByteString headersDelimiter = ByteString.encodeUtf8(CRLF + CRLF); int bufferLen = 4 * 1024; long chunkStart = 0; long bytesSeen = 0; Buffer content = new Buffer(); + Map currentHeaders = null; + long currentHeadersLength = 0; while (true) { boolean isCloseDelimiter = false; @@ -98,6 +119,20 @@ public boolean readAllParts(ChunkCallback callback) throws IOException { if (indexOfDelimiter == -1) { bytesSeen = content.size(); + + if (currentHeaders == null) { + long indexOfHeaders = content.indexOf(headersDelimiter, searchStart); + if (indexOfHeaders >= 0) { + mSource.read(content, indexOfHeaders); + Buffer headers = new Buffer(); + content.copyTo(headers, searchStart, indexOfHeaders - searchStart); + currentHeadersLength = headers.size(); + currentHeaders = parseHeaders(headers); + } + } else { + emitProgress(currentHeaders, content.size() - currentHeadersLength, false, progressCallback); + } + long bytesRead = mSource.read(content, bufferLen); if (bytesRead <= 0) { return false; @@ -113,7 +148,10 @@ public boolean readAllParts(ChunkCallback callback) throws IOException { Buffer chunk = new Buffer(); content.skip(chunkStart); content.read(chunk, length); + emitProgress(currentHeaders, chunk.size() - currentHeadersLength, true, progressCallback); emitChunk(chunk, isCloseDelimiter, callback); + currentHeaders = null; + currentHeadersLength = 0; } else { content.skip(chunkEnd); } From 58029d2e7d09f279ccf3c568e4f96162d66ea566 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Mon, 12 Feb 2018 23:52:23 -0500 Subject: [PATCH 2/4] Rename progress api, fix off by one progress number --- .../react/devsupport/BundleDownloader.java | 19 ++++----- .../devsupport/MultipartStreamReader.java | 42 ++++++++++--------- 2 files changed, 32 insertions(+), 29 deletions(-) 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 97790442ef8ba1..37ec30f8bf59e6 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/devsupport/BundleDownloader.java +++ b/ReactAndroid/src/main/java/com/facebook/react/devsupport/BundleDownloader.java @@ -109,7 +109,7 @@ public void downloadBundleFromURL( // 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") + .addHeader("Accept", "multipart/mixed") .build(); mDownloadBundleFromURLCall = Assertions.assertNotNull(mClient.newCall(request)); mDownloadBundleFromURLCall.enqueue(new Callback() { @@ -146,13 +146,13 @@ public void onResponse(Call call, final Response response) throws IOException { if (match.find()) { String boundary = match.group(1); MultipartStreamReader bodyReader = new MultipartStreamReader(response.body().source(), boundary); - boolean completed = bodyReader.readAllParts(new MultipartStreamReader.ChunkCallback() { + boolean completed = bodyReader.readAllParts(new MultipartStreamReader.ChunkListener() { @Override - public void execute(Map headers, Buffer body, boolean finished) throws IOException { + 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 - // (finished = true) will be the JS bundle, the other ones will be progress events + // (isLastChunk = true) will be the JS bundle, the other ones will be progress events // encoded as JSON. - if (finished) { + 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")) { @@ -184,14 +184,13 @@ public void execute(Map headers, Buffer body, boolean finished) } } } - }, new MultipartStreamReader.ProgressCallback() { @Override - public void execute(Map headers, long loaded, long total) throws IOException { + 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)); + "Downloading JavaScript bundle", + (int) (loaded / 1024), + (int) (total / 1024)); } } }); diff --git a/ReactAndroid/src/main/java/com/facebook/react/devsupport/MultipartStreamReader.java b/ReactAndroid/src/main/java/com/facebook/react/devsupport/MultipartStreamReader.java index e8247f400c1336..7a661eb031ecee 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/devsupport/MultipartStreamReader.java +++ b/ReactAndroid/src/main/java/com/facebook/react/devsupport/MultipartStreamReader.java @@ -28,12 +28,16 @@ public class MultipartStreamReader { private final String mBoundary; private long mLastProgressEvent; - public interface ChunkCallback { - void execute(Map headers, Buffer body, boolean done) throws IOException; - } - - public interface ProgressCallback { - void execute(Map headers, long loaded, long total) throws IOException; + public interface ChunkListener { + /** + * Invoked when a chunk of a multipart response is fully downloaded. + */ + void onChunkComplete(Map headers, Buffer body, boolean isLastChunk) throws IOException; + + /** + * Invoked as bytes of the current chunk are read. + */ + void onChunkProgress(Map headers, long loaded, long total) throws IOException; } public MultipartStreamReader(BufferedSource source, String boundary) { @@ -60,23 +64,23 @@ private Map parseHeaders(Buffer data) { return headers; } - private void emitChunk(Buffer chunk, boolean done, ChunkCallback callback) throws IOException { + private void emitChunk(Buffer chunk, boolean done, ChunkListener listener) throws IOException { ByteString marker = ByteString.encodeUtf8(CRLF + CRLF); long indexOfMarker = chunk.indexOf(marker); if (indexOfMarker == -1) { - callback.execute(null, chunk, done); + listener.onChunkComplete(null, chunk, done); } else { Buffer headers = new Buffer(); Buffer body = new Buffer(); chunk.read(headers, indexOfMarker); chunk.skip(marker.size()); chunk.readAll(body); - callback.execute(parseHeaders(headers), body, done); + listener.onChunkComplete(parseHeaders(headers), body, done); } } - private void emitProgress(Map headers, long contentLength, boolean isFinal, ProgressCallback callback) throws IOException { - if (headers == null || callback == null) { + private void emitProgress(Map headers, long contentLength, boolean isFinal, ChunkListener listener) throws IOException { + if (headers == null || listener == null) { return; } @@ -84,16 +88,16 @@ private void emitProgress(Map headers, long contentLength, boole if (currentTime - mLastProgressEvent > 16 || isFinal) { mLastProgressEvent = currentTime; long headersContentLength = headers.get("Content-Length") != null ? Long.parseLong(headers.get("Content-Length")) : 0; - callback.execute(headers, contentLength, headersContentLength); + listener.onChunkProgress(headers, contentLength, headersContentLength); } } /** - * Reads all parts of the multipart response and execute the callback for each chunk received. - * @param callback Callback executed when a chunk is received + * Reads all parts of the multipart response and execute the listener for each chunk received. + * @param listener Listener invoked when chunks are received. * @return If the read was successful */ - public boolean readAllParts(ChunkCallback callback, ProgressCallback progressCallback) throws IOException { + public boolean readAllParts(ChunkListener listener) throws IOException { ByteString delimiter = ByteString.encodeUtf8(CRLF + "--" + mBoundary + CRLF); ByteString closeDelimiter = ByteString.encodeUtf8(CRLF + "--" + mBoundary + "--" + CRLF); ByteString headersDelimiter = ByteString.encodeUtf8(CRLF + CRLF); @@ -126,11 +130,11 @@ public boolean readAllParts(ChunkCallback callback, ProgressCallback progressCal mSource.read(content, indexOfHeaders); Buffer headers = new Buffer(); content.copyTo(headers, searchStart, indexOfHeaders - searchStart); - currentHeadersLength = headers.size(); + currentHeadersLength = headers.size() + headersDelimiter.size(); currentHeaders = parseHeaders(headers); } } else { - emitProgress(currentHeaders, content.size() - currentHeadersLength, false, progressCallback); + emitProgress(currentHeaders, content.size() - currentHeadersLength, false, listener); } long bytesRead = mSource.read(content, bufferLen); @@ -148,8 +152,8 @@ public boolean readAllParts(ChunkCallback callback, ProgressCallback progressCal Buffer chunk = new Buffer(); content.skip(chunkStart); content.read(chunk, length); - emitProgress(currentHeaders, chunk.size() - currentHeadersLength, true, progressCallback); - emitChunk(chunk, isCloseDelimiter, callback); + emitProgress(currentHeaders, chunk.size() - currentHeadersLength, true, listener); + emitChunk(chunk, isCloseDelimiter, listener); currentHeaders = null; currentHeadersLength = 0; } else { From fed68e9321546e47294b71ff7a947787ed9a733d Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Tue, 13 Feb 2018 00:09:39 -0500 Subject: [PATCH 3/4] Update tests --- .../devsupport/MultipartStreamReaderTest.java | 41 +++++++++++-------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/ReactAndroid/src/test/java/com/facebook/react/devsupport/MultipartStreamReaderTest.java b/ReactAndroid/src/test/java/com/facebook/react/devsupport/MultipartStreamReaderTest.java index 08089511f7bfe1..e304694319b899 100644 --- a/ReactAndroid/src/test/java/com/facebook/react/devsupport/MultipartStreamReaderTest.java +++ b/ReactAndroid/src/test/java/com/facebook/react/devsupport/MultipartStreamReaderTest.java @@ -24,14 +24,19 @@ @RunWith(RobolectricTestRunner.class) public class MultipartStreamReaderTest { - class CallCountTrackingChunkCallback implements MultipartStreamReader.ChunkCallback { + class CallCountTrackingChunkCallback implements MultipartStreamReader.ChunkListener { private int mCount = 0; @Override - public void execute(Map headers, Buffer body, boolean done) throws IOException { + public void onChunkComplete(Map headers, Buffer body, boolean done) throws IOException { mCount++; } + @Override + public void onChunkProgress(Map headers, long loaded, long total) throws IOException { + + } + public int getCallCount() { return mCount; } @@ -41,12 +46,12 @@ public int getCallCount() { public void testSimpleCase() throws IOException { ByteString response = ByteString.encodeUtf8( "preable, should be ignored\r\n" + - "--sample_boundary\r\n" + - "Content-Type: application/json; charset=utf-8\r\n" + - "Content-Length: 2\r\n\r\n" + - "{}\r\n" + - "--sample_boundary--\r\n" + - "epilogue, should be ignored"); + "--sample_boundary\r\n" + + "Content-Type: application/json; charset=utf-8\r\n" + + "Content-Length: 2\r\n\r\n" + + "{}\r\n" + + "--sample_boundary--\r\n" + + "epilogue, should be ignored"); Buffer source = new Buffer(); source.write(response); @@ -55,8 +60,8 @@ public void testSimpleCase() throws IOException { CallCountTrackingChunkCallback callback = new CallCountTrackingChunkCallback() { @Override - public void execute(Map headers, Buffer body, boolean done) throws IOException { - super.execute(headers, body, done); + public void onChunkComplete(Map headers, Buffer body, boolean done) throws IOException { + super.onChunkComplete(headers, body, done); assertThat(done).isTrue(); assertThat(headers.get("Content-Type")).isEqualTo("application/json; charset=utf-8"); @@ -89,8 +94,8 @@ public void testMultipleParts() throws IOException { CallCountTrackingChunkCallback callback = new CallCountTrackingChunkCallback() { @Override - public void execute(Map headers, Buffer body, boolean done) throws IOException { - super.execute(headers, body, done); + public void onChunkComplete(Map headers, Buffer body, boolean done) throws IOException { + super.onChunkComplete(headers, body, done); assertThat(done).isEqualTo(getCallCount() == 3); assertThat(body.readUtf8()).isEqualTo(String.valueOf(getCallCount())); @@ -122,12 +127,12 @@ public void testNoDelimiter() throws IOException { public void testNoCloseDelimiter() throws IOException { ByteString response = ByteString.encodeUtf8( "preable, should be ignored\r\n" + - "--sample_boundary\r\n" + - "Content-Type: application/json; charset=utf-8\r\n" + - "Content-Length: 2\r\n\r\n" + - "{}\r\n" + - "--sample_boundary\r\n" + - "incomplete message..."); + "--sample_boundary\r\n" + + "Content-Type: application/json; charset=utf-8\r\n" + + "Content-Length: 2\r\n\r\n" + + "{}\r\n" + + "--sample_boundary\r\n" + + "incomplete message..."); Buffer source = new Buffer(); source.write(response); From 60e8060c0ab626ce18397e23dba07edec444841f Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Tue, 13 Feb 2018 00:12:55 -0500 Subject: [PATCH 4/4] Re-comment test --- .../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 37ec30f8bf59e6..a5501081ac90f8 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/devsupport/BundleDownloader.java +++ b/ReactAndroid/src/main/java/com/facebook/react/devsupport/BundleDownloader.java @@ -109,7 +109,7 @@ public void downloadBundleFromURL( // 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") + // .addHeader("Accept", "multipart/mixed") .build(); mDownloadBundleFromURLCall = Assertions.assertNotNull(mClient.newCall(request)); mDownloadBundleFromURLCall.enqueue(new Callback() {