diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/network/NetworkEventUtil.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/network/NetworkEventUtil.kt index 8965f140ef6f..dbb0d746ad21 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/network/NetworkEventUtil.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/network/NetworkEventUtil.kt @@ -260,15 +260,16 @@ internal object NetworkEventUtil { val body = (requestBody as? ProgressRequestBody)?.innerBody() ?: requestBody if (body.isOneShot()) { - // Fallback - body cannot be read twice - return "[Preview unavailable]" + // Reading would drain the underlying stream and break the real upload, + // so fall back to a placeholder that includes the byte count when known + return binaryPartLabel(body) } // MultipartBody does not propagate isOneShot() from its parts, so check each // part explicitly. Reading a one-shot part here would drain the underlying // stream and cause the real request to fail. if (body is MultipartBody && body.parts().any { it.body().isOneShot() }) { - return "[Preview unavailable]" + return previewMultipartWithBinaryParts(body) } return try { @@ -285,4 +286,53 @@ internal object NetworkEventUtil { "[Preview unavailable]" } } + + private fun previewMultipartWithBinaryParts(body: MultipartBody): String { + val boundary = body.boundary() + val out = StringBuilder() + + for (part in body.parts()) { + out.append("--").append(boundary).append("\r\n") + + part.headers()?.let { headers -> + for (i in 0 until headers.size()) { + out.append(headers.name(i)).append(": ").append(headers.value(i)).append("\r\n") + } + } + val partBody = part.body() + partBody.contentType()?.let { out.append("Content-Type: ").append(it).append("\r\n") } + out.append("\r\n") + + if (partBody.isOneShot()) { + out.append(binaryPartLabel(partBody)) + } else { + try { + val partBuffer = Buffer() + partBody.writeTo(partBuffer) + out.append(partBuffer.readUtf8()) + } catch (e: IOException) { + out.append("[Preview unavailable]") + } + } + out.append("\r\n") + } + out.append("--").append(boundary).append("--\r\n") + + return if (out.length <= MAX_BODY_PREVIEW_SIZE) { + out.toString() + } else { + out.substring(0, MAX_BODY_PREVIEW_SIZE) + "... (truncated, ${out.length} bytes total)" + } + } + + /** Placeholder for a one-shot body, including the byte count when known. */ + private fun binaryPartLabel(body: RequestBody): String { + val length = + try { + body.contentLength() + } catch (e: IOException) { + -1L + } + return if (length >= 0) "[Binary data, $length bytes]" else "[Binary data]" + } } diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/network/NetworkEventUtilTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/network/NetworkEventUtilTest.kt index ba47cddec5a0..2f922179c3c5 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/network/NetworkEventUtilTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/network/NetworkEventUtilTest.kt @@ -5,6 +5,8 @@ * LICENSE file in the root directory of this source tree. */ +@file:Suppress("DEPRECATION_ERROR") // Conflicting okhttp versions + package com.facebook.react.modules.network import com.facebook.react.bridge.Arguments @@ -15,7 +17,13 @@ import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsDefaults import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests import com.facebook.testutils.shadows.ShadowArguments +import java.io.ByteArrayInputStream import java.net.SocketTimeoutException +import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody import org.assertj.core.api.Assertions.assertThat import org.junit.After import org.junit.Before @@ -292,6 +300,84 @@ class NetworkEventUtilTest { assertThat(args.getString(3)).isEqualTo(url) } + @Test + fun testGetRequestBodyPreviewReturnsNullForNullBody() { + assertThat(NetworkEventUtil.getRequestBodyPreview(null)).isNull() + } + + @Test + fun testGetRequestBodyPreviewReturnsBodyForStringRequest() { + val payload = """{"key":"value"}""" + val body = payload.toRequestBody("application/json".toMediaTypeOrNull()) + + assertThat(NetworkEventUtil.getRequestBodyPreview(body)).isEqualTo(payload) + } + + @Test + fun testGetRequestBodyPreviewUnwrapsProgressRequestBody() { + val payload = "hello world" + val inner = payload.toRequestBody("text/plain".toMediaTypeOrNull()) + val wrapped = ProgressRequestBody(inner) { _, _, _ -> } + + assertThat(NetworkEventUtil.getRequestBodyPreview(wrapped)).isEqualTo(payload) + } + + @Test + fun testGetRequestBodyPreviewMultipartWithTextParts() { + val body = + MultipartBody.Builder("test-boundary") + .setType(MultipartBody.FORM) + .addFormDataPart("field1", "value1") + .addFormDataPart("field2", "value2") + .build() + + val preview = NetworkEventUtil.getRequestBodyPreview(body) + + assertThat(preview).isNotNull() + assertThat(preview).contains("--test-boundary") + assertThat(preview).contains("--test-boundary--") + assertThat(preview).contains("name=\"field1\"") + assertThat(preview).contains("value1") + assertThat(preview).contains("name=\"field2\"") + assertThat(preview).contains("value2") + assertThat(preview).doesNotContain("[Preview unavailable]") + } + + @Test + fun testGetRequestBodyPreviewMultipartWithFilePartReplacesBinaryContent() { + val fileBytes = ByteArray(2048) { it.toByte() } + val streamingPart = + RequestBodyUtil.create(MediaType.parse("application/octet-stream"), ByteArrayInputStream(fileBytes)) + val body = + MultipartBody.Builder("test-boundary") + .setType(MultipartBody.FORM) + .addFormDataPart("description", "an image") + .addFormDataPart("file", "photo.jpg", streamingPart) + .build() + + val preview = NetworkEventUtil.getRequestBodyPreview(body) + + assertThat(preview).isNotNull() + assertThat(preview).contains("--test-boundary") + assertThat(preview).contains("name=\"description\"") + assertThat(preview).contains("an image") + assertThat(preview).contains("name=\"file\"") + assertThat(preview).contains("filename=\"photo.jpg\"") + assertThat(preview).contains("[Binary data, 2048 bytes]") + assertThat(preview).doesNotContain("[Preview unavailable]") + } + + @Test + fun testGetRequestBodyPreviewSingleOneShotBodyShowsPlaceholder() { + val fileBytes = ByteArray(512) { it.toByte() } + val body = + RequestBodyUtil.create(MediaType.parse("application/octet-stream"), ByteArrayInputStream(fileBytes)) + + val preview = NetworkEventUtil.getRequestBodyPreview(body) + + assertThat(preview).isEqualTo("[Binary data, 512 bytes]") + } + @Test fun testNullReactContext() { val url = "http://example.com"