From da6fcc29895f6b11bf3f9a7a719c1d83148574a9 Mon Sep 17 00:00:00 2001 From: Manabu-GT Date: Thu, 4 Dec 2025 17:44:58 -0700 Subject: [PATCH 1/2] [feat] add NetworkRequestDetailBottomSheet to show captured network request details --- .../debugoverlay/internal/data/TextType.kt | 34 + .../debugoverlay/internal/data/UrlParts.kt | 16 + .../square/debugoverlay/internal/ui/Badges.kt | 52 ++ .../ui/NetworkRequestDetailBottomSheet.kt | 769 ++++++++++++++++++ .../internal/ui/NetworkTabContent.kt | 120 +-- .../square/debugoverlay/internal/ui/Texts.kt | 264 ++++++ .../debugoverlay/internal/util/Colors.kt | 41 + .../debugoverlay/internal/util/Formatters.kt | 127 +++ .../internal/util/HttpStatusCodes.kt | 44 + .../debugoverlay/model/NetworkRequest.kt | 17 +- .../okhttp/DebugOverlayNetworkInterceptor.kt | 20 +- 11 files changed, 1369 insertions(+), 135 deletions(-) create mode 100644 debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/data/TextType.kt create mode 100644 debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/data/UrlParts.kt create mode 100644 debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/ui/Badges.kt create mode 100644 debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/ui/NetworkRequestDetailBottomSheet.kt create mode 100644 debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/ui/Texts.kt create mode 100644 debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/util/Colors.kt create mode 100644 debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/util/Formatters.kt create mode 100644 debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/util/HttpStatusCodes.kt diff --git a/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/data/TextType.kt b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/data/TextType.kt new file mode 100644 index 0000000..87aea35 --- /dev/null +++ b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/data/TextType.kt @@ -0,0 +1,34 @@ +package com.ms.square.debugoverlay.internal.data + +internal enum class TextType { + JSON, + HTML, + XML, + PLAIN, + ; + + companion object Companion { + + /** + * Detect text type from http content-type header and content. + */ + fun from(body: String, contentType: String?): TextType { + // Check content-type header first + contentType?.lowercase()?.let { ct -> + when { + ct.contains("json") -> return JSON + ct.contains("html") -> return HTML + ct.contains("xml") -> return XML + } + } + // Fallback: Detect from content + val trimmed = body.trimStart() + return when { + trimmed.startsWith("{") || trimmed.startsWith("[") -> JSON + trimmed.startsWith(" HTML + trimmed.startsWith(" XML + else -> PLAIN + } + } + } +} diff --git a/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/data/UrlParts.kt b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/data/UrlParts.kt new file mode 100644 index 0000000..ddec2a4 --- /dev/null +++ b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/data/UrlParts.kt @@ -0,0 +1,16 @@ +package com.ms.square.debugoverlay.internal.data + +import androidx.core.net.toUri + +internal data class UrlParts(val scheme: String, val domain: String, val path: String) { + companion object { + fun from(url: String): UrlParts { + val uri = url.toUri() + return UrlParts( + scheme = uri.scheme ?: "", + domain = uri.host ?: "", + path = uri.path ?: "/" + ) + } + } +} diff --git a/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/ui/Badges.kt b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/ui/Badges.kt new file mode 100644 index 0000000..b9511a2 --- /dev/null +++ b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/ui/Badges.kt @@ -0,0 +1,52 @@ +package com.ms.square.debugoverlay.internal.ui + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ms.square.debugoverlay.internal.util.httpMethodColor +import com.ms.square.debugoverlay.internal.util.httpStatusColor + +/** + * HTTP method badge (GET, POST, etc.) + */ +@Composable +internal fun MethodBadge(method: String, modifier: Modifier = Modifier) { + Surface( + modifier = modifier, + color = method.httpMethodColor, + shape = RoundedCornerShape(4.dp) + ) { + Text( + text = method, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + style = MaterialTheme.typography.labelSmall, + color = Color.Black, + fontWeight = FontWeight.Bold, + fontSize = 10.sp + ) + } +} + +/** + * Status code badge with color coding. + */ +@Composable +internal fun StatusCodeBadge(statusCode: Int?, modifier: Modifier = Modifier) { + Text( + text = statusCode?.toString() ?: "ERR", + modifier = modifier, + style = MaterialTheme.typography.titleMedium, + color = statusCode.httpStatusColor, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace + ) +} diff --git a/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/ui/NetworkRequestDetailBottomSheet.kt b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/ui/NetworkRequestDetailBottomSheet.kt new file mode 100644 index 0000000..df74ff3 --- /dev/null +++ b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/ui/NetworkRequestDetailBottomSheet.kt @@ -0,0 +1,769 @@ +@file:Suppress("TooManyFunctions") + +package com.ms.square.debugoverlay.internal.ui + +import android.content.ClipData +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.Info +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SecondaryTabRow +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.ClipEntry +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +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.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.model.NetworkRequest +import kotlinx.coroutines.launch + +private const val BOTTOM_SHEET_HEIGHT_FRACTION = 0.8f + +/** + * Network request detail bottom sheet with comprehensive information. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun NetworkRequestDetailBottomSheet(request: NetworkRequest, onDismiss: () -> Unit) { + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ) + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + tonalElevation = 3.dp, + shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp), + dragHandle = { + Box( + modifier = Modifier + .padding(top = 12.dp, bottom = 8.dp) + .size(width = 32.dp, height = 4.dp) + .background( + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + shape = RoundedCornerShape(2.dp) + ) + ) + } + ) { + NetworkRequestDetailContent( + request = request, + onDismiss = onDismiss + ) + } +} + +@Composable +private fun NetworkRequestDetailContent( + request: NetworkRequest, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + var selectedTab by remember { mutableIntStateOf(0) } + + Column( + modifier = modifier + .fillMaxWidth() + .fillMaxHeight(BOTTOM_SHEET_HEIGHT_FRACTION) + ) { + // Header + NetworkDetailHeader( + request = request, + onDismiss = onDismiss + ) + + // Tabs + SecondaryTabRow( + selectedTabIndex = selectedTab, + containerColor = MaterialTheme.colorScheme.surfaceContainer + ) { + Tab( + selected = selectedTab == 0, + onClick = { selectedTab = 0 }, + text = { Text("Overview") } + ) + Tab( + selected = selectedTab == 1, + onClick = { selectedTab = 1 }, + text = { Text("Headers") } + ) + Tab( + selected = selectedTab == 2, + onClick = { selectedTab = 2 }, + text = { Text("Body") } + ) + } + + // Content + when (selectedTab) { + 0 -> OverviewTab(request = request) + 1 -> HeadersTab(request = request) + 2 -> BodyTab(request = request) + } + } +} + +@Composable +private fun NetworkDetailHeader(request: NetworkRequest, onDismiss: () -> Unit, modifier: Modifier = Modifier) { + val clipboard = LocalClipboard.current + val context = LocalContext.current + val scope = rememberCoroutineScope() + val domain = remember(request.url) { UrlParts.from(request.url).domain } + + Surface( + modifier = modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceContainer, + tonalElevation = 2.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // Method and Status + Column(modifier = Modifier.weight(1f)) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + MethodBadge(method = request.method) + StatusCodeBadge(statusCode = request.statusCode) + } + + Text( + text = domain, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + fontFamily = FontFamily.Monospace, + modifier = Modifier.padding(top = 4.dp) + ) + } + + // Actions + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + IconButton(onClick = { + scope.launch { + val clipboardLabel = context.getString(R.string.debugoverlay_clipboard_label) + val clipEntry = ClipEntry(ClipData.newPlainText(clipboardLabel, request.url)) + clipboard.setClipEntry(clipEntry) + } + }) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = stringResource(R.string.debugoverlay_copy) + ) + } + IconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.debugoverlay_close_description) + ) + } + } + } + } +} + +/** + * Overview tab with URL, request info, and response summary. + */ +@Suppress("LongMethod") // Complex UI with multiple sections +@Composable +private fun OverviewTab(request: NetworkRequest, modifier: Modifier = Modifier) { + val clipboard = LocalClipboard.current + val context = LocalContext.current + val scope = rememberCoroutineScope() + + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + // Error Section (if applicable) + if (request.error != null) { + item { + ErrorSection(error = request.error) + } + } + + // URL Section + item { + DetailSection( + title = "URL", + onCopy = { + scope.launch { + val clipboardLabel = context.getString(R.string.debugoverlay_clipboard_label) + val clipEntry = ClipEntry(ClipData.newPlainText(clipboardLabel, request.url)) + clipboard.setClipEntry(clipEntry) + } + } + ) { + UrlDisplay(url = request.url) + } + } + + // Request Info + item { + DetailSection(title = "Request Info") { + InfoCard { + InfoRow("Method", request.method) + InfoRow( + "Status", + request.statusCode?.let { "$it ${it.httpStatusMessage}" } ?: "Error", + valueColor = request.statusCode.httpStatusColor + ) + InfoRow("Duration", "${request.durationMs} ms") + InfoRow("Response Size", formatBytes(request.responseSize)) + InfoRow("Request Size", formatBytes(request.requestSize)) + InfoRow("Timestamp", formatTimestamp(request.timestamp), showDivider = false) + } + } + } + + // Response Summary + if (request.responseHeaders.isNotEmpty()) { + item { + DetailSection(title = "Response Summary") { + InfoCard { + var itemCount = 0 + request.responseHeaders["content-type"]?.let { + InfoRow("Content-Type", it) + itemCount++ + } + request.responseHeaders["content-length"]?.let { + InfoRow("Content-Length", it) + itemCount++ + } + request.responseHeaders["cache-control"]?.let { + InfoRow("Cache", it, showDivider = false) + itemCount++ + } + if (itemCount == 0) { + EmptyState(text = "No notable headers") + } + } + } + } + } + } +} + +/** + * Headers tab with request and response headers. + */ +@Suppress("LongMethod") // Complex UI with request and response sections +@Composable +private fun HeadersTab(request: NetworkRequest, modifier: Modifier = Modifier) { + val clipboard = LocalClipboard.current + val context = LocalContext.current + val scope = rememberCoroutineScope() + var requestHeadersExpanded by remember { mutableStateOf(true) } + var responseHeadersExpanded by remember { mutableStateOf(true) } + + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + // Request Headers + item { + DetailSection( + title = "Request Headers (${request.requestHeaders.size})", + isExpandable = true, + isExpanded = requestHeadersExpanded, + onToggleExpand = { requestHeadersExpanded = !requestHeadersExpanded }, + onCopy = if (request.requestHeaders.isNotEmpty()) { + { + scope.launch { + val text = request.requestHeaders.entries.joinToString("\n") { + "${it.key}: ${it.value}" + } + val clipboardLabel = context.getString(R.string.debugoverlay_clipboard_label) + val clipEntry = ClipEntry(ClipData.newPlainText(clipboardLabel, text)) + clipboard.setClipEntry(clipEntry) + } + } + } else { + null + } + ) { + if (requestHeadersExpanded) { + if (request.requestHeaders.isEmpty()) { + EmptyState(text = "No request headers") + } else { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + request.requestHeaders.forEach { (name, value) -> + HeaderItem(name = name, value = value) + } + } + } + } + } + } + + // Response Headers + item { + DetailSection( + title = "Response Headers (${request.responseHeaders.size})", + isExpandable = true, + isExpanded = responseHeadersExpanded, + onToggleExpand = { responseHeadersExpanded = !responseHeadersExpanded }, + onCopy = if (request.responseHeaders.isNotEmpty()) { + { + scope.launch { + val text = request.responseHeaders.entries.joinToString("\n") { + "${it.key}: ${it.value}" + } + val clipboardLabel = context.getString(R.string.debugoverlay_clipboard_label) + val clipEntry = ClipEntry(ClipData.newPlainText(clipboardLabel, text)) + clipboard.setClipEntry(clipEntry) + } + } + } else { + null + } + ) { + if (responseHeadersExpanded) { + if (request.responseHeaders.isEmpty()) { + EmptyState(text = "No response headers") + } else { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + request.responseHeaders.forEach { (name, value) -> + HeaderItem(name = name, value = value) + } + } + } + } + } + } + } +} + +/** + * Body tab with request and response bodies. + */ +@Composable +private fun BodyTab(request: NetworkRequest, modifier: Modifier = Modifier) { + val clipboard = LocalClipboard.current + val context = LocalContext.current + val scope = rememberCoroutineScope() + + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + // Request Body + item { + DetailSection( + title = "Request Body", + onCopy = request.requestBody?.let { + { + scope.launch { + val clipboardLabel = context.getString(R.string.debugoverlay_clipboard_label) + val clipEntry = ClipEntry(ClipData.newPlainText(clipboardLabel, it)) + clipboard.setClipEntry(clipEntry) + } + } + } + ) { + if (request.requestBody != null) { + BodyPreview( + body = request.requestBody, + contentType = request.requestHeaders["content-type"] + ) + } else { + EmptyState(text = "No request body") + } + } + } + + // Response Body + item { + DetailSection( + title = "Response Body", + onCopy = request.responseBody?.let { + { + scope.launch { + val clipboardLabel = context.getString(R.string.debugoverlay_clipboard_label) + val clipEntry = ClipEntry(ClipData.newPlainText(clipboardLabel, it)) + clipboard.setClipEntry(clipEntry) + } + } + } + ) { + if (request.responseBody != null) { + BodyPreview( + body = request.responseBody, + contentType = request.responseHeaders["content-type"] + ) + } else { + EmptyState(text = "No response body") + } + } + } + } +} + +// ============================================================================ +// Helper Composables +// ============================================================================ + +/** + * Section with title and optional copy button. + */ +@Composable +private fun DetailSection( + title: String, + modifier: Modifier = Modifier, + isExpandable: Boolean = false, + isExpanded: Boolean = true, + onToggleExpand: (() -> Unit)? = null, + onCopy: (() -> Unit)? = null, + content: @Composable () -> Unit, +) { + Column(modifier = modifier) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (isExpandable && onToggleExpand != null) { + IconButton( + onClick = onToggleExpand, + modifier = Modifier.size(24.dp) + ) { + Icon( + imageVector = if (isExpanded) { + Icons.Default.ExpandMore + } else { + Icons.Default.ChevronRight + }, + contentDescription = if (isExpanded) "Collapse" else "Expand", + tint = MaterialTheme.colorScheme.primary + ) + } + } + + Text( + text = title, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + letterSpacing = 0.5.sp + ) + } + + if (onCopy != null) { + IconButton( + onClick = onCopy, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = stringResource(R.string.debugoverlay_copy), + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + content() + } +} + +/** + * Info card container. + */ +@Composable +private fun InfoCard(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) { + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerHighest, + tonalElevation = 2.dp + ) { + Column( + modifier = Modifier.padding(16.dp), + content = content + ) + } +} + +/** + * Info row with label and value. + */ +@Composable +private fun InfoRow( + label: String, + value: String, + modifier: Modifier = Modifier, + valueColor: Color = MaterialTheme.colorScheme.onSurface, + showDivider: Boolean = true, +) { + Column(modifier = modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 13.sp + ) + + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + color = valueColor, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + fontFamily = FontFamily.Monospace, + modifier = Modifier.padding(start = 16.dp) + ) + } + + if (showDivider) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f), + modifier = Modifier.padding(top = 8.dp) + ) + } + } +} + +/** + * URL display with scheme, domain, and path. + */ +@Composable +private fun UrlDisplay(url: String, modifier: Modifier = Modifier) { + val parts = remember(url) { + UrlParts.from(url) + } + + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerHighest, + tonalElevation = 2.dp + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = parts.scheme, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontFamily = FontFamily.Monospace + ) + + Text( + text = parts.domain, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Medium, + fontFamily = FontFamily.Monospace, + modifier = Modifier.padding(vertical = 4.dp) + ) + + Text( + text = parts.path, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.tertiary, + fontFamily = FontFamily.Monospace, + lineHeight = 18.sp + ) + } + } +} + +/** + * Header item with name and value. + */ +@Composable +private fun HeaderItem(name: String, value: String, modifier: Modifier = Modifier) { + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceContainerHighest + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = name, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp + ) + + Text( + text = value, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + lineHeight = 18.sp, + modifier = Modifier.padding(top = 4.dp) + ) + } + } +} + +/** + * Body preview. + */ +@Composable +private fun BodyPreview(body: String, contentType: String?, modifier: Modifier = Modifier) { + val textType = remember(body, contentType) { TextType.from(body, contentType) } + TextPreview(body, textType, modifier) +} + +/** + * Error section display. + */ +@Composable +private fun ErrorSection(error: com.ms.square.debugoverlay.model.NetworkError, modifier: Modifier = Modifier) { + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f), + tonalElevation = 2.dp + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Error, + contentDescription = "Error", + tint = MaterialTheme.colorScheme.error + ) + Text( + text = error.title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.error, + fontWeight = FontWeight.Bold + ) + } + + Text( + text = error.message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(top = 8.dp) + ) + + if (error.stackTrace != null) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest + ) { + // No verticalScroll - LazyColumn parent handles vertical scrolling + Text( + text = error.stackTrace, + modifier = Modifier.padding(12.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + lineHeight = 16.sp + ) + } + } + } + } +} + +/** + * Empty state. + */ +@Composable +private fun EmptyState(text: String, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .fillMaxWidth() + .padding(40.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) + ) + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) + } + } +} diff --git a/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/ui/NetworkTabContent.kt b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/ui/NetworkTabContent.kt index 8f99b89..c352c8c 100644 --- a/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/ui/NetworkTabContent.kt +++ b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/ui/NetworkTabContent.kt @@ -32,42 +32,20 @@ import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.ms.square.debugoverlay.DebugOverlay import com.ms.square.debugoverlay.core.R +import com.ms.square.debugoverlay.internal.data.UrlParts import com.ms.square.debugoverlay.internal.data.model.NetworkStats -import com.ms.square.debugoverlay.model.HttpMethod +import com.ms.square.debugoverlay.internal.util.HTTP_CLIENT_ERROR_START +import com.ms.square.debugoverlay.internal.util.formatBytes import com.ms.square.debugoverlay.model.NetworkRequest import kotlin.math.roundToInt -// HTTP Status Code ranges -private const val HTTP_SUCCESS_START = 200 -private const val HTTP_SUCCESS_END = 299 -private const val HTTP_REDIRECT_START = 300 -private const val HTTP_REDIRECT_END = 399 -private const val HTTP_CLIENT_ERROR_START = 400 -private const val HTTP_CLIENT_ERROR_END = 499 -private const val HTTP_SERVER_ERROR_START = 500 -private const val HTTP_SERVER_ERROR_END = 599 - -// Status code colors -private val STATUS_COLOR_SUCCESS = Color(0xFF4CAF50) // Green -private val STATUS_COLOR_REDIRECT = Color(0xFF2196F3) // Blue -private val STATUS_COLOR_CLIENT_ERROR = Color(0xFFF44336) // Red -private val STATUS_COLOR_SERVER_ERROR = Color(0xFFFF5722) // Deep orange -private val STATUS_COLOR_UNKNOWN = Color(0xFF757575) // Gray - -// HTTP method colors -private val METHOD_COLOR_GET = Color(0xFF03DAC6) // Cyan -private val METHOD_COLOR_POST = Color(0xFFFFC107) // Amber -private val METHOD_COLOR_PUT = Color(0xFF2196F3) // Blue -private val METHOD_COLOR_DELETE = Color(0xFFF44336) // Red -private val METHOD_COLOR_PATCH = Color(0xFF9C27B0) // Purple -private val METHOD_COLOR_UNKNOWN = Color(0xFF757575) // Gray - /** * Network tab showing HTTP requests with stats. * * Features: * - Download/Upload stats */ +@Suppress("LongMethod") // Added detail bottom sheet handling @Composable internal fun NetworkTabContent(modifier: Modifier = Modifier) { val networkStats by DebugOverlay.overlayDataRepository.netStats.collectAsStateWithLifecycle( @@ -77,6 +55,7 @@ internal fun NetworkTabContent(modifier: Modifier = Modifier) { initialValue = emptyList() ) var searchQuery by remember { mutableStateOf("") } + var selectedRequest by remember { mutableStateOf(null) } val augmentedNetworkStats = remember(networkStats, networkRequests) { networkStats.augmentNetworkStatsWith(networkRequests) @@ -88,8 +67,8 @@ internal fun NetworkTabContent(modifier: Modifier = Modifier) { networkRequests } else { networkRequests.filter { request -> - request.shortUrl.contains(searchQuery, ignoreCase = true) || - request.method.toString().contains(searchQuery, ignoreCase = true) + request.url.contains(searchQuery, ignoreCase = true) || + request.method.contains(searchQuery, ignoreCase = true) } } } @@ -129,11 +108,19 @@ internal fun NetworkTabContent(modifier: Modifier = Modifier) { ) { request -> NetworkRequestItem( request = request, - onClick = { /* No-op: Detail view not implemented yet */ } + onClick = { selectedRequest = request } ) } } } + + // Show detail bottom sheet + selectedRequest?.let { request -> + NetworkRequestDetailBottomSheet( + request = request, + onDismiss = { selectedRequest = null } + ) + } } /** @@ -253,6 +240,7 @@ private fun NetworkStatsHeaderValue( */ @Composable private fun NetworkRequestItem(request: NetworkRequest, onClick: () -> Unit, modifier: Modifier = Modifier) { + val urlPath = remember(request.url) { UrlParts.from(request.url).path } Surface( onClick = onClick, modifier = modifier.fillMaxWidth(), @@ -279,7 +267,7 @@ private fun NetworkRequestItem(request: NetworkRequest, onClick: () -> Unit, mod MethodBadge(method = request.method) Text( - text = request.shortUrl, + text = urlPath, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, fontFamily = FontFamily.Monospace, @@ -319,59 +307,6 @@ private fun NetworkRequestItem(request: NetworkRequest, onClick: () -> Unit, mod } } -/** - * HTTP method badge (GET, POST, etc.) - */ -@Composable -private fun MethodBadge(method: HttpMethod, modifier: Modifier = Modifier) { - Surface( - modifier = modifier, - color = method.toColor(), - shape = RoundedCornerShape(4.dp) - ) { - Text( - text = method.name, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), - style = MaterialTheme.typography.labelSmall, - color = Color.Black, - fontWeight = FontWeight.Bold, - fontSize = 10.sp - ) - } -} - -/** - * Status code badge with color coding. - */ -@Composable -private fun StatusCodeBadge(statusCode: Int?, modifier: Modifier = Modifier) { - val color = when (statusCode) { - in HTTP_SUCCESS_START..HTTP_SUCCESS_END -> STATUS_COLOR_SUCCESS - in HTTP_REDIRECT_START..HTTP_REDIRECT_END -> STATUS_COLOR_REDIRECT - in HTTP_CLIENT_ERROR_START..HTTP_CLIENT_ERROR_END -> STATUS_COLOR_CLIENT_ERROR - in HTTP_SERVER_ERROR_START..HTTP_SERVER_ERROR_END -> STATUS_COLOR_SERVER_ERROR - else -> STATUS_COLOR_UNKNOWN - } - - Text( - text = statusCode?.toString() ?: "ERR", - modifier = modifier, - style = MaterialTheme.typography.titleMedium, - color = color, - fontWeight = FontWeight.Bold, - fontFamily = FontFamily.Monospace - ) -} - -private fun HttpMethod.toColor(): Color = when (this) { - HttpMethod.GET -> METHOD_COLOR_GET - HttpMethod.POST -> METHOD_COLOR_POST - HttpMethod.PUT -> METHOD_COLOR_PUT - HttpMethod.DELETE -> METHOD_COLOR_DELETE - HttpMethod.PATCH -> METHOD_COLOR_PATCH - else -> METHOD_COLOR_UNKNOWN -} - /** * Augment network statistics from requests. */ @@ -401,22 +336,3 @@ private fun NetworkStats.augmentNetworkStatsWith(requests: List) ) } } - -private const val BYTES_PER_KB = 1024L -private const val BYTES_PER_MB = 1024L * 1024L -private const val BYTES_PER_GB = 1024L * 1024L * 1024L - -/** - * Format bytes to human-readable string. - */ -private fun formatBytes(bytes: Long?): String = when { - bytes == null || bytes < 0 -> "—" - bytes < BYTES_PER_KB -> "$bytes B" - bytes < BYTES_PER_MB -> { - val kb = bytes / BYTES_PER_KB.toDouble() - @Suppress("MagicNumber") - if (kb < 10) "%.2f KB".format(kb) else "%.1f KB".format(kb) - } - bytes < BYTES_PER_GB -> "%.1f MB".format(bytes / BYTES_PER_MB.toDouble()) - else -> "%.1f GB".format(bytes / BYTES_PER_GB.toDouble()) -} diff --git a/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/ui/Texts.kt b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/ui/Texts.kt new file mode 100644 index 0000000..9421724 --- /dev/null +++ b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/ui/Texts.kt @@ -0,0 +1,264 @@ +package com.ms.square.debugoverlay.internal.ui + +import android.content.ClipData +import android.graphics.Color +import android.graphics.Typeface +import android.view.ViewGroup +import android.webkit.WebView +import android.widget.TextView +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Info +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import com.ms.square.debugoverlay.core.R +import com.ms.square.debugoverlay.internal.data.TextType +import com.ms.square.debugoverlay.internal.util.formatJsonAsHtml +import com.ms.square.debugoverlay.internal.util.formatPlainTextAsHtml +import com.ms.square.debugoverlay.internal.util.formatTextSize +import kotlinx.coroutines.launch + +private const val SMALL_TEXT_THRESHOLD = 10_000 // Use Compose Text +private const val LARGE_TEXT_THRESHOLD = 500_000 // Use TextView, above this truncate + +/** + * Text preview with four-tier performance optimization for large texts. + */ +@Composable +internal fun TextPreview(text: String, textType: TextType, modifier: Modifier = Modifier) { + when { + // Tier 1: Small texts - Use Compose Text (best UX, integrated styling) + text.length < SMALL_TEXT_THRESHOLD -> { + CompactTextPreview(text, modifier) + } + // Tier 2: Structured data - Offer formatted view + textType == TextType.JSON || textType == TextType.HTML -> { + StructuredTextPreview(text, textType, modifier) + } + // Tier 3: Large plain text - Use native TextView (performant) + text.length < LARGE_TEXT_THRESHOLD -> { + TextViewTextPreview(text, modifier) + } + // Tier 4: Very large - Truncate (always instant) + else -> { + TruncatedTextPreview(text, modifier) + } + } +} + +/** + * Structured text data preview with raw/formatted toggle. + */ +@Composable +private fun StructuredTextPreview(text: String, textType: TextType, modifier: Modifier = Modifier) { + var showFormatted by remember { mutableStateOf(false) } + + Column(modifier = modifier.fillMaxWidth()) { + // Toggle buttons + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = { showFormatted = false }, + modifier = Modifier.weight(1f) + ) { + Text("Raw") + } + Button( + onClick = { showFormatted = true }, + modifier = Modifier.weight(1f) + ) { + Text("Formatted") + } + } + + if (showFormatted) { + WebViewTextPreview(text, textType) + } else { + TextViewTextPreview(text) + } + } +} + +/** + * Native TextView preview for large plain text (performant). + */ +@Suppress("MagicNumber") +@Composable +private fun TextViewTextPreview(text: String, modifier: Modifier = Modifier) { + val textColor = MaterialTheme.colorScheme.onSurface + + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + tonalElevation = 1.dp + ) { + AndroidView( + factory = { context -> + TextView(context).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + textSize = 12f + typeface = Typeface.MONOSPACE + setTextIsSelectable(true) + setTextColor(textColor.toArgb()) + setBackgroundColor(Color.TRANSPARENT) + } + }, + update = { textView -> + textView.text = text + }, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) + } +} + +/** + * WebView preview for formatted JSON/HTML text. + */ +@Composable +private fun WebViewTextPreview(text: String, textType: TextType, modifier: Modifier = Modifier) { + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + tonalElevation = 1.dp + ) { + AndroidView( + factory = { context -> + WebView(context).apply { + settings.javaScriptEnabled = false // Security: No JS needed + settings.builtInZoomControls = true + settings.displayZoomControls = false + settings.setSupportZoom(true) + setBackgroundColor(Color.TRANSPARENT) + } + }, + update = { webView -> + val html = when (textType) { + TextType.JSON -> formatJsonAsHtml(text) + // For now, just display the raw HTML for BodyType.HTML. + // Could add a toggle to show rendered vs source + else -> formatPlainTextAsHtml(text) + } + webView.loadDataWithBaseURL(null, html, "text/html", "UTF-8", null) + }, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 200.dp, max = 600.dp) + ) + } +} + +/** + * Compose Text preview for short texts. + */ +@Composable +internal fun CompactTextPreview(body: String, modifier: Modifier = Modifier) { + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + tonalElevation = 1.dp + ) { + Text( + text = body, + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + lineHeight = 18.sp + ) + } +} + +/** + * Truncated compose text preview for very large texts. + */ +@Composable +internal fun TruncatedTextPreview(text: String, modifier: Modifier = Modifier) { + val clipboard = LocalClipboard.current + val context = LocalContext.current + val scope = rememberCoroutineScope() + + Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) { + // Warning message + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.tertiaryContainer + ) { + Row( + modifier = Modifier.padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.onTertiaryContainer + ) + Text( + text = "Text too large (${formatTextSize(text.length)}). Showing first ${ + formatTextSize( + SMALL_TEXT_THRESHOLD + ) + }.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onTertiaryContainer + ) + } + } + + // Truncated preview + CompactTextPreview(body = text.take(SMALL_TEXT_THRESHOLD)) + + // Copy full body button + Button( + onClick = { + scope.launch { + val clipboardLabel = context.getString(R.string.debugoverlay_clipboard_label) + val clipEntry = ClipEntry(ClipData.newPlainText(clipboardLabel, text)) + clipboard.setClipEntry(clipEntry) + } + }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Copy Full text (${formatTextSize(text.length)})") + } + } +} diff --git a/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/util/Colors.kt b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/util/Colors.kt new file mode 100644 index 0000000..30f1170 --- /dev/null +++ b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/util/Colors.kt @@ -0,0 +1,41 @@ +package com.ms.square.debugoverlay.internal.util + +import androidx.compose.ui.graphics.Color + +// HTTP method colors +private val METHOD_COLOR_GET = Color(0xFF03DAC6) // Cyan +private val METHOD_COLOR_POST = Color(0xFFFFC107) // Amber +private val METHOD_COLOR_PUT = Color(0xFF2196F3) // Blue +private val METHOD_COLOR_DELETE = Color(0xFFF44336) // Red +private val METHOD_COLOR_PATCH = Color(0xFF9C27B0) // Purple +private val METHOD_COLOR_HEAD = Color(0xFF009688) // Teal +private val METHOD_COLOR_OPTIONS = Color(0xFFBDBDBD) // Light gray +private val METHOD_COLOR_UNKNOWN = Color(0xFF757575) // Gray + +internal val String.httpMethodColor: Color + get() = when (this) { + "GET" -> METHOD_COLOR_GET + "POST" -> METHOD_COLOR_POST + "PUT" -> METHOD_COLOR_PUT + "DELETE" -> METHOD_COLOR_DELETE + "PATCH" -> METHOD_COLOR_PATCH + "HEAD" -> METHOD_COLOR_HEAD + "OPTIONS" -> METHOD_COLOR_OPTIONS + else -> METHOD_COLOR_UNKNOWN + } + +// Status code colors +private val STATUS_COLOR_SUCCESS = Color(0xFF4CAF50) // Green +private val STATUS_COLOR_REDIRECT = Color(0xFF2196F3) // Blue +private val STATUS_COLOR_CLIENT_ERROR = Color(0xFFF44336) // Red +private val STATUS_COLOR_SERVER_ERROR = Color(0xFFFF5722) // Deep orange +private val STATUS_COLOR_UNKNOWN = Color(0xFF757575) // Gray + +internal val Int?.httpStatusColor: Color + get() = when (this) { + in HTTP_SUCCESS_START..HTTP_SUCCESS_END -> STATUS_COLOR_SUCCESS + in HTTP_REDIRECT_START..HTTP_REDIRECT_END -> STATUS_COLOR_REDIRECT + in HTTP_CLIENT_ERROR_START..HTTP_CLIENT_ERROR_END -> STATUS_COLOR_CLIENT_ERROR + in HTTP_SERVER_ERROR_START..HTTP_SERVER_ERROR_END -> STATUS_COLOR_SERVER_ERROR + else -> STATUS_COLOR_UNKNOWN + } diff --git a/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/util/Formatters.kt b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/util/Formatters.kt new file mode 100644 index 0000000..0adf455 --- /dev/null +++ b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/util/Formatters.kt @@ -0,0 +1,127 @@ +package com.ms.square.debugoverlay.internal.util + +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +private const val BYTES_PER_KB = 1024L +private const val BYTES_PER_MB = 1024L * 1024L +private const val BYTES_PER_GB = 1024L * 1024L * 1024L + +/** + * Format bytes to human-readable string. + */ +internal fun formatBytes(bytes: Long?): String = when { + bytes == null || bytes < 0 -> "—" + bytes < BYTES_PER_KB -> "$bytes B" + bytes < BYTES_PER_MB -> { + val kb = bytes / BYTES_PER_KB.toDouble() + @Suppress("MagicNumber") + if (kb < 10) "%.2f KB".format(kb) else "%.1f KB".format(kb) + } + bytes < BYTES_PER_GB -> "%.1f MB".format(bytes / BYTES_PER_MB.toDouble()) + else -> "%.1f GB".format(bytes / BYTES_PER_GB.toDouble()) +} + +/** + * Format text size to human-readable string. + */ +internal fun formatTextSize(length: Int): String = when { + length < BYTES_PER_KB -> "$length chars" + length < BYTES_PER_MB -> "${length / BYTES_PER_KB} KB" + else -> "${"%.1f".format(length / (BYTES_PER_MB.toDouble()))} MB" +} + +internal fun formatTimestamp(timestamp: Long): String { + val date = Date(timestamp) + val formatter = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()) + return formatter.format(date) +} + +/** + * Format JSON as syntax-highlighted HTML. + */ +@Suppress("MaxLineLength") // HTML template +internal fun formatJsonAsHtml(json: String): String { + val escaped = json + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + + // Simple syntax highlighting with regex + val highlighted = escaped + .replace(Regex("""("[^&]+")\s*:"""), """$1:""") // Keys + .replace(Regex(""":\s*("[^&]+")"""), """: $1""") // String values + .replace(Regex("""\b(\d+\.?\d*)\b"""), """$1""") // Numbers + .replace(Regex("""\b(true|false)\b"""), """$1""") // Booleans + .replace(Regex("""\b(null)\b"""), """$1""") // Null + + return """ + + + + + + + +
$highlighted
+ + + """.trimIndent() +} + +/** + * Format plain text as HTML. + */ +internal fun formatPlainTextAsHtml(text: String): String { + val escaped = text + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + + return """ + + + + + + + +
$escaped
+ + + """.trimIndent() +} diff --git a/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/util/HttpStatusCodes.kt b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/util/HttpStatusCodes.kt new file mode 100644 index 0000000..915b59c --- /dev/null +++ b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/util/HttpStatusCodes.kt @@ -0,0 +1,44 @@ +package com.ms.square.debugoverlay.internal.util + +import java.net.HttpURLConnection.HTTP_BAD_GATEWAY +import java.net.HttpURLConnection.HTTP_BAD_REQUEST +import java.net.HttpURLConnection.HTTP_CREATED +import java.net.HttpURLConnection.HTTP_FORBIDDEN +import java.net.HttpURLConnection.HTTP_INTERNAL_ERROR +import java.net.HttpURLConnection.HTTP_MOVED_PERM +import java.net.HttpURLConnection.HTTP_MOVED_TEMP +import java.net.HttpURLConnection.HTTP_NOT_FOUND +import java.net.HttpURLConnection.HTTP_NOT_MODIFIED +import java.net.HttpURLConnection.HTTP_NO_CONTENT +import java.net.HttpURLConnection.HTTP_OK +import java.net.HttpURLConnection.HTTP_UNAUTHORIZED +import java.net.HttpURLConnection.HTTP_UNAVAILABLE + +// HTTP Status Code ranges +internal const val HTTP_SUCCESS_START = 200 +internal const val HTTP_SUCCESS_END = 299 +internal const val HTTP_REDIRECT_START = 300 +internal const val HTTP_REDIRECT_END = 399 +internal const val HTTP_CLIENT_ERROR_START = 400 +internal const val HTTP_CLIENT_ERROR_END = 499 +internal const val HTTP_SERVER_ERROR_START = 500 +internal const val HTTP_SERVER_ERROR_END = 599 + +private val HTTP_STATUS_MESSAGES = mapOf( + HTTP_OK to "OK", + HTTP_CREATED to "Created", + HTTP_NO_CONTENT to "No Content", + HTTP_MOVED_PERM to "Moved Permanently", + HTTP_MOVED_TEMP to "Temporary Redirect", + HTTP_NOT_MODIFIED to "Not Modified", + HTTP_BAD_REQUEST to "Bad Request", + HTTP_UNAUTHORIZED to "Unauthorized", + HTTP_FORBIDDEN to "Forbidden", + HTTP_NOT_FOUND to "Not Found", + HTTP_INTERNAL_ERROR to "Internal Server Error", + HTTP_BAD_GATEWAY to "Bad Gateway", + HTTP_UNAVAILABLE to "Service Unavailable" +) + +internal val Int.httpStatusMessage: String + get() = HTTP_STATUS_MESSAGES[this] ?: "" diff --git a/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/model/NetworkRequest.kt b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/model/NetworkRequest.kt index b426328..a57fa47 100644 --- a/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/model/NetworkRequest.kt +++ b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/model/NetworkRequest.kt @@ -9,9 +9,8 @@ public data class NetworkRequest( // Base NetworkRequest fields val id: String = UUID.randomUUID().toString(), val protocol: String?, // http/1.1, h2, quic..etc - val method: HttpMethod, // GET, POST, etc. - val fullUrl: String, // https://test.com/api/v1/feed - val shortUrl: String, // /api/v1/feed + val method: String, // GET, POST, etc. + val url: String, // https://test.com/api/v1/feed val statusCode: Int?, // 200, 404, etc. val durationMs: Long, // 245 val responseSize: Long?, // bytes @@ -25,18 +24,6 @@ public data class NetworkRequest( val error: NetworkError? = null, ) -public enum class HttpMethod { - GET, - POST, - PUT, - DELETE, - PATCH, - HEAD, - OPTIONS, - TRACE, - UNKNOWN, -} - /** * Error information for failed requests. */ diff --git a/debugoverlay-extension-okhttp/src/main/kotlin/com/ms/square/debugoverlay/extension/okhttp/DebugOverlayNetworkInterceptor.kt b/debugoverlay-extension-okhttp/src/main/kotlin/com/ms/square/debugoverlay/extension/okhttp/DebugOverlayNetworkInterceptor.kt index b3b7b7e..39f42dc 100644 --- a/debugoverlay-extension-okhttp/src/main/kotlin/com/ms/square/debugoverlay/extension/okhttp/DebugOverlayNetworkInterceptor.kt +++ b/debugoverlay-extension-okhttp/src/main/kotlin/com/ms/square/debugoverlay/extension/okhttp/DebugOverlayNetworkInterceptor.kt @@ -2,7 +2,6 @@ package com.ms.square.debugoverlay.extension.okhttp import com.ms.square.debugoverlay.NetworkRequestTracker import com.ms.square.debugoverlay.extension.okhttp.internal.isProbablyUtf8 -import com.ms.square.debugoverlay.model.HttpMethod import com.ms.square.debugoverlay.model.NetworkError import com.ms.square.debugoverlay.model.NetworkRequest import kotlinx.coroutines.flow.Flow @@ -402,9 +401,8 @@ public class DebugOverlayNetworkInterceptor( val redactUrl = redactUrl(url) val newRequest = NetworkRequest( protocol = protocol, - method = method.toHttpMethod(), - fullUrl = redactUrl.toString(), - shortUrl = redactUrl.toShortUrlString(), + method = method, + url = redactUrl.toString(), statusCode = statusCode, durationMs = durationMs, responseSize = responseData?.contentSize, @@ -511,20 +509,6 @@ private fun createErrorFromResponse(response: Response, body: String?): NetworkE private fun MediaType?.charsetOrUtf8(): Charset = this?.charset() ?: Charsets.UTF_8 -private fun String.toHttpMethod(): HttpMethod = when (this) { - "GET" -> HttpMethod.GET - "POST" -> HttpMethod.POST - "PUT" -> HttpMethod.PUT - "DELETE" -> HttpMethod.DELETE - "PATCH" -> HttpMethod.PATCH - "HEAD" -> HttpMethod.HEAD - "OPTIONS" -> HttpMethod.OPTIONS - "TRACE" -> HttpMethod.TRACE - else -> HttpMethod.UNKNOWN -} - -private fun HttpUrl.toShortUrlString(): String = encodedPath + (encodedQuery?.let { "?$it" } ?: "") - private data class NetworkData( val headers: Map, val contentType: String?, From 4e64214bb1eaab0b5860e6ea00b05ac8286ce414 Mon Sep 17 00:00:00 2001 From: Manabu-GT Date: Thu, 4 Dec 2025 19:25:56 -0700 Subject: [PATCH 2/2] cr fixes --- debugoverlay-core/build.gradle.kts | 3 + .../debugoverlay/internal/data/TextType.kt | 5 +- .../internal/ui/LogEntryDetailBottomSheet.kt | 11 +- .../ui/NetworkRequestDetailBottomSheet.kt | 57 ++------ .../square/debugoverlay/internal/ui/Texts.kt | 129 +++--------------- .../debugoverlay/internal/util/Clipboards.kt | 14 ++ .../debugoverlay/internal/util/Formatters.kt | 97 ++----------- .../src/main/res/values/strings.xml | 2 +- gradle/libs.versions.toml | 2 + 9 files changed, 70 insertions(+), 250 deletions(-) create mode 100644 debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/util/Clipboards.kt diff --git a/debugoverlay-core/build.gradle.kts b/debugoverlay-core/build.gradle.kts index adf2c0c..0763f43 100644 --- a/debugoverlay-core/build.gradle.kts +++ b/debugoverlay-core/build.gradle.kts @@ -66,6 +66,9 @@ dependencies { debugImplementation(libs.androidx.compose.ui.tooling) + // Json + implementation(libs.kotlinx.serialization.json) + // Lifecycle for synthetic lifecycle owner implementation(libs.androidx.lifecycle.runtime) implementation(libs.androidx.lifecycle.runtime.ktx) diff --git a/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/data/TextType.kt b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/data/TextType.kt index 87aea35..fb58fce 100644 --- a/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/data/TextType.kt +++ b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/data/TextType.kt @@ -25,7 +25,10 @@ internal enum class TextType { val trimmed = body.trimStart() return when { trimmed.startsWith("{") || trimmed.startsWith("[") -> JSON - trimmed.startsWith(" HTML + trimmed.startsWith( + " HTML trimmed.startsWith(" XML else -> PLAIN } diff --git a/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/ui/LogEntryDetailBottomSheet.kt b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/ui/LogEntryDetailBottomSheet.kt index 4b1995d..b0fab26 100644 --- a/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/ui/LogEntryDetailBottomSheet.kt +++ b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/ui/LogEntryDetailBottomSheet.kt @@ -1,6 +1,5 @@ package com.ms.square.debugoverlay.internal.ui -import android.content.ClipData import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -28,7 +27,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -39,7 +37,7 @@ import androidx.compose.ui.unit.sp import com.ms.square.debugoverlay.core.R import com.ms.square.debugoverlay.internal.data.model.LogcatEntry import com.ms.square.debugoverlay.internal.data.model.toColor -import kotlinx.coroutines.launch +import com.ms.square.debugoverlay.internal.util.copyToClipboard private const val BOTTOM_SHEET_HEIGHT_FRACTION = 0.8f private const val TIMESTAMP_DISPLAY_LENGTH = 12 // HH:MM:SS.mmm @@ -236,11 +234,8 @@ private fun DetailActionButtons(logEntry: LogcatEntry, onFilterTag: (String) -> // Copy button Button( onClick = { - scope.launch { - val clipboardLabel = context.getString(R.string.debugoverlay_clipboard_label) - val clipEntry = ClipEntry(ClipData.newPlainText(clipboardLabel, logEntry.rawLine)) - clipboard.setClipEntry(clipEntry) - } + val clipboardLabel = context.getString(R.string.debugoverlay_clipboard_label_logcat) + scope.copyToClipboard(clipboard, logEntry.rawLine, clipboardLabel) }, modifier = Modifier.weight(1f) ) { diff --git a/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/ui/NetworkRequestDetailBottomSheet.kt b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/ui/NetworkRequestDetailBottomSheet.kt index df74ff3..15fb64c 100644 --- a/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/ui/NetworkRequestDetailBottomSheet.kt +++ b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/ui/NetworkRequestDetailBottomSheet.kt @@ -2,7 +2,6 @@ package com.ms.square.debugoverlay.internal.ui -import android.content.ClipData import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -45,9 +44,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.ClipEntry import androidx.compose.ui.platform.LocalClipboard -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight @@ -56,12 +53,12 @@ import androidx.compose.ui.unit.sp 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.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.model.NetworkRequest -import kotlinx.coroutines.launch private const val BOTTOM_SHEET_HEIGHT_FRACTION = 0.8f @@ -153,7 +150,6 @@ private fun NetworkRequestDetailContent( @Composable private fun NetworkDetailHeader(request: NetworkRequest, onDismiss: () -> Unit, modifier: Modifier = Modifier) { val clipboard = LocalClipboard.current - val context = LocalContext.current val scope = rememberCoroutineScope() val domain = remember(request.url) { UrlParts.from(request.url).domain } @@ -192,11 +188,7 @@ private fun NetworkDetailHeader(request: NetworkRequest, onDismiss: () -> Unit, // Actions Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { IconButton(onClick = { - scope.launch { - val clipboardLabel = context.getString(R.string.debugoverlay_clipboard_label) - val clipEntry = ClipEntry(ClipData.newPlainText(clipboardLabel, request.url)) - clipboard.setClipEntry(clipEntry) - } + scope.copyToClipboard(clipboard, request.url) }) { Icon( imageVector = Icons.Default.ContentCopy, @@ -221,7 +213,6 @@ private fun NetworkDetailHeader(request: NetworkRequest, onDismiss: () -> Unit, @Composable private fun OverviewTab(request: NetworkRequest, modifier: Modifier = Modifier) { val clipboard = LocalClipboard.current - val context = LocalContext.current val scope = rememberCoroutineScope() LazyColumn( @@ -241,11 +232,7 @@ private fun OverviewTab(request: NetworkRequest, modifier: Modifier = Modifier) DetailSection( title = "URL", onCopy = { - scope.launch { - val clipboardLabel = context.getString(R.string.debugoverlay_clipboard_label) - val clipEntry = ClipEntry(ClipData.newPlainText(clipboardLabel, request.url)) - clipboard.setClipEntry(clipEntry) - } + scope.copyToClipboard(clipboard, request.url) } ) { UrlDisplay(url = request.url) @@ -305,7 +292,6 @@ private fun OverviewTab(request: NetworkRequest, modifier: Modifier = Modifier) @Composable private fun HeadersTab(request: NetworkRequest, modifier: Modifier = Modifier) { val clipboard = LocalClipboard.current - val context = LocalContext.current val scope = rememberCoroutineScope() var requestHeadersExpanded by remember { mutableStateOf(true) } var responseHeadersExpanded by remember { mutableStateOf(true) } @@ -324,14 +310,10 @@ private fun HeadersTab(request: NetworkRequest, modifier: Modifier = Modifier) { onToggleExpand = { requestHeadersExpanded = !requestHeadersExpanded }, onCopy = if (request.requestHeaders.isNotEmpty()) { { - scope.launch { - val text = request.requestHeaders.entries.joinToString("\n") { - "${it.key}: ${it.value}" - } - val clipboardLabel = context.getString(R.string.debugoverlay_clipboard_label) - val clipEntry = ClipEntry(ClipData.newPlainText(clipboardLabel, text)) - clipboard.setClipEntry(clipEntry) + val text = request.requestHeaders.entries.joinToString("\n") { + "${it.key}: ${it.value}" } + scope.copyToClipboard(clipboard, text) } } else { null @@ -360,14 +342,10 @@ private fun HeadersTab(request: NetworkRequest, modifier: Modifier = Modifier) { onToggleExpand = { responseHeadersExpanded = !responseHeadersExpanded }, onCopy = if (request.responseHeaders.isNotEmpty()) { { - scope.launch { - val text = request.responseHeaders.entries.joinToString("\n") { - "${it.key}: ${it.value}" - } - val clipboardLabel = context.getString(R.string.debugoverlay_clipboard_label) - val clipEntry = ClipEntry(ClipData.newPlainText(clipboardLabel, text)) - clipboard.setClipEntry(clipEntry) + val text = request.responseHeaders.entries.joinToString("\n") { + "${it.key}: ${it.value}" } + scope.copyToClipboard(clipboard, text) } } else { null @@ -395,7 +373,6 @@ private fun HeadersTab(request: NetworkRequest, modifier: Modifier = Modifier) { @Composable private fun BodyTab(request: NetworkRequest, modifier: Modifier = Modifier) { val clipboard = LocalClipboard.current - val context = LocalContext.current val scope = rememberCoroutineScope() LazyColumn( @@ -409,11 +386,7 @@ private fun BodyTab(request: NetworkRequest, modifier: Modifier = Modifier) { title = "Request Body", onCopy = request.requestBody?.let { { - scope.launch { - val clipboardLabel = context.getString(R.string.debugoverlay_clipboard_label) - val clipEntry = ClipEntry(ClipData.newPlainText(clipboardLabel, it)) - clipboard.setClipEntry(clipEntry) - } + scope.copyToClipboard(clipboard, it) } } ) { @@ -434,11 +407,7 @@ private fun BodyTab(request: NetworkRequest, modifier: Modifier = Modifier) { title = "Response Body", onCopy = request.responseBody?.let { { - scope.launch { - val clipboardLabel = context.getString(R.string.debugoverlay_clipboard_label) - val clipEntry = ClipEntry(ClipData.newPlainText(clipboardLabel, it)) - clipboard.setClipEntry(clipEntry) - } + scope.copyToClipboard(clipboard, it) } } ) { @@ -673,9 +642,9 @@ private fun HeaderItem(name: String, value: String, modifier: Modifier = Modifie * Body preview. */ @Composable -private fun BodyPreview(body: String, contentType: String?, modifier: Modifier = Modifier) { +private fun BodyPreview(body: String, contentType: String?) { val textType = remember(body, contentType) { TextType.from(body, contentType) } - TextPreview(body, textType, modifier) + TextPreview(body, textType) } /** diff --git a/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/ui/Texts.kt b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/ui/Texts.kt index 9421724..e77fc0f 100644 --- a/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/ui/Texts.kt +++ b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/ui/Texts.kt @@ -1,16 +1,13 @@ package com.ms.square.debugoverlay.internal.ui -import android.content.ClipData import android.graphics.Color import android.graphics.Typeface import android.view.ViewGroup -import android.webkit.WebView import android.widget.TextView import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -21,90 +18,44 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.LocalClipboard -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView -import com.ms.square.debugoverlay.core.R import com.ms.square.debugoverlay.internal.data.TextType -import com.ms.square.debugoverlay.internal.util.formatJsonAsHtml -import com.ms.square.debugoverlay.internal.util.formatPlainTextAsHtml +import com.ms.square.debugoverlay.internal.util.copyToClipboard +import com.ms.square.debugoverlay.internal.util.formatJson import com.ms.square.debugoverlay.internal.util.formatTextSize -import kotlinx.coroutines.launch -private const val SMALL_TEXT_THRESHOLD = 10_000 // Use Compose Text -private const val LARGE_TEXT_THRESHOLD = 500_000 // Use TextView, above this truncate +private const val COMPOSE_TEXT_MAX_SIZE = 10_000 // Use Compose Text +private const val TEXT_VIEW_MAX_SIZE = 500_000 // Use TextView, above this truncate /** * Text preview with four-tier performance optimization for large texts. */ @Composable -internal fun TextPreview(text: String, textType: TextType, modifier: Modifier = Modifier) { +internal fun TextPreview(text: String, textType: TextType) { when { - // Tier 1: Small texts - Use Compose Text (best UX, integrated styling) - text.length < SMALL_TEXT_THRESHOLD -> { - CompactTextPreview(text, modifier) - } - // Tier 2: Structured data - Offer formatted view - textType == TextType.JSON || textType == TextType.HTML -> { - StructuredTextPreview(text, textType, modifier) - } - // Tier 3: Large plain text - Use native TextView (performant) - text.length < LARGE_TEXT_THRESHOLD -> { - TextViewTextPreview(text, modifier) - } - // Tier 4: Very large - Truncate (always instant) - else -> { - TruncatedTextPreview(text, modifier) - } - } -} - -/** - * Structured text data preview with raw/formatted toggle. - */ -@Composable -private fun StructuredTextPreview(text: String, textType: TextType, modifier: Modifier = Modifier) { - var showFormatted by remember { mutableStateOf(false) } + // Tier 1: Small - Compose Text + text.length < COMPOSE_TEXT_MAX_SIZE -> CompactTextPreview(text) - Column(modifier = modifier.fillMaxWidth()) { - // Toggle buttons - Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Button( - onClick = { showFormatted = false }, - modifier = Modifier.weight(1f) - ) { - Text("Raw") - } - Button( - onClick = { showFormatted = true }, - modifier = Modifier.weight(1f) - ) { - Text("Formatted") + // Tier 2: Medium/Large - TextView (formatted if JSON) + text.length < TEXT_VIEW_MAX_SIZE -> { + val formatted = if (textType == TextType.JSON) { + formatJson(text) + } else { + text } + TextViewTextPreview(formatted) } - if (showFormatted) { - WebViewTextPreview(text, textType) - } else { - TextViewTextPreview(text) - } + // Tier 3: Very Large - Truncate + else -> TruncatedTextPreview(text) } } @@ -146,43 +97,6 @@ private fun TextViewTextPreview(text: String, modifier: Modifier = Modifier) { } } -/** - * WebView preview for formatted JSON/HTML text. - */ -@Composable -private fun WebViewTextPreview(text: String, textType: TextType, modifier: Modifier = Modifier) { - Surface( - modifier = modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surfaceContainerLowest, - tonalElevation = 1.dp - ) { - AndroidView( - factory = { context -> - WebView(context).apply { - settings.javaScriptEnabled = false // Security: No JS needed - settings.builtInZoomControls = true - settings.displayZoomControls = false - settings.setSupportZoom(true) - setBackgroundColor(Color.TRANSPARENT) - } - }, - update = { webView -> - val html = when (textType) { - TextType.JSON -> formatJsonAsHtml(text) - // For now, just display the raw HTML for BodyType.HTML. - // Could add a toggle to show rendered vs source - else -> formatPlainTextAsHtml(text) - } - webView.loadDataWithBaseURL(null, html, "text/html", "UTF-8", null) - }, - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 200.dp, max = 600.dp) - ) - } -} - /** * Compose Text preview for short texts. */ @@ -212,7 +126,6 @@ internal fun CompactTextPreview(body: String, modifier: Modifier = Modifier) { @Composable internal fun TruncatedTextPreview(text: String, modifier: Modifier = Modifier) { val clipboard = LocalClipboard.current - val context = LocalContext.current val scope = rememberCoroutineScope() Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) { @@ -235,7 +148,7 @@ internal fun TruncatedTextPreview(text: String, modifier: Modifier = Modifier) { Text( text = "Text too large (${formatTextSize(text.length)}). Showing first ${ formatTextSize( - SMALL_TEXT_THRESHOLD + COMPOSE_TEXT_MAX_SIZE ) }.", style = MaterialTheme.typography.bodySmall, @@ -245,16 +158,12 @@ internal fun TruncatedTextPreview(text: String, modifier: Modifier = Modifier) { } // Truncated preview - CompactTextPreview(body = text.take(SMALL_TEXT_THRESHOLD)) + CompactTextPreview(body = text.take(COMPOSE_TEXT_MAX_SIZE)) // Copy full body button Button( onClick = { - scope.launch { - val clipboardLabel = context.getString(R.string.debugoverlay_clipboard_label) - val clipEntry = ClipEntry(ClipData.newPlainText(clipboardLabel, text)) - clipboard.setClipEntry(clipEntry) - } + scope.copyToClipboard(clipboard, text) }, modifier = Modifier.fillMaxWidth() ) { 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 new file mode 100644 index 0000000..c9d8455 --- /dev/null +++ b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/util/Clipboards.kt @@ -0,0 +1,14 @@ +package com.ms.square.debugoverlay.internal.util + +import android.content.ClipData +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.Clipboard +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +internal fun CoroutineScope.copyToClipboard(clipboard: Clipboard, text: String, label: String = "") { + launch { + val clipEntry = ClipEntry(ClipData.newPlainText(label, text)) + clipboard.setClipEntry(clipEntry) + } +} diff --git a/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/util/Formatters.kt b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/util/Formatters.kt index 0adf455..acbda84 100644 --- a/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/util/Formatters.kt +++ b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/util/Formatters.kt @@ -1,5 +1,7 @@ package com.ms.square.debugoverlay.internal.util +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -8,6 +10,10 @@ private const val BYTES_PER_KB = 1024L private const val BYTES_PER_MB = 1024L * 1024L private const val BYTES_PER_GB = 1024L * 1024L * 1024L +private val JSON_FORMATTER = Json { + prettyPrint = true +} + /** * Format bytes to human-readable string. */ @@ -38,90 +44,9 @@ internal fun formatTimestamp(timestamp: Long): String { return formatter.format(date) } -/** - * Format JSON as syntax-highlighted HTML. - */ -@Suppress("MaxLineLength") // HTML template -internal fun formatJsonAsHtml(json: String): String { - val escaped = json - .replace("&", "&") - .replace("<", "<") - .replace(">", ">") - .replace("\"", """) - - // Simple syntax highlighting with regex - val highlighted = escaped - .replace(Regex("""("[^&]+")\s*:"""), """$1:""") // Keys - .replace(Regex(""":\s*("[^&]+")"""), """: $1""") // String values - .replace(Regex("""\b(\d+\.?\d*)\b"""), """$1""") // Numbers - .replace(Regex("""\b(true|false)\b"""), """$1""") // Booleans - .replace(Regex("""\b(null)\b"""), """$1""") // Null - - return """ - - - - - - - -
$highlighted
- - - """.trimIndent() -} - -/** - * Format plain text as HTML. - */ -internal fun formatPlainTextAsHtml(text: String): String { - val escaped = text - .replace("&", "&") - .replace("<", "<") - .replace(">", ">") - - return """ - - - - - - - -
$escaped
- - - """.trimIndent() +internal fun formatJson(json: String): String = try { + val element = Json.parseToJsonElement(json) + JSON_FORMATTER.encodeToString(JsonElement.serializer(), element) +} catch (_: Exception) { + json } diff --git a/debugoverlay-core/src/main/res/values/strings.xml b/debugoverlay-core/src/main/res/values/strings.xml index 1dcae5c..c28f2f3 100644 --- a/debugoverlay-core/src/main/res/values/strings.xml +++ b/debugoverlay-core/src/main/res/values/strings.xml @@ -1,7 +1,7 @@ No log entries - Logcat Line + Logcat Line Copy log to clipboard Copied to clipboard Scroll to bottom diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4df0915..17926c7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,6 +2,7 @@ # Plugins agp = "8.13.0" dexcount = "4.0.0" +kotlinxSerializationJson = "1.9.0" maven-publish = "0.34.0" spotless = "8.0.0" detekt = "1.23.8" @@ -86,6 +87,7 @@ okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } coil = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } # Testing junit4 = { module = "junit:junit", version.ref = "junit4" }