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 2b03787..bb0e598 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 4ac286a..5d942f7 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 @@ -51,11 +52,13 @@ 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 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 /** @@ -64,8 +67,6 @@ import com.ms.square.debugoverlay.model.NetworkRequest @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( @@ -96,14 +97,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) - ) - } + NetworkRequestDetailActions(request) }, colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.surfaceContainer @@ -119,6 +113,31 @@ 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) { + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + 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) } @@ -214,7 +233,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++ } @@ -344,7 +363,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)) @@ -365,7 +384,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/Headers.kt b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/util/Headers.kt new file mode 100644 index 0000000..3365000 --- /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 new file mode 100644 index 0000000..06f8faa --- /dev/null +++ b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/util/NetworkRequestFormatting.kt @@ -0,0 +1,75 @@ +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. 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(maxClipBoardLength: Int = MAX_CLIPBOARD_BODY_LENGTH): String = buildString { + appendLine("$method $url") + appendLine("Status: ${statusCode?.let { "$it ${it.httpStatusMessage}" } ?: "Error"}") + appendLine("Duration: $durationMs ms") + appendLine("Timestamp: ${formatClipboardTimestamp(timestampMs)}") + appendLine("Request Size: ${formatBytes(requestSize)}") + append("Response Size: ${formatBytes(responseSize)}") + + appendHeadersSection("Request Headers", requestHeaders) + appendBodySection("Request Body", requestBody, requestHeaders.contentType(), maxClipBoardLength) + + error?.let { appendErrorSection(it) } + appendHeadersSection("Response Headers", responseHeaders) + appendBodySection("Response Body", responseBody, responseHeaders.contentType(), maxClipBoardLength) +} + +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?, + 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(maxClipBoardLength)) +} + +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) + } +} + +/** + * 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(maxClipBoardLength: Int): String = if (length <= maxClipBoardLength) { + this +} else { + val shownSize = formatBytes(maxClipBoardLength.toLong()) + val totalSize = formatBytes(length.toLong()) + "${take(maxClipBoardLength)}...\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 68e0c6a..a141863 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 253299f..707de8f 100644 --- a/debugoverlay-core/src/main/res/values/strings.xml +++ b/debugoverlay-core/src/main/res/values/strings.xml @@ -19,6 +19,8 @@ Back Copy + Copy URL + 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 0000000..d80ffee --- /dev/null +++ b/debugoverlay-core/src/test/kotlin/com/ms/square/debugoverlay/internal/util/NetworkRequestFormattingTest.kt @@ -0,0 +1,230 @@ +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: ${formatClipboardTimestamp(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: ${formatClipboardTimestamp(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 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 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 = bodyLengthAboveThreshold.toLong(), + requestSize = 0, + timestampMs = 1_700_000_000_000, + responseBody = hugeBody + ) + + val result = request.toClipboardText(bodyLengthAboveThreshold / 2) + + assertThat(result).contains("[truncated: showing ${bodyLengthAboveThreshold / 2} B 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", + 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: ${formatClipboardTimestamp(request.timestampMs)} + Request Size: 0 B + Response Size: — + + --- Error --- + Connection failed + Unable to resolve host + java.net.UnknownHostException: api.example.com + """.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() + ) + } +}