From 675f14257a129cf8047f60ddbf1e1bcfeb8b4453 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Tue, 13 Feb 2018 20:20:39 -0800 Subject: [PATCH] Bundle download progress on Android Summary: Android equivalent of #15066 Tested that download progress shows up properly when reloading the app. [ANDROID] [FEATURE] [DevSupport] - Show bundle download progress on Android Closes https://github.com/facebook/react-native/pull/17809 Differential Revision: D6982823 Pulled By: hramos fbshipit-source-id: da01e42b8ebb1c603f4407f6bafd68e0b6b3ecba --- .../react/devsupport/BundleDownloader.java | 17 +++-- .../devsupport/MultipartStreamReader.java | 62 ++++++++++++++++--- .../devsupport/MultipartStreamReaderTest.java | 41 ++++++------ 3 files changed, 88 insertions(+), 32 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 6cf6cb14eba3bc..a5501081ac90f8 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/devsupport/BundleDownloader.java +++ b/ReactAndroid/src/main/java/com/facebook/react/devsupport/BundleDownloader.java @@ -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,6 +184,15 @@ public void execute(Map headers, Buffer body, boolean finished) } } } + @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( 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..7a661eb031ecee 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/devsupport/MultipartStreamReader.java +++ b/ReactAndroid/src/main/java/com/facebook/react/devsupport/MultipartStreamReader.java @@ -26,9 +26,18 @@ public class MultipartStreamReader { private final BufferedSource mSource; private final String mBoundary; - - public interface ChunkCallback { - void execute(Map headers, Buffer body, boolean done) throws IOException; + private long mLastProgressEvent; + + 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) { @@ -55,34 +64,50 @@ 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, ChunkListener listener) throws IOException { + if (headers == null || listener == 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; + 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) 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); 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 +123,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() + headersDelimiter.size(); + currentHeaders = parseHeaders(headers); + } + } else { + emitProgress(currentHeaders, content.size() - currentHeadersLength, false, listener); + } + long bytesRead = mSource.read(content, bufferLen); if (bytesRead <= 0) { return false; @@ -113,7 +152,10 @@ public boolean readAllParts(ChunkCallback callback) throws IOException { Buffer chunk = new Buffer(); content.skip(chunkStart); content.read(chunk, length); - emitChunk(chunk, isCloseDelimiter, callback); + emitProgress(currentHeaders, chunk.size() - currentHeadersLength, true, listener); + emitChunk(chunk, isCloseDelimiter, listener); + currentHeaders = null; + currentHeadersLength = 0; } else { content.skip(chunkEnd); } 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);