Skip to content

Commit c099d9d

Browse files
committed
fix(android): show payload preview for FormData and one-shot uploads in DevTools (#55764)
1 parent c0bf154 commit c099d9d

2 files changed

Lines changed: 142 additions & 8 deletions

File tree

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/network/NetworkEventUtil.kt

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@
55
* LICENSE file in the root directory of this source tree.
66
*/
77

8-
@file:Suppress("DEPRECATION_ERROR") // Conflicting okhttp versions
9-
108
package com.facebook.react.modules.network
119

1210
import android.os.Bundle
@@ -238,7 +236,7 @@ internal object NetworkEventUtil {
238236
@JvmStatic
239237
fun okHttpHeadersToMap(headers: Headers): Map<String, String> {
240238
val responseHeaders = mutableMapOf<String, String>()
241-
for (i in 0 until headers.size()) {
239+
for (i in 0 until headers.size) {
242240
val headerName = headers.name(i)
243241
// multiple values for the same header
244242
if (responseHeaders.containsKey(headerName)) {
@@ -260,22 +258,23 @@ internal object NetworkEventUtil {
260258
val body = (requestBody as? ProgressRequestBody)?.innerBody() ?: requestBody
261259

262260
if (body.isOneShot()) {
263-
// Fallback - body cannot be read twice
264-
return "[Preview unavailable]"
261+
// Reading would drain the underlying stream and break the real upload,
262+
// so fall back to a placeholder that includes the byte count when known.
263+
return binaryPartLabel(body)
265264
}
266265

267266
// MultipartBody does not propagate isOneShot() from its parts, so check each
268267
// part explicitly. Reading a one-shot part here would drain the underlying
269268
// stream and cause the real request to fail.
270-
if (body is MultipartBody && body.parts().any { it.body().isOneShot() }) {
271-
return "[Preview unavailable]"
269+
if (body is MultipartBody && body.parts.any { it.body.isOneShot() }) {
270+
return previewMultipartWithBinaryParts(body)
272271
}
273272

274273
return try {
275274
val buffer = Buffer()
276275
body.writeTo(buffer)
277276

278-
val size = buffer.size()
277+
val size = buffer.size
279278
if (size <= MAX_BODY_PREVIEW_SIZE) {
280279
buffer.readUtf8()
281280
} else {
@@ -285,4 +284,53 @@ internal object NetworkEventUtil {
285284
"[Preview unavailable]"
286285
}
287286
}
287+
288+
private fun previewMultipartWithBinaryParts(body: MultipartBody): String {
289+
val boundary = body.boundary
290+
val out = StringBuilder()
291+
292+
for (part in body.parts) {
293+
out.append("--").append(boundary).append("\r\n")
294+
295+
part.headers?.let { headers ->
296+
for (i in 0 until headers.size) {
297+
out.append(headers.name(i)).append(": ").append(headers.value(i)).append("\r\n")
298+
}
299+
}
300+
val partBody = part.body
301+
partBody.contentType()?.let { out.append("Content-Type: ").append(it).append("\r\n") }
302+
out.append("\r\n")
303+
304+
if (partBody.isOneShot()) {
305+
out.append(binaryPartLabel(partBody))
306+
} else {
307+
try {
308+
val partBuffer = Buffer()
309+
partBody.writeTo(partBuffer)
310+
out.append(partBuffer.readUtf8())
311+
} catch (e: IOException) {
312+
out.append("[Preview unavailable]")
313+
}
314+
}
315+
out.append("\r\n")
316+
}
317+
out.append("--").append(boundary).append("--\r\n")
318+
319+
return if (out.length <= MAX_BODY_PREVIEW_SIZE) {
320+
out.toString()
321+
} else {
322+
out.substring(0, MAX_BODY_PREVIEW_SIZE) + "... (truncated, ${out.length} bytes total)"
323+
}
324+
}
325+
326+
/** Placeholder for a one-shot body, including the byte count when known. */
327+
private fun binaryPartLabel(body: RequestBody): String {
328+
val length =
329+
try {
330+
body.contentLength()
331+
} catch (e: IOException) {
332+
-1L
333+
}
334+
return if (length >= 0) "[Binary data, $length bytes]" else "[Binary data]"
335+
}
288336
}

packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/network/NetworkEventUtilTest.kt

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
* LICENSE file in the root directory of this source tree.
66
*/
77

8+
@file:Suppress("DEPRECATION_ERROR") // Conflicting okhttp versions
9+
810
package com.facebook.react.modules.network
911

1012
import com.facebook.react.bridge.Arguments
@@ -15,7 +17,13 @@ import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags
1517
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsDefaults
1618
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests
1719
import com.facebook.testutils.shadows.ShadowArguments
20+
import java.io.ByteArrayInputStream
1821
import java.net.SocketTimeoutException
22+
import okhttp3.MediaType
23+
import okhttp3.MediaType.Companion.toMediaTypeOrNull
24+
import okhttp3.MultipartBody
25+
import okhttp3.RequestBody
26+
import okhttp3.RequestBody.Companion.toRequestBody
1927
import org.assertj.core.api.Assertions.assertThat
2028
import org.junit.After
2129
import org.junit.Before
@@ -292,6 +300,84 @@ class NetworkEventUtilTest {
292300
assertThat(args.getString(3)).isEqualTo(url)
293301
}
294302

303+
@Test
304+
fun testGetRequestBodyPreviewReturnsNullForNullBody() {
305+
assertThat(NetworkEventUtil.getRequestBodyPreview(null)).isNull()
306+
}
307+
308+
@Test
309+
fun testGetRequestBodyPreviewReturnsBodyForStringRequest() {
310+
val payload = """{"key":"value"}"""
311+
val body = payload.toRequestBody("application/json".toMediaTypeOrNull())
312+
313+
assertThat(NetworkEventUtil.getRequestBodyPreview(body)).isEqualTo(payload)
314+
}
315+
316+
@Test
317+
fun testGetRequestBodyPreviewUnwrapsProgressRequestBody() {
318+
val payload = "hello world"
319+
val inner = payload.toRequestBody("text/plain".toMediaTypeOrNull())
320+
val wrapped = ProgressRequestBody(inner) { _, _, _ -> }
321+
322+
assertThat(NetworkEventUtil.getRequestBodyPreview(wrapped)).isEqualTo(payload)
323+
}
324+
325+
@Test
326+
fun testGetRequestBodyPreviewMultipartWithTextParts() {
327+
val body =
328+
MultipartBody.Builder("test-boundary")
329+
.setType(MultipartBody.FORM)
330+
.addFormDataPart("field1", "value1")
331+
.addFormDataPart("field2", "value2")
332+
.build()
333+
334+
val preview = NetworkEventUtil.getRequestBodyPreview(body)
335+
336+
assertThat(preview).isNotNull()
337+
assertThat(preview).contains("--test-boundary")
338+
assertThat(preview).contains("--test-boundary--")
339+
assertThat(preview).contains("name=\"field1\"")
340+
assertThat(preview).contains("value1")
341+
assertThat(preview).contains("name=\"field2\"")
342+
assertThat(preview).contains("value2")
343+
assertThat(preview).doesNotContain("[Preview unavailable]")
344+
}
345+
346+
@Test
347+
fun testGetRequestBodyPreviewMultipartWithFilePartReplacesBinaryContent() {
348+
val fileBytes = ByteArray(2048) { it.toByte() }
349+
val streamingPart =
350+
RequestBodyUtil.create(MediaType.parse("application/octet-stream"), ByteArrayInputStream(fileBytes))
351+
val body =
352+
MultipartBody.Builder("test-boundary")
353+
.setType(MultipartBody.FORM)
354+
.addFormDataPart("description", "an image")
355+
.addFormDataPart("file", "photo.jpg", streamingPart)
356+
.build()
357+
358+
val preview = NetworkEventUtil.getRequestBodyPreview(body)
359+
360+
assertThat(preview).isNotNull()
361+
assertThat(preview).contains("--test-boundary")
362+
assertThat(preview).contains("name=\"description\"")
363+
assertThat(preview).contains("an image")
364+
assertThat(preview).contains("name=\"file\"")
365+
assertThat(preview).contains("filename=\"photo.jpg\"")
366+
assertThat(preview).contains("[Binary data, 2048 bytes]")
367+
assertThat(preview).doesNotContain("[Preview unavailable]")
368+
}
369+
370+
@Test
371+
fun testGetRequestBodyPreviewSingleOneShotBodyShowsPlaceholder() {
372+
val fileBytes = ByteArray(512) { it.toByte() }
373+
val body =
374+
RequestBodyUtil.create(MediaType.parse("application/octet-stream"), ByteArrayInputStream(fileBytes))
375+
376+
val preview = NetworkEventUtil.getRequestBodyPreview(body)
377+
378+
assertThat(preview).isEqualTo("[Binary data, 512 bytes]")
379+
}
380+
295381
@Test
296382
fun testNullReactContext() {
297383
val url = "http://example.com"

0 commit comments

Comments
 (0)