Skip to content

Commit

Permalink
Bundle download progress on Android
Browse files Browse the repository at this point in the history
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 #17809

Differential Revision: D6982823

Pulled By: hramos

fbshipit-source-id: da01e42b8ebb1c603f4407f6bafd68e0b6b3ecba
  • Loading branch information
janicduplessis authored and facebook-github-bot committed Feb 14, 2018
1 parent d220118 commit d06e143
Show file tree
Hide file tree
Showing 3 changed files with 88 additions and 32 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> headers, Buffer body, boolean finished) throws IOException {
public void onChunkComplete(Map<String, String> 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")) {
Expand Down Expand Up @@ -184,6 +184,15 @@ public void execute(Map<String, String> headers, Buffer body, boolean finished)
}
}
}
@Override
public void onChunkProgress(Map<String, String> 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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,18 @@ public class MultipartStreamReader {

private final BufferedSource mSource;
private final String mBoundary;

public interface ChunkCallback {
void execute(Map<String, String> 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<String, String> headers, Buffer body, boolean isLastChunk) throws IOException;

/**
* Invoked as bytes of the current chunk are read.
*/
void onChunkProgress(Map<String, String> headers, long loaded, long total) throws IOException;
}

public MultipartStreamReader(BufferedSource source, String boundary) {
Expand All @@ -55,34 +64,50 @@ private Map<String, String> 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<String, String> 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<String, String> currentHeaders = null;
long currentHeadersLength = 0;

while (true) {
boolean isCloseDelimiter = false;
Expand All @@ -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;
Expand All @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> headers, Buffer body, boolean done) throws IOException {
public void onChunkComplete(Map<String, String> headers, Buffer body, boolean done) throws IOException {
mCount++;
}

@Override
public void onChunkProgress(Map<String, String> headers, long loaded, long total) throws IOException {

}

public int getCallCount() {
return mCount;
}
Expand All @@ -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);
Expand All @@ -55,8 +60,8 @@ public void testSimpleCase() throws IOException {

CallCountTrackingChunkCallback callback = new CallCountTrackingChunkCallback() {
@Override
public void execute(Map<String, String> headers, Buffer body, boolean done) throws IOException {
super.execute(headers, body, done);
public void onChunkComplete(Map<String, String> 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");
Expand Down Expand Up @@ -89,8 +94,8 @@ public void testMultipleParts() throws IOException {

CallCountTrackingChunkCallback callback = new CallCountTrackingChunkCallback() {
@Override
public void execute(Map<String, String> headers, Buffer body, boolean done) throws IOException {
super.execute(headers, body, done);
public void onChunkComplete(Map<String, String> headers, Buffer body, boolean done) throws IOException {
super.onChunkComplete(headers, body, done);

assertThat(done).isEqualTo(getCallCount() == 3);
assertThat(body.readUtf8()).isEqualTo(String.valueOf(getCallCount()));
Expand Down Expand Up @@ -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);
Expand Down

0 comments on commit d06e143

Please sign in to comment.