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 new file mode 100644 index 0000000..fb58fce --- /dev/null +++ b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/data/TextType.kt @@ -0,0 +1,37 @@ +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/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 new file mode 100644 index 0000000..15fb64c --- /dev/null +++ b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/ui/NetworkRequestDetailBottomSheet.kt @@ -0,0 +1,738 @@ +@file:Suppress("TooManyFunctions") + +package com.ms.square.debugoverlay.internal.ui + +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.LocalClipboard +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.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 + +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 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.copyToClipboard(clipboard, request.url) + }) { + 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 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.copyToClipboard(clipboard, request.url) + } + ) { + 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 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()) { + { + val text = request.requestHeaders.entries.joinToString("\n") { + "${it.key}: ${it.value}" + } + scope.copyToClipboard(clipboard, text) + } + } 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()) { + { + val text = request.responseHeaders.entries.joinToString("\n") { + "${it.key}: ${it.value}" + } + scope.copyToClipboard(clipboard, text) + } + } 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 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.copyToClipboard(clipboard, it) + } + } + ) { + 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.copyToClipboard(clipboard, it) + } + } + ) { + 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?) { + val textType = remember(body, contentType) { TextType.from(body, contentType) } + TextPreview(body, textType) +} + +/** + * 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..e77fc0f --- /dev/null +++ b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/ui/Texts.kt @@ -0,0 +1,173 @@ +package com.ms.square.debugoverlay.internal.ui + +import android.graphics.Color +import android.graphics.Typeface +import android.view.ViewGroup +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.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.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalClipboard +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.internal.data.TextType +import com.ms.square.debugoverlay.internal.util.copyToClipboard +import com.ms.square.debugoverlay.internal.util.formatJson +import com.ms.square.debugoverlay.internal.util.formatTextSize + +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) { + when { + // Tier 1: Small - Compose Text + text.length < COMPOSE_TEXT_MAX_SIZE -> CompactTextPreview(text) + + // 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) + } + + // Tier 3: Very Large - Truncate + else -> TruncatedTextPreview(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) + ) + } +} + +/** + * 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 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( + COMPOSE_TEXT_MAX_SIZE + ) + }.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onTertiaryContainer + ) + } + } + + // Truncated preview + CompactTextPreview(body = text.take(COMPOSE_TEXT_MAX_SIZE)) + + // Copy full body button + Button( + onClick = { + scope.copyToClipboard(clipboard, text) + }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Copy Full text (${formatTextSize(text.length)})") + } + } +} 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/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..acbda84 --- /dev/null +++ b/debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/util/Formatters.kt @@ -0,0 +1,52 @@ +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 + +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. + */ +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) +} + +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/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-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/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?, 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" }