Skip to content

Commit 48fe6df

Browse files
HarshitMadhavmeta-codesync[bot]
authored andcommitted
Fix (android): show payload preview for FormData and one-shot uploads in DevTools (#55764) (#56742)
Summary: Follow-up to #56406. After that PR landed, NetworkEventUtil.getRequestBodyPreview still returns the literal string "[Preview unavailable]" in two cases: The top-level RequestBody.isOneShot() is true (e.g. a single URI/file upload). The body is a MultipartBody whose parts include any one-shot stream (the common case for FormData with files on Android). Both branches exist for a good reason — calling writeTo() on a one-shot stream would drain it and break the real upload. This PR keeps that invariant but replaces the two return sites with structured previews: Single one-shot body → [Binary data, N bytes] (or [Binary data] if contentLength() is unknown). MultipartBody with one-shot parts → a multipart envelope using the request's actual boundary, with text parts inlined and one-shot parts replaced by [Binary data, N bytes]. The result parses cleanly against the on-the-wire Content-Type: multipart/form-data; boundary=..., so DevTools renders it the same way it renders any other multipart payload. Net result: issue #55764 (no payload preview for FormData / file uploads in the React Native DevTools Network tab) is closed, with no change to what gets sent on the wire. ## Changelog: NetworkEventUtil.kt: replace the two "[Preview unavailable]" returns with binaryPartLabel(body) and a new previewMultipartWithBinaryParts(body) helper. No change to the existing all-text path. NetworkEventUtilTest.kt: 6 new tests covering null body, plain string body, ProgressRequestBody unwrapping, multipart with text parts, multipart with a file part, and a single one-shot body. [ANDROID] [FIXED] - Show request body preview for FormData and file uploads in DevTools Network tab Pull Request resolved: #56742 Test Plan: 1) `` ./gradlew :packages:react-native:ReactAndroid:testDebugUnitTest --tests "com.facebook.react.modules.network.*"`` — 16/16 pass in NetworkEventUtilTest, full network suite green. 2) Manual: in RN-Tester debug build, POST a FormData with a file using fetch → DevTools Network → Payload tab now shows the multipart envelope with [Binary data, N bytes] instead of [Preview unavailable]. 3) Manual: POST a single-file RequestBody from a URI → preview now shows [Binary data, N bytes]. Stream safety The whole point of #56406 bail-outs was to avoid draining one-shot streams. This PR preserves that: it never calls writeTo() on a body whose isOneShot() is true. The only RequestBody methods invoked on one-shot bodies are contentType() and contentLength(), both of which are pure getters per the OkHttp docs. Refs #55764, follows up on #56406. Reviewed By: javache Differential Revision: D105997964 Pulled By: huntie fbshipit-source-id: e1ac0091cd167d8118bd0952b5bd9829f9ec85a5
1 parent 09fc043 commit 48fe6df

2 files changed

Lines changed: 176 additions & 3 deletions

File tree

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

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -260,15 +260,16 @@ internal object NetworkEventUtil {
260260
val body = (requestBody as? ProgressRequestBody)?.innerBody() ?: requestBody
261261

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

267268
// MultipartBody does not propagate isOneShot() from its parts, so check each
268269
// part explicitly. Reading a one-shot part here would drain the underlying
269270
// stream and cause the real request to fail.
270271
if (body is MultipartBody && body.parts().any { it.body().isOneShot() }) {
271-
return "[Preview unavailable]"
272+
return previewMultipartWithBinaryParts(body)
272273
}
273274

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

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

Lines changed: 123 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,11 @@ 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.MultipartBody
24+
import okhttp3.RequestBody
1925
import org.assertj.core.api.Assertions.assertThat
2026
import org.junit.After
2127
import org.junit.Before
@@ -292,6 +298,123 @@ class NetworkEventUtilTest {
292298
assertThat(args.getString(3)).isEqualTo(url)
293299
}
294300

301+
@Test
302+
fun testGetRequestBodyPreviewReturnsNullForNullBody() {
303+
assertThat(NetworkEventUtil.getRequestBodyPreview(null)).isNull()
304+
}
305+
306+
@Test
307+
fun testGetRequestBodyPreviewReturnsBodyForStringRequest() {
308+
val payload = """{"key":"value"}"""
309+
val body = RequestBody.create(MediaType.parse("application/json"), payload)
310+
311+
assertThat(NetworkEventUtil.getRequestBodyPreview(body)).isEqualTo(payload)
312+
}
313+
314+
@Test
315+
fun testGetRequestBodyPreviewUnwrapsProgressRequestBody() {
316+
val payload = "hello world"
317+
val inner = RequestBody.create(MediaType.parse("text/plain"), payload)
318+
val wrapped = ProgressRequestBody(inner) { _, _, _ -> }
319+
320+
assertThat(NetworkEventUtil.getRequestBodyPreview(wrapped)).isEqualTo(payload)
321+
}
322+
323+
@Test
324+
fun testGetRequestBodyPreviewMultipartWithTextParts() {
325+
val body =
326+
MultipartBody.Builder("test-boundary")
327+
.setType(MultipartBody.FORM)
328+
.addFormDataPart("field1", "value1")
329+
.addFormDataPart("field2", "value2")
330+
.build()
331+
332+
val preview = NetworkEventUtil.getRequestBodyPreview(body)
333+
334+
assertThat(preview).isNotNull()
335+
assertThat(preview).contains("--test-boundary")
336+
assertThat(preview).contains("--test-boundary--")
337+
assertThat(preview).contains("name=\"field1\"")
338+
assertThat(preview).contains("value1")
339+
assertThat(preview).contains("name=\"field2\"")
340+
assertThat(preview).contains("value2")
341+
assertThat(preview).doesNotContain("[Preview unavailable]")
342+
}
343+
344+
@Test
345+
fun testGetRequestBodyPreviewMultipartWithFilePartReplacesBinaryContent() {
346+
val fileBytes = ByteArray(2048) { it.toByte() }
347+
val streamingPart =
348+
RequestBodyUtil.create(
349+
MediaType.parse("application/octet-stream"),
350+
ByteArrayInputStream(fileBytes),
351+
)
352+
val body =
353+
MultipartBody.Builder("test-boundary")
354+
.setType(MultipartBody.FORM)
355+
.addFormDataPart("description", "an image")
356+
.addFormDataPart("file", "photo.jpg", streamingPart)
357+
.build()
358+
359+
val preview = NetworkEventUtil.getRequestBodyPreview(body)
360+
361+
assertThat(preview).isNotNull()
362+
assertThat(preview).contains("--test-boundary")
363+
assertThat(preview).contains("name=\"description\"")
364+
assertThat(preview).contains("an image")
365+
assertThat(preview).contains("name=\"file\"")
366+
assertThat(preview).contains("filename=\"photo.jpg\"")
367+
assertThat(preview).contains("[Binary data, 2048 bytes]")
368+
assertThat(preview).doesNotContain("[Preview unavailable]")
369+
}
370+
371+
@Test
372+
fun testGetRequestBodyPreviewSingleOneShotBodyShowsPlaceholder() {
373+
val fileBytes = ByteArray(512) { it.toByte() }
374+
val body =
375+
RequestBodyUtil.create(
376+
MediaType.parse("application/octet-stream"),
377+
ByteArrayInputStream(fileBytes),
378+
)
379+
380+
val preview = NetworkEventUtil.getRequestBodyPreview(body)
381+
382+
assertThat(preview).isEqualTo("[Binary data, 512 bytes]")
383+
}
384+
385+
@Test
386+
fun testGetRequestBodyPreviewDoesNotConsumeMultipartOneShotStream() {
387+
// Regression guard: previewMultipartWithBinaryParts must not call writeTo() on a
388+
// one-shot part. If it ever does, the underlying stream will be drained and the
389+
// real upload will fail at request time.
390+
val fileBytes = ByteArray(2048) { it.toByte() }
391+
val stream = ByteArrayInputStream(fileBytes)
392+
val streamingPart = RequestBodyUtil.create(MediaType.parse("application/octet-stream"), stream)
393+
val body =
394+
MultipartBody.Builder("test-boundary")
395+
.setType(MultipartBody.FORM)
396+
.addFormDataPart("description", "an image")
397+
.addFormDataPart("file", "photo.jpg", streamingPart)
398+
.build()
399+
400+
NetworkEventUtil.getRequestBodyPreview(body)
401+
402+
assertThat(stream.available()).isEqualTo(fileBytes.size)
403+
}
404+
405+
@Test
406+
fun testGetRequestBodyPreviewDoesNotConsumeSingleOneShotStream() {
407+
// Regression guard for the top-level one-shot branch: getRequestBodyPreview must
408+
// only read contentLength() / contentType() on a one-shot body, never writeTo().
409+
val fileBytes = ByteArray(512) { it.toByte() }
410+
val stream = ByteArrayInputStream(fileBytes)
411+
val body = RequestBodyUtil.create(MediaType.parse("application/octet-stream"), stream)
412+
413+
NetworkEventUtil.getRequestBodyPreview(body)
414+
415+
assertThat(stream.available()).isEqualTo(fileBytes.size)
416+
}
417+
295418
@Test
296419
fun testNullReactContext() {
297420
val url = "http://example.com"

0 commit comments

Comments
 (0)