Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -659,9 +660,6 @@ internal object HtmlReportBuilder {
}
}

private fun Map<String, String>.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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

/**
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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) }
Expand Down Expand Up @@ -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++
}
Expand Down Expand Up @@ -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))
Expand All @@ -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))
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, String>.contentType(): String? =
entries.firstOrNull { it.key.equals("content-type", ignoreCase = true) }?.value
Original file line number Diff line number Diff line change
@@ -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<String, String>) {
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
Original file line number Diff line number Diff line change
Expand Up @@ -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))

/**
Expand Down
2 changes: 2 additions & 0 deletions debugoverlay-core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
<!-- Common Actions -->
<string name="debugoverlay_back">Back</string>
<string name="debugoverlay_copy">Copy</string>
<string name="debugoverlay_copy_url">Copy URL</string>
<string name="debugoverlay_copy_all">Copy all</string>
<string name="debugoverlay_refresh">Refresh</string>
<string name="debugoverlay_clear_search">Clear search</string>
<string name="debugoverlay_view_details">View details</string>
Expand Down
Loading