From 034d9ba3dcb2e93b6da3c0a5f5567e11ae33be8a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 20:08:25 +0000 Subject: [PATCH 1/3] feat: copy full request/response from network detail screen (#256) Adds a "Copy All" action to the network request detail screen's TopAppBar, alongside the existing per-section copy buttons. Copies method, URL, status, timing, both header sets, and both bodies (JSON pretty-printed when detected) as a single plain-text block via NetworkRequest.toClipboardText(). Resolves item 2 of #256. --- .../internal/ui/NetworkRequestDetailScreen.kt | 10 ++ .../internal/util/NetworkRequestFormatting.kt | 53 +++++++ .../src/main/res/values/strings.xml | 1 + .../util/NetworkRequestFormattingTest.kt | 139 ++++++++++++++++++ 4 files changed, 203 insertions(+) create mode 100644 debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/util/NetworkRequestFormatting.kt create mode 100644 debugoverlay-core/src/test/kotlin/com/ms/square/debugoverlay/internal/util/NetworkRequestFormattingTest.kt diff --git a/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/ui/NetworkRequestDetailScreen.kt b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/ui/NetworkRequestDetailScreen.kt index 4ac286a0..562ff3f7 100644 --- a/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/ui/NetworkRequestDetailScreen.kt +++ b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/ui/NetworkRequestDetailScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.CopyAll import androidx.compose.material.icons.filled.Error import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Info @@ -56,6 +57,7 @@ import com.ms.square.debugoverlay.internal.util.formatBytes import com.ms.square.debugoverlay.internal.util.formatTimestamp import com.ms.square.debugoverlay.internal.util.httpStatusColor import com.ms.square.debugoverlay.internal.util.httpStatusMessage +import com.ms.square.debugoverlay.internal.util.toClipboardText import com.ms.square.debugoverlay.model.NetworkRequest /** @@ -104,6 +106,14 @@ internal fun NetworkRequestDetailScreen(request: NetworkRequest, onBack: () -> U contentDescription = stringResource(R.string.debugoverlay_copy) ) } + IconButton(onClick = { + scope.copyToClipboard(clipboard, request.toClipboardText()) + }) { + Icon( + imageVector = Icons.Default.CopyAll, + contentDescription = stringResource(R.string.debugoverlay_copy_all) + ) + } }, colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.surfaceContainer diff --git a/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/util/NetworkRequestFormatting.kt b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/util/NetworkRequestFormatting.kt new file mode 100644 index 00000000..97baefb6 --- /dev/null +++ b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/util/NetworkRequestFormatting.kt @@ -0,0 +1,53 @@ +package com.ms.square.debugoverlay.internal.util + +import com.ms.square.debugoverlay.internal.data.TextType +import com.ms.square.debugoverlay.model.NetworkError +import com.ms.square.debugoverlay.model.NetworkRequest + +/** + * Format the full request/response transaction as plain text for clipboard copy. + * Mirrors the Overview/Headers/Body sections shown on the network request detail screen, + * omitting any section that has nothing to show. + */ +internal fun NetworkRequest.toClipboardText(): String = buildString { + appendLine("$method $url") + appendLine("Status: ${statusCode?.let { "$it ${it.httpStatusMessage}" } ?: "Error"}") + appendLine("Duration: $durationMs ms") + appendLine("Timestamp: ${formatTimestamp(timestampMs)}") + appendLine("Request Size: ${formatBytes(requestSize)}") + append("Response Size: ${formatBytes(responseSize)}") + + appendHeadersSection("Request Headers", requestHeaders) + appendBodySection("Request Body", requestBody, requestHeaders["content-type"]) + + if (error != null) { + appendErrorSection(error) + } else { + appendHeadersSection("Response Headers", responseHeaders) + appendBodySection("Response Body", responseBody, responseHeaders["content-type"]) + } +} + +private fun StringBuilder.appendHeadersSection(title: String, headers: Map) { + if (headers.isEmpty()) return + append("\n\n--- $title ---\n") + append(headers.entries.joinToString("\n") { (name, value) -> "$name: $value" }) +} + +private fun StringBuilder.appendBodySection(title: String, body: String?, contentType: String?) { + if (body.isNullOrEmpty()) return + append("\n\n--- $title ---\n") + val formatted = if (TextType.from(body, contentType) == TextType.JSON) formatJsonIfPossible(body) else body + append(formatted) +} + +private fun StringBuilder.appendErrorSection(error: NetworkError) { + append("\n\n--- Error ---\n") + append(error.title) + append('\n') + append(error.message) + error.stackTrace?.let { + append('\n') + append(it) + } +} diff --git a/debugoverlay-core/src/main/res/values/strings.xml b/debugoverlay-core/src/main/res/values/strings.xml index 253299fd..dd66783a 100644 --- a/debugoverlay-core/src/main/res/values/strings.xml +++ b/debugoverlay-core/src/main/res/values/strings.xml @@ -19,6 +19,7 @@ Back Copy + Copy all Refresh Clear search View details diff --git a/debugoverlay-core/src/test/kotlin/com/ms/square/debugoverlay/internal/util/NetworkRequestFormattingTest.kt b/debugoverlay-core/src/test/kotlin/com/ms/square/debugoverlay/internal/util/NetworkRequestFormattingTest.kt new file mode 100644 index 00000000..5c0d52ac --- /dev/null +++ b/debugoverlay-core/src/test/kotlin/com/ms/square/debugoverlay/internal/util/NetworkRequestFormattingTest.kt @@ -0,0 +1,139 @@ +package com.ms.square.debugoverlay.internal.util + +import com.google.common.truth.Truth.assertThat +import com.ms.square.debugoverlay.model.NetworkError +import com.ms.square.debugoverlay.model.NetworkRequest +import org.junit.Test + +class NetworkRequestFormattingTest { + + @Test + fun `toClipboardText includes all sections for a full request and response`() { + val request = NetworkRequest( + protocol = "h2", + method = "POST", + url = "https://api.example.com/v1/users", + statusCode = 200, + durationMs = 245, + responseSize = 128, + requestSize = 42, + timestampMs = 1_700_000_000_000, + requestHeaders = mapOf("content-type" to "application/json"), + responseHeaders = mapOf("content-type" to "application/json"), + requestBody = """{"name":"test"}""", + responseBody = """{"result":"ok"}""" + ) + + val result = request.toClipboardText() + + assertThat(result).isEqualTo( + """ + POST https://api.example.com/v1/users + Status: 200 OK + Duration: 245 ms + Timestamp: ${formatTimestamp(request.timestampMs)} + Request Size: 42 B + Response Size: 128 B + + --- Request Headers --- + content-type: application/json + + --- Request Body --- + { + "name": "test" + } + + --- Response Headers --- + content-type: application/json + + --- Response Body --- + { + "result": "ok" + } + """.trimIndent() + ) + } + + @Test + fun `toClipboardText omits headers and body sections when absent`() { + val request = NetworkRequest( + protocol = "http/1.1", + method = "GET", + url = "https://api.example.com/v1/ping", + statusCode = 204, + durationMs = 12, + responseSize = 0, + requestSize = 0, + timestampMs = 1_700_000_000_000 + ) + + val result = request.toClipboardText() + + assertThat(result).isEqualTo( + """ + GET https://api.example.com/v1/ping + Status: 204 No Content + Duration: 12 ms + Timestamp: ${formatTimestamp(request.timestampMs)} + Request Size: 0 B + Response Size: 0 B + """.trimIndent() + ) + } + + @Test + fun `toClipboardText leaves non-JSON body unformatted`() { + val request = NetworkRequest( + protocol = "http/1.1", + method = "GET", + url = "https://api.example.com/v1/text", + statusCode = 200, + durationMs = 5, + responseSize = 11, + requestSize = 0, + timestampMs = 1_700_000_000_000, + responseBody = "plain text" + ) + + val result = request.toClipboardText() + + assertThat(result).contains("--- Response Body ---\nplain text") + } + + @Test + fun `toClipboardText shows error section instead of response headers and body`() { + val request = NetworkRequest( + protocol = "http/1.1", + method = "GET", + url = "https://api.example.com/v1/fail", + statusCode = null, + durationMs = 1000, + responseSize = null, + requestSize = 0, + timestampMs = 1_700_000_000_000, + error = NetworkError( + title = "Connection failed", + message = "Unable to resolve host", + stackTrace = "java.net.UnknownHostException: api.example.com" + ) + ) + + val result = request.toClipboardText() + + assertThat(result).isEqualTo( + """ + GET https://api.example.com/v1/fail + Status: Error + Duration: 1000 ms + Timestamp: ${formatTimestamp(request.timestampMs)} + Request Size: 0 B + Response Size: — + + --- Error --- + Connection failed + Unable to resolve host + java.net.UnknownHostException: api.example.com + """.trimIndent() + ) + } +} From 7d80fc38f9059423facf44833d66e1088a1d3f30 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 02:51:26 +0000 Subject: [PATCH 2/3] fix: preserve response data on HTTP errors, fix CI LongMethod, address review feedback (#268) - toClipboardText() no longer drops response headers/body when error is set: the OkHttp interceptor populates error for any HTTP status >= 400 while still capturing the response, so a 404/500 - the case most worth sharing - was silently exporting no response data. - Content-Type header lookups are now case-insensitive (shared Map.contentType() helper, also adopted by HtmlReportBuilder and the three pre-existing exact-match lookups in NetworkRequestDetailScreen), since HTTP/1.1 typically capitalizes the header while HTTP/2 lowercases it. - Cap each body at 64KB when building clipboard text, to bound a single ClipData and avoid TransactionTooLargeException on large bodies. - Build the clipboard text off the main thread (new suspend-producer overload of copyToClipboard) instead of synchronously in the onClick handler. - Exported timestamp now includes the date (formatClipboardTimestamp, shared with LogEntry's clipboard export) instead of a bare wall-clock time. - Renamed the URL-only top-bar button's content description to "Copy URL" so it reads distinctly next to "Copy all". - Extracted the top bar's actions into NetworkRequestDetailActions, fixing the detekt LongMethod failure on NetworkRequestDetailScreen (62 lines, threshold 60) that broke CI. - Added test coverage for the HTTP-error-with-response-data case, capitalized Content-Type detection, and body truncation. --- .../internal/bugreport/HtmlReportBuilder.kt | 4 +- .../internal/ui/NetworkRequestDetailScreen.kt | 49 +++++---- .../debugoverlay/internal/util/Clipboards.kt | 15 +++ .../debugoverlay/internal/util/Headers.kt | 9 ++ .../internal/util/NetworkRequestFormatting.kt | 34 ++++-- .../internal/util/TimeFormatters.kt | 2 +- .../src/main/res/values/strings.xml | 1 + .../util/NetworkRequestFormattingTest.kt | 100 +++++++++++++++++- 8 files changed, 177 insertions(+), 37 deletions(-) create mode 100644 debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/util/Headers.kt diff --git a/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/bugreport/HtmlReportBuilder.kt b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/bugreport/HtmlReportBuilder.kt index 2b03787d..bb0e5981 100644 --- a/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/bugreport/HtmlReportBuilder.kt +++ b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/bugreport/HtmlReportBuilder.kt @@ -12,6 +12,7 @@ import com.ms.square.debugoverlay.internal.data.model.DeviceInfo import com.ms.square.debugoverlay.internal.data.model.JankStatsUiState import com.ms.square.debugoverlay.internal.util.HTTP_SUCCESS_END import com.ms.square.debugoverlay.internal.util.HTTP_SUCCESS_START +import com.ms.square.debugoverlay.internal.util.contentType import com.ms.square.debugoverlay.internal.util.escapeHtml import com.ms.square.debugoverlay.internal.util.formatBytes import com.ms.square.debugoverlay.internal.util.formatBytesFromKb @@ -659,9 +660,6 @@ internal object HtmlReportBuilder { } } - private fun Map.contentType(): String? = - entries.firstOrNull { it.key.equals("content-type", ignoreCase = true) }?.value - /** * Renders a request/response body, pretty-printing it first if it is JSON. * diff --git a/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/ui/NetworkRequestDetailScreen.kt b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/ui/NetworkRequestDetailScreen.kt index 562ff3f7..fbfa043b 100644 --- a/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/ui/NetworkRequestDetailScreen.kt +++ b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/ui/NetworkRequestDetailScreen.kt @@ -43,6 +43,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.Clipboard import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily @@ -52,6 +53,7 @@ import androidx.compose.ui.unit.dp import com.ms.square.debugoverlay.core.R import com.ms.square.debugoverlay.internal.data.TextType import com.ms.square.debugoverlay.internal.data.UrlParts +import com.ms.square.debugoverlay.internal.util.contentType import com.ms.square.debugoverlay.internal.util.copyToClipboard import com.ms.square.debugoverlay.internal.util.formatBytes import com.ms.square.debugoverlay.internal.util.formatTimestamp @@ -59,6 +61,7 @@ import com.ms.square.debugoverlay.internal.util.httpStatusColor import com.ms.square.debugoverlay.internal.util.httpStatusMessage import com.ms.square.debugoverlay.internal.util.toClipboardText import com.ms.square.debugoverlay.model.NetworkRequest +import kotlinx.coroutines.CoroutineScope /** * Network request detail screen with TopAppBar and comprehensive information. @@ -98,22 +101,7 @@ internal fun NetworkRequestDetailScreen(request: NetworkRequest, onBack: () -> U BackButton(onClick = onBack) }, actions = { - IconButton(onClick = { - scope.copyToClipboard(clipboard, request.url) - }) { - Icon( - imageVector = Icons.Default.ContentCopy, - contentDescription = stringResource(R.string.debugoverlay_copy) - ) - } - IconButton(onClick = { - scope.copyToClipboard(clipboard, request.toClipboardText()) - }) { - Icon( - imageVector = Icons.Default.CopyAll, - contentDescription = stringResource(R.string.debugoverlay_copy_all) - ) - } + NetworkRequestDetailActions(request = request, clipboard = clipboard, scope = scope) }, colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.surfaceContainer @@ -129,6 +117,29 @@ internal fun NetworkRequestDetailScreen(request: NetworkRequest, onBack: () -> U } } +/** + * TopAppBar actions: copy the URL alone, or the full request/response transaction. + */ +@Composable +private fun NetworkRequestDetailActions(request: NetworkRequest, clipboard: Clipboard, scope: CoroutineScope) { + IconButton(onClick = { + scope.copyToClipboard(clipboard, request.url) + }) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = stringResource(R.string.debugoverlay_copy_url) + ) + } + IconButton(onClick = { + scope.copyToClipboard(clipboard) { request.toClipboardText() } + }) { + Icon( + imageVector = Icons.Default.CopyAll, + contentDescription = stringResource(R.string.debugoverlay_copy_all) + ) + } +} + @Composable private fun NetworkRequestDetailContent(request: NetworkRequest, urlParts: UrlParts, modifier: Modifier = Modifier) { var selectedTab by remember { mutableIntStateOf(0) } @@ -224,7 +235,7 @@ private fun OverviewTab(request: NetworkRequest, urlParts: UrlParts, modifier: M DetailSection(title = "Response Summary") { InfoCard { var itemCount = 0 - request.responseHeaders["content-type"]?.let { + request.responseHeaders.contentType()?.let { InfoRow("Content-Type", it) itemCount++ } @@ -354,7 +365,7 @@ private fun BodyTab(request: NetworkRequest, modifier: Modifier = Modifier) { if (request.requestBody != null) { BodyPreview( body = request.requestBody, - contentType = request.requestHeaders["content-type"] + contentType = request.requestHeaders.contentType() ) } else { EmptyState(text = stringResource(R.string.debugoverlay_network_no_request_body)) @@ -375,7 +386,7 @@ private fun BodyTab(request: NetworkRequest, modifier: Modifier = Modifier) { if (request.responseBody != null) { BodyPreview( body = request.responseBody, - contentType = request.responseHeaders["content-type"] + contentType = request.responseHeaders.contentType() ) } else { EmptyState(text = stringResource(R.string.debugoverlay_network_no_response_body)) diff --git a/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/util/Clipboards.kt b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/util/Clipboards.kt index c9d84552..0c25481a 100644 --- a/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/util/Clipboards.kt +++ b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/util/Clipboards.kt @@ -4,7 +4,9 @@ import android.content.ClipData import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.Clipboard import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext internal fun CoroutineScope.copyToClipboard(clipboard: Clipboard, text: String, label: String = "") { launch { @@ -12,3 +14,16 @@ internal fun CoroutineScope.copyToClipboard(clipboard: Clipboard, text: String, clipboard.setClipEntry(clipEntry) } } + +/** + * Builds the clipboard text off the main thread before copying. Use this overload when [text] + * is expensive enough to risk jank (e.g. formatting an entire network transaction), instead of + * computing it inline at the call site. + */ +internal fun CoroutineScope.copyToClipboard(clipboard: Clipboard, label: String = "", text: suspend () -> String) { + launch { + val resolved = withContext(Dispatchers.Default) { text() } + val clipEntry = ClipEntry(ClipData.newPlainText(label, resolved)) + clipboard.setClipEntry(clipEntry) + } +} diff --git a/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/util/Headers.kt b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/util/Headers.kt new file mode 100644 index 00000000..33650001 --- /dev/null +++ b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/util/Headers.kt @@ -0,0 +1,9 @@ +package com.ms.square.debugoverlay.internal.util + +/** + * Case-insensitive content-type lookup. Headers are captured verbatim off the wire, so casing + * varies by protocol (HTTP/2 lowercases header names, HTTP/1.1 typically capitalizes them); + * an exact-key map lookup silently misses the latter. + */ +internal fun Map.contentType(): String? = + entries.firstOrNull { it.key.equals("content-type", ignoreCase = true) }?.value diff --git a/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/util/NetworkRequestFormatting.kt b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/util/NetworkRequestFormatting.kt index 97baefb6..d7b44d42 100644 --- a/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/util/NetworkRequestFormatting.kt +++ b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/util/NetworkRequestFormatting.kt @@ -7,25 +7,24 @@ import com.ms.square.debugoverlay.model.NetworkRequest /** * Format the full request/response transaction as plain text for clipboard copy. * Mirrors the Overview/Headers/Body sections shown on the network request detail screen, - * omitting any section that has nothing to show. + * omitting any section that has nothing to show. Response headers/body are included whenever + * captured, even alongside an error section - the OkHttp extension populates [NetworkRequest]'s + * `error` for any HTTP status of 400 or above while still capturing the response. */ internal fun NetworkRequest.toClipboardText(): String = buildString { appendLine("$method $url") appendLine("Status: ${statusCode?.let { "$it ${it.httpStatusMessage}" } ?: "Error"}") appendLine("Duration: $durationMs ms") - appendLine("Timestamp: ${formatTimestamp(timestampMs)}") + appendLine("Timestamp: ${formatClipboardTimestamp(timestampMs)}") appendLine("Request Size: ${formatBytes(requestSize)}") append("Response Size: ${formatBytes(responseSize)}") appendHeadersSection("Request Headers", requestHeaders) - appendBodySection("Request Body", requestBody, requestHeaders["content-type"]) + appendBodySection("Request Body", requestBody, requestHeaders.contentType()) - if (error != null) { - appendErrorSection(error) - } else { - appendHeadersSection("Response Headers", responseHeaders) - appendBodySection("Response Body", responseBody, responseHeaders["content-type"]) - } + error?.let { appendErrorSection(it) } + appendHeadersSection("Response Headers", responseHeaders) + appendBodySection("Response Body", responseBody, responseHeaders.contentType()) } private fun StringBuilder.appendHeadersSection(title: String, headers: Map) { @@ -38,7 +37,7 @@ private fun StringBuilder.appendBodySection(title: String, body: String?, conten if (body.isNullOrEmpty()) return append("\n\n--- $title ---\n") val formatted = if (TextType.from(body, contentType) == TextType.JSON) formatJsonIfPossible(body) else body - append(formatted) + append(formatted.truncateForClipboard()) } private fun StringBuilder.appendErrorSection(error: NetworkError) { @@ -51,3 +50,18 @@ private fun StringBuilder.appendErrorSection(error: NetworkError) { append(it) } } + +/** + * Caps a single body at [MAX_CLIPBOARD_BODY_LENGTH]. The OkHttp extension allows bodies up to + * 2MB each by default; concatenating an uncapped request and response body into one ClipData + * risks TransactionTooLargeException when it crosses the clipboard's Binder call. + */ +private fun String.truncateForClipboard(): String = if (length <= MAX_CLIPBOARD_BODY_LENGTH) { + this +} else { + val shownSize = formatBytes(MAX_CLIPBOARD_BODY_LENGTH.toLong()) + val totalSize = formatBytes(length.toLong()) + "${take(MAX_CLIPBOARD_BODY_LENGTH)}...\n\n[truncated: showing $shownSize of $totalSize]" +} + +private const val MAX_CLIPBOARD_BODY_LENGTH = 64 * 1024 diff --git a/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/util/TimeFormatters.kt b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/util/TimeFormatters.kt index 68e0c6a5..a141863f 100644 --- a/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/util/TimeFormatters.kt +++ b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/util/TimeFormatters.kt @@ -22,7 +22,7 @@ internal fun formatTimestamp(timestamp: Long): String = /** * Format timestamp for clipboard copy (e.g., "12-11 14:35:22.786"). */ -private fun formatClipboardTimestamp(timestamp: Long): String = +internal fun formatClipboardTimestamp(timestamp: Long): String = SimpleDateFormat("MM-dd HH:mm:ss.SSS", Locale.US).format(Date(timestamp)) /** diff --git a/debugoverlay-core/src/main/res/values/strings.xml b/debugoverlay-core/src/main/res/values/strings.xml index dd66783a..707de8f9 100644 --- a/debugoverlay-core/src/main/res/values/strings.xml +++ b/debugoverlay-core/src/main/res/values/strings.xml @@ -19,6 +19,7 @@ Back Copy + Copy URL Copy all Refresh Clear search diff --git a/debugoverlay-core/src/test/kotlin/com/ms/square/debugoverlay/internal/util/NetworkRequestFormattingTest.kt b/debugoverlay-core/src/test/kotlin/com/ms/square/debugoverlay/internal/util/NetworkRequestFormattingTest.kt index 5c0d52ac..81556c87 100644 --- a/debugoverlay-core/src/test/kotlin/com/ms/square/debugoverlay/internal/util/NetworkRequestFormattingTest.kt +++ b/debugoverlay-core/src/test/kotlin/com/ms/square/debugoverlay/internal/util/NetworkRequestFormattingTest.kt @@ -31,7 +31,7 @@ class NetworkRequestFormattingTest { POST https://api.example.com/v1/users Status: 200 OK Duration: 245 ms - Timestamp: ${formatTimestamp(request.timestampMs)} + Timestamp: ${formatClipboardTimestamp(request.timestampMs)} Request Size: 42 B Response Size: 128 B @@ -74,7 +74,7 @@ class NetworkRequestFormattingTest { GET https://api.example.com/v1/ping Status: 204 No Content Duration: 12 ms - Timestamp: ${formatTimestamp(request.timestampMs)} + Timestamp: ${formatClipboardTimestamp(request.timestampMs)} Request Size: 0 B Response Size: 0 B """.trimIndent() @@ -101,7 +101,55 @@ class NetworkRequestFormattingTest { } @Test - fun `toClipboardText shows error section instead of response headers and body`() { + fun `toClipboardText detects JSON via a capitalized Content-Type header`() { + val request = NetworkRequest( + protocol = "http/1.1", + method = "GET", + url = "https://api.example.com/v1/data", + statusCode = 200, + durationMs = 10, + responseSize = 20, + requestSize = 0, + timestampMs = 1_700_000_000_000, + responseHeaders = mapOf("Content-Type" to "application/json"), + responseBody = """{"a":1}""" + ) + + val result = request.toClipboardText() + + assertThat(result).contains( + """ + --- Response Body --- + { + "a": 1 + } + """.trimIndent() + ) + } + + @Test + fun `toClipboardText truncates a body larger than the clipboard cap`() { + val hugeBody = "a".repeat(HUGE_BODY_LENGTH) + val request = NetworkRequest( + protocol = "http/1.1", + method = "GET", + url = "https://api.example.com/v1/large", + statusCode = 200, + durationMs = 10, + responseSize = HUGE_BODY_LENGTH.toLong(), + requestSize = 0, + timestampMs = 1_700_000_000_000, + responseBody = hugeBody + ) + + val result = request.toClipboardText() + + assertThat(result).contains("[truncated: showing 64.0 KB of") + assertThat(result).doesNotContain(hugeBody) + } + + @Test + fun `toClipboardText shows error section for a transport failure with no response data`() { val request = NetworkRequest( protocol = "http/1.1", method = "GET", @@ -125,7 +173,7 @@ class NetworkRequestFormattingTest { GET https://api.example.com/v1/fail Status: Error Duration: 1000 ms - Timestamp: ${formatTimestamp(request.timestampMs)} + Timestamp: ${formatClipboardTimestamp(request.timestampMs)} Request Size: 0 B Response Size: — @@ -136,4 +184,48 @@ class NetworkRequestFormattingTest { """.trimIndent() ) } + + @Test + fun `toClipboardText includes both error and response sections for an HTTP error status`() { + val request = NetworkRequest( + protocol = "http/1.1", + method = "GET", + url = "https://api.example.com/v1/resource", + statusCode = 404, + durationMs = 80, + responseSize = 42, + requestSize = 0, + timestampMs = 1_700_000_000_000, + responseHeaders = mapOf("content-type" to "application/json"), + responseBody = """{"error":"not found"}""", + error = NetworkError(title = "HTTP 404", message = "Not Found") + ) + + val result = request.toClipboardText() + + assertThat(result).isEqualTo( + """ + GET https://api.example.com/v1/resource + Status: 404 Not Found + Duration: 80 ms + Timestamp: ${formatClipboardTimestamp(request.timestampMs)} + Request Size: 0 B + Response Size: 42 B + + --- Error --- + HTTP 404 + Not Found + + --- Response Headers --- + content-type: application/json + + --- Response Body --- + { + "error": "not found" + } + """.trimIndent() + ) + } } + +private const val HUGE_BODY_LENGTH = 70_000 From ce8cd997dc09f1351a2d037df27c2bb100769a1e Mon Sep 17 00:00:00 2001 From: Manabu-GT Date: Thu, 6 Aug 2026 14:31:23 -0700 Subject: [PATCH 3/3] manual tweaks --- .../internal/ui/NetworkRequestDetailScreen.kt | 12 ++++----- .../debugoverlay/internal/util/Clipboards.kt | 15 ----------- .../internal/util/NetworkRequestFormatting.kt | 26 ++++++++++++------- .../util/NetworkRequestFormattingTest.kt | 11 ++++---- 4 files changed, 27 insertions(+), 37 deletions(-) diff --git a/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/ui/NetworkRequestDetailScreen.kt b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/ui/NetworkRequestDetailScreen.kt index fbfa043b..5d942f7b 100644 --- a/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/ui/NetworkRequestDetailScreen.kt +++ b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/ui/NetworkRequestDetailScreen.kt @@ -43,7 +43,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.Clipboard import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily @@ -61,7 +60,6 @@ import com.ms.square.debugoverlay.internal.util.httpStatusColor import com.ms.square.debugoverlay.internal.util.httpStatusMessage import com.ms.square.debugoverlay.internal.util.toClipboardText import com.ms.square.debugoverlay.model.NetworkRequest -import kotlinx.coroutines.CoroutineScope /** * Network request detail screen with TopAppBar and comprehensive information. @@ -69,8 +67,6 @@ import kotlinx.coroutines.CoroutineScope @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun NetworkRequestDetailScreen(request: NetworkRequest, onBack: () -> Unit, modifier: Modifier = Modifier) { - val clipboard = LocalClipboard.current - val scope = rememberCoroutineScope() val urlParts = remember(request.url) { UrlParts.from(request.url) } Scaffold( @@ -101,7 +97,7 @@ internal fun NetworkRequestDetailScreen(request: NetworkRequest, onBack: () -> U BackButton(onClick = onBack) }, actions = { - NetworkRequestDetailActions(request = request, clipboard = clipboard, scope = scope) + NetworkRequestDetailActions(request) }, colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.surfaceContainer @@ -121,7 +117,9 @@ internal fun NetworkRequestDetailScreen(request: NetworkRequest, onBack: () -> U * TopAppBar actions: copy the URL alone, or the full request/response transaction. */ @Composable -private fun NetworkRequestDetailActions(request: NetworkRequest, clipboard: Clipboard, scope: CoroutineScope) { +private fun NetworkRequestDetailActions(request: NetworkRequest) { + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() IconButton(onClick = { scope.copyToClipboard(clipboard, request.url) }) { @@ -131,7 +129,7 @@ private fun NetworkRequestDetailActions(request: NetworkRequest, clipboard: Clip ) } IconButton(onClick = { - scope.copyToClipboard(clipboard) { request.toClipboardText() } + scope.copyToClipboard(clipboard, request.toClipboardText()) }) { Icon( imageVector = Icons.Default.CopyAll, diff --git a/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/util/Clipboards.kt b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/util/Clipboards.kt index 0c25481a..c9d84552 100644 --- a/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/util/Clipboards.kt +++ b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/util/Clipboards.kt @@ -4,9 +4,7 @@ import android.content.ClipData import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.Clipboard import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext internal fun CoroutineScope.copyToClipboard(clipboard: Clipboard, text: String, label: String = "") { launch { @@ -14,16 +12,3 @@ internal fun CoroutineScope.copyToClipboard(clipboard: Clipboard, text: String, clipboard.setClipEntry(clipEntry) } } - -/** - * Builds the clipboard text off the main thread before copying. Use this overload when [text] - * is expensive enough to risk jank (e.g. formatting an entire network transaction), instead of - * computing it inline at the call site. - */ -internal fun CoroutineScope.copyToClipboard(clipboard: Clipboard, label: String = "", text: suspend () -> String) { - launch { - val resolved = withContext(Dispatchers.Default) { text() } - val clipEntry = ClipEntry(ClipData.newPlainText(label, resolved)) - clipboard.setClipEntry(clipEntry) - } -} diff --git a/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/util/NetworkRequestFormatting.kt b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/util/NetworkRequestFormatting.kt index d7b44d42..06f8faa1 100644 --- a/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/util/NetworkRequestFormatting.kt +++ b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/util/NetworkRequestFormatting.kt @@ -10,8 +10,11 @@ import com.ms.square.debugoverlay.model.NetworkRequest * omitting any section that has nothing to show. Response headers/body are included whenever * captured, even alongside an error section - the OkHttp extension populates [NetworkRequest]'s * `error` for any HTTP status of 400 or above while still capturing the response. + * + * @param maxClipBoardLength per-body character cap before truncation, defaulting to + * [MAX_CLIPBOARD_BODY_LENGTH]. */ -internal fun NetworkRequest.toClipboardText(): String = buildString { +internal fun NetworkRequest.toClipboardText(maxClipBoardLength: Int = MAX_CLIPBOARD_BODY_LENGTH): String = buildString { appendLine("$method $url") appendLine("Status: ${statusCode?.let { "$it ${it.httpStatusMessage}" } ?: "Error"}") appendLine("Duration: $durationMs ms") @@ -20,11 +23,11 @@ internal fun NetworkRequest.toClipboardText(): String = buildString { append("Response Size: ${formatBytes(responseSize)}") appendHeadersSection("Request Headers", requestHeaders) - appendBodySection("Request Body", requestBody, requestHeaders.contentType()) + appendBodySection("Request Body", requestBody, requestHeaders.contentType(), maxClipBoardLength) error?.let { appendErrorSection(it) } appendHeadersSection("Response Headers", responseHeaders) - appendBodySection("Response Body", responseBody, responseHeaders.contentType()) + appendBodySection("Response Body", responseBody, responseHeaders.contentType(), maxClipBoardLength) } private fun StringBuilder.appendHeadersSection(title: String, headers: Map) { @@ -33,11 +36,16 @@ private fun StringBuilder.appendHeadersSection(title: String, headers: Map "$name: $value" }) } -private fun StringBuilder.appendBodySection(title: String, body: String?, contentType: String?) { +private fun StringBuilder.appendBodySection( + title: String, + body: String?, + contentType: String?, + maxClipBoardLength: Int, +) { if (body.isNullOrEmpty()) return append("\n\n--- $title ---\n") val formatted = if (TextType.from(body, contentType) == TextType.JSON) formatJsonIfPossible(body) else body - append(formatted.truncateForClipboard()) + append(formatted.truncateForClipboard(maxClipBoardLength)) } private fun StringBuilder.appendErrorSection(error: NetworkError) { @@ -52,16 +60,16 @@ private fun StringBuilder.appendErrorSection(error: NetworkError) { } /** - * Caps a single body at [MAX_CLIPBOARD_BODY_LENGTH]. The OkHttp extension allows bodies up to + * Caps a single body at [maxClipBoardLength]. The OkHttp extension allows bodies up to * 2MB each by default; concatenating an uncapped request and response body into one ClipData * risks TransactionTooLargeException when it crosses the clipboard's Binder call. */ -private fun String.truncateForClipboard(): String = if (length <= MAX_CLIPBOARD_BODY_LENGTH) { +private fun String.truncateForClipboard(maxClipBoardLength: Int): String = if (length <= maxClipBoardLength) { this } else { - val shownSize = formatBytes(MAX_CLIPBOARD_BODY_LENGTH.toLong()) + val shownSize = formatBytes(maxClipBoardLength.toLong()) val totalSize = formatBytes(length.toLong()) - "${take(MAX_CLIPBOARD_BODY_LENGTH)}...\n\n[truncated: showing $shownSize of $totalSize]" + "${take(maxClipBoardLength)}...\n\n[truncated: showing $shownSize of $totalSize]" } private const val MAX_CLIPBOARD_BODY_LENGTH = 64 * 1024 diff --git a/debugoverlay-core/src/test/kotlin/com/ms/square/debugoverlay/internal/util/NetworkRequestFormattingTest.kt b/debugoverlay-core/src/test/kotlin/com/ms/square/debugoverlay/internal/util/NetworkRequestFormattingTest.kt index 81556c87..d80ffee7 100644 --- a/debugoverlay-core/src/test/kotlin/com/ms/square/debugoverlay/internal/util/NetworkRequestFormattingTest.kt +++ b/debugoverlay-core/src/test/kotlin/com/ms/square/debugoverlay/internal/util/NetworkRequestFormattingTest.kt @@ -129,22 +129,23 @@ class NetworkRequestFormattingTest { @Test fun `toClipboardText truncates a body larger than the clipboard cap`() { - val hugeBody = "a".repeat(HUGE_BODY_LENGTH) + val bodyLengthAboveThreshold = 10 + val hugeBody = "a".repeat(bodyLengthAboveThreshold) val request = NetworkRequest( protocol = "http/1.1", method = "GET", url = "https://api.example.com/v1/large", statusCode = 200, durationMs = 10, - responseSize = HUGE_BODY_LENGTH.toLong(), + responseSize = bodyLengthAboveThreshold.toLong(), requestSize = 0, timestampMs = 1_700_000_000_000, responseBody = hugeBody ) - val result = request.toClipboardText() + val result = request.toClipboardText(bodyLengthAboveThreshold / 2) - assertThat(result).contains("[truncated: showing 64.0 KB of") + assertThat(result).contains("[truncated: showing ${bodyLengthAboveThreshold / 2} B of") assertThat(result).doesNotContain(hugeBody) } @@ -227,5 +228,3 @@ class NetworkRequestFormattingTest { ) } } - -private const val HUGE_BODY_LENGTH = 70_000