diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/MultipartStreamReader.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/MultipartStreamReader.java deleted file mode 100644 index 4c99f9bee75..00000000000 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/MultipartStreamReader.java +++ /dev/null @@ -1,170 +0,0 @@ -/* - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -package com.facebook.react.devsupport; - -import com.facebook.infer.annotation.Nullsafe; -import java.io.IOException; -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; -import okio.Buffer; -import okio.BufferedSource; -import okio.ByteString; - -/** Utility class to parse the body of a response of type multipart/mixed. */ -@Nullsafe(Nullsafe.Mode.LOCAL) -class MultipartStreamReader { - // Standard line separator for HTTP. - private static final String CRLF = "\r\n"; - - private final BufferedSource mSource; - private final String mBoundary; - 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) { - mSource = source; - mBoundary = boundary; - } - - private Map parseHeaders(Buffer data) { - Map headers = new HashMap<>(); - - String text = data.readUtf8(); - String[] lines = text.split(CRLF); - for (String line : lines) { - int indexOfSeparator = line.indexOf(":"); - if (indexOfSeparator == -1) { - continue; - } - - String key = line.substring(0, indexOfSeparator).trim(); - String value = line.substring(indexOfSeparator + 1).trim(); - headers.put(key, value); - } - - return headers; - } - - 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) { - listener.onChunkComplete(Collections.emptyMap(), chunk, done); - } else { - Buffer headers = new Buffer(); - Buffer body = new Buffer(); - chunk.read(headers, indexOfMarker); - chunk.skip(marker.size()); - chunk.readAll(body); - 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 listener for each chunk received. - * - * @param listener Listener invoked when chunks are received. - * @return If the read was successful - */ - 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; - - // Search only a subset of chunk that we haven't seen before + few bytes - // to allow for the edge case when the delimiter is cut by read call. - long searchStart = Math.max(bytesSeen - closeDelimiter.size(), chunkStart); - long indexOfDelimiter = content.indexOf(delimiter, searchStart); - if (indexOfDelimiter == -1) { - isCloseDelimiter = true; - indexOfDelimiter = content.indexOf(closeDelimiter, searchStart); - } - - 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; - } - continue; - } - - long chunkEnd = indexOfDelimiter; - long length = chunkEnd - chunkStart; - - // Ignore preamble - if (chunkStart > 0) { - Buffer chunk = new Buffer(); - content.skip(chunkStart); - content.read(chunk, length); - // NULLSAFE_FIXME[Parameter Not Nullable] - emitProgress(currentHeaders, chunk.size() - currentHeadersLength, true, listener); - emitChunk(chunk, isCloseDelimiter, listener); - currentHeaders = null; - currentHeadersLength = 0; - } else { - content.skip(chunkEnd); - } - - if (isCloseDelimiter) { - return true; - } - - bytesSeen = chunkStart = delimiter.size(); - } - } -} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/MultipartStreamReader.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/MultipartStreamReader.kt new file mode 100644 index 00000000000..ff4fe287306 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/MultipartStreamReader.kt @@ -0,0 +1,168 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +@file:Suppress("DEPRECATION_ERROR") // Conflicting okio versions + +package com.facebook.react.devsupport + +import java.io.IOException +import kotlin.math.max +import okio.Buffer +import okio.BufferedSource +import okio.ByteString + +/** Utility class to parse the body of a response of type multipart/mixed. */ +internal class MultipartStreamReader( + private val source: BufferedSource, + private val boundary: String +) { + private var lastProgressEvent: Long = 0 + + interface ChunkListener { + /** Invoked when a chunk of a multipart response is fully downloaded. */ + @Throws(IOException::class) + fun onChunkComplete(headers: Map, body: Buffer, isLastChunk: Boolean) + + /** Invoked as bytes of the current chunk are read. */ + @Throws(IOException::class) + fun onChunkProgress(headers: Map, loaded: Long, total: Long) + } + + /** + * 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 + */ + @Throws(IOException::class) + fun readAllParts(listener: ChunkListener): Boolean { + val delimiter: ByteString = ByteString.encodeUtf8("$CRLF--$boundary$CRLF") + val closeDelimiter: ByteString = ByteString.encodeUtf8("$CRLF--$boundary--$CRLF") + val headersDelimiter: ByteString = ByteString.encodeUtf8(CRLF + CRLF) + + val bufferLen = 4 * 1024 + var chunkStart: Long = 0 + var bytesSeen: Long = 0 + val content = Buffer() + var currentHeaders: Map? = null + var currentHeadersLength: Long = 0 + + while (true) { + var isCloseDelimiter = false + + // Search only a subset of chunk that we haven't seen before + few bytes + // to allow for the edge case when the delimiter is cut by read call. + val searchStart = + max((bytesSeen - closeDelimiter.size()).toDouble(), chunkStart.toDouble()).toLong() + var indexOfDelimiter = content.indexOf(delimiter, searchStart) + if (indexOfDelimiter == -1L) { + isCloseDelimiter = true + indexOfDelimiter = content.indexOf(closeDelimiter, searchStart) + } + + if (indexOfDelimiter == -1L) { + bytesSeen = content.size() + + if (currentHeaders == null) { + val indexOfHeaders = content.indexOf(headersDelimiter, searchStart) + if (indexOfHeaders >= 0) { + source.read(content, indexOfHeaders) + val headers = Buffer() + content.copyTo(headers, searchStart, indexOfHeaders - searchStart) + currentHeadersLength = headers.size() + headersDelimiter.size() + currentHeaders = parseHeaders(headers) + } + } else { + emitProgress(currentHeaders, content.size() - currentHeadersLength, false, listener) + } + + val bytesRead = source.read(content, bufferLen.toLong()) + if (bytesRead <= 0) { + return false + } + continue + } + + val chunkEnd = indexOfDelimiter + val length = chunkEnd - chunkStart + + // Ignore preamble + if (chunkStart > 0) { + val chunk = Buffer() + content.skip(chunkStart) + content.read(chunk, length) + // NULLSAFE_FIXME[Parameter Not Nullable] + emitProgress(currentHeaders, chunk.size() - currentHeadersLength, true, listener) + emitChunk(chunk, isCloseDelimiter, listener) + currentHeaders = null + currentHeadersLength = 0 + } else { + content.skip(chunkEnd) + } + if (isCloseDelimiter) { + return true + } + chunkStart = delimiter.size().toLong() + bytesSeen = chunkStart + } + } + + private fun parseHeaders(data: Buffer): Map { + val headers: MutableMap = mutableMapOf() + val text = data.readUtf8() + val lines = text.split(CRLF.toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + for (line in lines) { + val indexOfSeparator = line.indexOf(":") + if (indexOfSeparator == -1) { + continue + } + val key = line.substring(0, indexOfSeparator).trim { it <= ' ' } + val value = line.substring(indexOfSeparator + 1).trim { it <= ' ' } + headers[key] = value + } + return headers + } + + @Throws(IOException::class) + private fun emitChunk(chunk: Buffer, done: Boolean, listener: ChunkListener) { + val marker: ByteString = ByteString.encodeUtf8(CRLF + CRLF) + val indexOfMarker = chunk.indexOf(marker) + if (indexOfMarker == -1L) { + listener.onChunkComplete(emptyMap(), chunk, done) + } else { + val headers = Buffer() + val body = Buffer() + chunk.read(headers, indexOfMarker) + chunk.skip(marker.size().toLong()) + chunk.readAll(body) + listener.onChunkComplete(parseHeaders(headers), body, done) + } + } + + @Throws(IOException::class) + private fun emitProgress( + headers: Map?, + contentLength: Long, + isFinal: Boolean, + listener: ChunkListener? + ) { + if (listener == null || headers == null) { + return + } + val currentTime = System.currentTimeMillis() + if (currentTime - lastProgressEvent > 16 || isFinal) { + lastProgressEvent = currentTime + val headersContentLength = headers.getOrDefault("Content-Length", "0").toLong() + listener.onChunkProgress(headers, contentLength, headersContentLength) + } + } + + companion object { + // Standard line separator for HTTP. + private const val CRLF = "\r\n" + } +} diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/devsupport/MultipartStreamReaderTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/devsupport/MultipartStreamReaderTest.kt index 2a693a8c1ee..23d2383430f 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/devsupport/MultipartStreamReaderTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/devsupport/MultipartStreamReaderTest.kt @@ -37,7 +37,7 @@ class MultipartStreamReaderTest { super.onChunkComplete(headers, body, done) assertThat(done).isTrue - assertThat(headers!!["Content-Type"]).isEqualTo("application/json; charset=utf-8") + assertThat(headers["Content-Type"]).isEqualTo("application/json; charset=utf-8") assertThat(body.readUtf8()).isEqualTo("{}") } }