diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..205ed04 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,32 @@ +# Project Guidelines + +## Commit & PR Rules + +- Do NOT include `Co-Authored-By` lines referencing Codex in commit messages. +- Do NOT include "Generated with Codex" or similar AI attribution in PR descriptions. +- Do NOT use a `codex/` branch prefix. Follow the existing type-based convention, such as `feat/`, `fix/`, `chore/`, `docs/`, or `refactor/`. +- **Keep commit messages compact.** Subject ≤ 72 chars. Body only when the "why" isn't obvious from the diff — 1–3 short lines, no multi-paragraph essays. Don't restate what the diff already shows. + +## Code Style + +- Do NOT use fully-qualified names (FQN) inline in Kotlin code. Always add a proper `import` statement at the top of the file and reference the type by its simple name. This applies to both production and test code. +- **Keep comments compact.** Explain non-obvious intent in one or two lines. No multi-paragraph KDoc unless the API is genuinely complex. Skip comments that just restate the code. + +## Code Style + +- Do NOT use fully-qualified names (FQN) inline in Kotlin code. Always add a proper `import` statement at the top of the file and reference the type by its simple name. This applies to both production and test code. + +## Issues + +- Write all issues in **English**. +- Use clear section headers: `## Summary`, `## Problem` / `## Motivation`, `## Proposed Approach` / `## Proposed Behavior`, etc. +- Include relevant code snippets, color values, or architecture details where helpful. + +## Release + +- Always run `./gradlew clean` before `publishAllPublicationsToMavenCentralRepository` to ensure freshly compiled artifacts are uploaded (not stale build cache). + +## Skills + +- Code review follow-up: use the `/resolve-coderabbit-review` skill to triage and apply CodeRabbit comments on the current PR. +- Release: use the `/release` skill to publish to Maven Central and draft GitHub release notes. diff --git a/dari/src/main/kotlin/com/easyhooon/dari/Dari.kt b/dari/src/main/kotlin/com/easyhooon/dari/Dari.kt index 29e612d..c378396 100644 --- a/dari/src/main/kotlin/com/easyhooon/dari/Dari.kt +++ b/dari/src/main/kotlin/com/easyhooon/dari/Dari.kt @@ -97,6 +97,14 @@ object Dari { preferences.setDarkMode(value) } + /** + * Enable or disable controls for folding nested JSON objects and arrays. + * Persists across process restarts. + */ + fun setJsonFoldingEnabled(enabled: Boolean) { + preferences.setJsonFoldingEnabled(enabled) + } + private fun applyShakeToOpen(enabled: Boolean) { shakeManager?.unregister() shakeManager = if (enabled) { diff --git a/dari/src/main/kotlin/com/easyhooon/dari/data/DariPreferences.kt b/dari/src/main/kotlin/com/easyhooon/dari/data/DariPreferences.kt index 2b663ff..fe8ed66 100644 --- a/dari/src/main/kotlin/com/easyhooon/dari/data/DariPreferences.kt +++ b/dari/src/main/kotlin/com/easyhooon/dari/data/DariPreferences.kt @@ -16,7 +16,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking /** - * Persists user-toggled Dari settings (shake-to-open, dark mode) so changes + * Persists user-toggled Dari settings so changes * survive process restarts and override the initial [com.easyhooon.dari.DariConfig] * defaults. * @@ -41,15 +41,16 @@ internal class DariPreferences( /** User override for dark mode; `null` means "follow the system theme". */ private val _darkMode = MutableStateFlow(null) + private val _jsonFoldingEnabled = MutableStateFlow(true) + init { - // One-shot blocking read so [shakeToOpen] and [darkMode] are correct - // for the very first synchronous caller (e.g. Dari.init's shake - // registration, or Compose initialValue). DataStore file is tiny so - // this takes ~a few ms on cold start. + // One-shot blocking read so settings are correct for the first synchronous + // caller (e.g. Dari.init or Compose initialValue). runBlocking { val snapshot = dataStore.data.first() _shakeToOpen.value = snapshot[KEY_SHAKE_TO_OPEN] ?: defaultShakeToOpen _darkMode.value = snapshot[KEY_DARK_MODE] + _jsonFoldingEnabled.value = snapshot[KEY_JSON_FOLDING_ENABLED] ?: true } // Keep the StateFlows in sync with any subsequent DataStore writes. @@ -57,6 +58,7 @@ internal class DariPreferences( dataStore.data.collect { prefs -> _shakeToOpen.value = prefs[KEY_SHAKE_TO_OPEN] ?: defaultShakeToOpen _darkMode.value = prefs[KEY_DARK_MODE] + _jsonFoldingEnabled.value = prefs[KEY_JSON_FOLDING_ENABLED] ?: true } } } @@ -95,8 +97,23 @@ internal class DariPreferences( // endregion + // region JSON folding + + val jsonFoldingEnabled: Boolean get() = _jsonFoldingEnabled.value + + fun jsonFoldingEnabledFlow(): Flow = _jsonFoldingEnabled.asStateFlow() + + fun setJsonFoldingEnabled(value: Boolean) { + scope.launch { + dataStore.edit { it[KEY_JSON_FOLDING_ENABLED] = value } + } + } + + // endregion + companion object { private val KEY_SHAKE_TO_OPEN = booleanPreferencesKey("shake_to_open") private val KEY_DARK_MODE = booleanPreferencesKey("dark_mode") + private val KEY_JSON_FOLDING_ENABLED = booleanPreferencesKey("json_folding_enabled") } } diff --git a/dari/src/main/kotlin/com/easyhooon/dari/ui/DariActivity.kt b/dari/src/main/kotlin/com/easyhooon/dari/ui/DariActivity.kt index 9af3ec3..83bda84 100644 --- a/dari/src/main/kotlin/com/easyhooon/dari/ui/DariActivity.kt +++ b/dari/src/main/kotlin/com/easyhooon/dari/ui/DariActivity.kt @@ -151,6 +151,10 @@ class DariActivity : ComponentActivity() { val shakeToOpen by Dari.preferences.shakeToOpenFlow().collectAsStateWithLifecycle( initialValue = Dari.preferences.shakeToOpen, ) + val jsonFoldingEnabled by Dari.preferences.jsonFoldingEnabledFlow() + .collectAsStateWithLifecycle( + initialValue = Dari.preferences.jsonFoldingEnabled, + ) var isSearchMode by rememberSaveable { mutableStateOf(false) } var searchQuery by rememberSaveable { mutableStateOf("") } var selectedTag by rememberSaveable { mutableStateOf(null) } @@ -396,6 +400,8 @@ class DariActivity : ComponentActivity() { onShakeToOpenChange = { Dari.setShakeToOpenEnabled(it) }, darkMode = darkMode, onDarkModeChange = { Dari.setDarkMode(it) }, + jsonFoldingEnabled = jsonFoldingEnabled, + onJsonFoldingEnabledChange = { Dari.setJsonFoldingEnabled(it) }, onClearMessages = { showSettingsSheet = false showClearDialog = true diff --git a/dari/src/main/kotlin/com/easyhooon/dari/ui/DariDetailActivity.kt b/dari/src/main/kotlin/com/easyhooon/dari/ui/DariDetailActivity.kt index 01b2f2f..19b34f4 100644 --- a/dari/src/main/kotlin/com/easyhooon/dari/ui/DariDetailActivity.kt +++ b/dari/src/main/kotlin/com/easyhooon/dari/ui/DariDetailActivity.kt @@ -119,6 +119,10 @@ class DariDetailActivity : ComponentActivity() { val darkMode by Dari.preferences.darkModeFlow().collectAsStateWithLifecycle( initialValue = Dari.preferences.darkMode, ) + val jsonFoldingEnabled by Dari.preferences.jsonFoldingEnabledFlow() + .collectAsStateWithLifecycle( + initialValue = Dari.preferences.jsonFoldingEnabled, + ) val isDark = darkMode ?: isSystemInDarkTheme() ApplyDariSystemBars(isDark) DariTheme(darkTheme = darkMode) { @@ -199,7 +203,7 @@ class DariDetailActivity : ComponentActivity() { if (entry == null) { Text("Message not found", modifier = Modifier.padding(16.dp)) } else { - DetailTabs(entry) + DetailTabs(entry, jsonFoldingEnabled) } } @@ -212,7 +216,10 @@ class DariDetailActivity : ComponentActivity() { private val TAB_TITLES = listOf("OVERVIEW", "REQUEST", "RESPONSE") @Composable -private fun DetailTabs(entry: MessageEntry) { +private fun DetailTabs( + entry: MessageEntry, + jsonFoldingEnabled: Boolean, +) { val pagerState = rememberPagerState(pageCount = { TAB_TITLES.size }) val coroutineScope = rememberCoroutineScope() @@ -255,8 +262,8 @@ private fun DetailTabs(entry: MessageEntry) { ) { page -> when (page) { 0 -> OverviewTab(entry) - 1 -> DataTab(entry.requestData, entry.requestPayloadMetadata) - 2 -> DataTab(entry.responseData, entry.responsePayloadMetadata) + 1 -> DataTab(entry.requestData, entry.requestPayloadMetadata, jsonFoldingEnabled) + 2 -> DataTab(entry.responseData, entry.responsePayloadMetadata, jsonFoldingEnabled) } } } @@ -343,7 +350,11 @@ private enum class PayloadViewMode { } @Composable -private fun DataTab(data: String?, metadata: MessagePayloadMetadata?) { +private fun DataTab( + data: String?, + metadata: MessagePayloadMetadata?, + jsonFoldingEnabled: Boolean, +) { val rawPreview = metadata?.rawPreview var viewMode by remember(rawPreview?.base64) { mutableStateOf(PayloadViewMode.DECODED) } @@ -372,18 +383,20 @@ private fun DataTab(data: String?, metadata: MessagePayloadMetadata?) { if (viewMode == PayloadViewMode.RAW && rawPreview != null) { RawPayloadView(metadata) } else { - DecodedPayloadView(data) + DecodedPayloadView(data, jsonFoldingEnabled) } } } } @Composable -private fun DecodedPayloadView(data: String?) { - Column( +private fun DecodedPayloadView( + data: String?, + jsonFoldingEnabled: Boolean, +) { + Box( modifier = Modifier .fillMaxSize() - .verticalScroll(rememberScrollState()) .padding(16.dp), ) { if (data.isNullOrBlank()) { @@ -393,7 +406,11 @@ private fun DecodedPayloadView(data: String?) { color = MaterialTheme.colorScheme.onSurfaceVariant, ) } else { - JsonViewer(jsonString = data) + JsonViewer( + jsonString = data, + foldingEnabled = jsonFoldingEnabled, + modifier = Modifier.fillMaxSize(), + ) } } } diff --git a/dari/src/main/kotlin/com/easyhooon/dari/ui/components/JsonViewer.kt b/dari/src/main/kotlin/com/easyhooon/dari/ui/components/JsonViewer.kt index 53b2e0d..ec27698 100644 --- a/dari/src/main/kotlin/com/easyhooon/dari/ui/components/JsonViewer.kt +++ b/dari/src/main/kotlin/com/easyhooon/dari/ui/components/JsonViewer.kt @@ -1,44 +1,244 @@ package com.easyhooon.dari.ui.components import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement - -@OptIn(ExperimentalSerializationApi::class) -private val prettyJson = Json { - prettyPrint = true - prettyPrintIndent = " " -} +import kotlinx.serialization.json.JsonPrimitive /** - * Composable that pretty-prints and displays a JSON string. + * Composable that displays valid JSON as an expandable tree. */ @Composable -internal fun JsonViewer(jsonString: String) { - val formatted = remember(jsonString) { - try { - val element = prettyJson.parseToJsonElement(jsonString) - prettyJson.encodeToString(JsonElement.serializer(), element) - } catch (_: Exception) { - jsonString +internal fun JsonViewer( + jsonString: String, + foldingEnabled: Boolean, + modifier: Modifier = Modifier, +) { + val content = remember(jsonString) { parseJsonViewerContent(jsonString) } + when (content) { + is JsonViewerContent.Structured -> JsonTreeViewer( + element = content.element, + stateKey = jsonString, + foldingEnabled = foldingEnabled, + modifier = modifier, + ) + + is JsonViewerContent.PlainText -> Box( + modifier = modifier.verticalScroll(rememberScrollState()), + ) { + CodeViewer(content.text) + } + } +} + +@Composable +private fun JsonTreeViewer( + element: JsonElement, + stateKey: String, + foldingEnabled: Boolean, + modifier: Modifier = Modifier, +) { + val collapsedPaths = remember(stateKey, foldingEnabled) { + mutableStateMapOf().apply { + if (foldingEnabled) { + defaultCollapsedPaths(element).forEach { path -> this[path] = Unit } + } } } + val rows = buildJsonTreeRows( + element = element, + collapsedPaths = collapsedPaths.keys, + foldingEnabled = foldingEnabled, + ) + val horizontalScrollState = rememberScrollState() + val shape = RoundedCornerShape(8.dp) - CodeViewer(formatted) + BoxWithConstraints( + modifier = modifier + .fillMaxWidth() + .clip(shape) + .background(MaterialTheme.colorScheme.surfaceVariant), + ) { + val viewportWidth = maxWidth + Row( + modifier = Modifier + .fillMaxSize() + .horizontalScroll(horizontalScrollState), + ) { + LazyColumn( + modifier = Modifier + .fillMaxHeight() + .widthIn(min = viewportWidth) + .padding(vertical = 8.dp), + ) { + items( + items = rows, + key = JsonTreeRow::id, + contentType = { if (it.containerPath != null) "container" else "value" }, + ) { row -> + JsonTreeLine( + row = row, + showDisclosureGutter = foldingEnabled, + onToggle = { path, expanded -> + if (expanded) { + collapsedPaths[path] = Unit + } else { + collapsedPaths.remove(path) + } + }, + ) + } + } + } + } +} + +@Composable +private fun JsonTreeLine( + row: JsonTreeRow, + showDisclosureGutter: Boolean, + onToggle: (path: String, expanded: Boolean) -> Unit, +) { + val containerPath = row.containerPath + val expanded = row.expanded + val toggleDescription = if (expanded == true) { + "Collapse ${row.toggleLabel}" + } else { + "Expand ${row.toggleLabel}" + } + val interactionModifier = if (containerPath != null && expanded != null) { + Modifier + .clickable(role = Role.Button) { onToggle(containerPath, expanded) } + .semantics { contentDescription = toggleDescription } + } else { + Modifier + } + val syntaxColors = jsonSyntaxColors() + val highlightedText = remember(row, syntaxColors) { + row.toAnnotatedString(syntaxColors) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .then(interactionModifier) + .padding(horizontal = 12.dp, vertical = 2.dp), + ) { + Spacer(modifier = Modifier.width((row.depth * JSON_INDENT_DP).dp)) + if (expanded != null) { + Icon( + imageVector = if (expanded) { + Icons.Default.KeyboardArrowDown + } else { + Icons.AutoMirrored.Filled.KeyboardArrowRight + }, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f), + modifier = Modifier.size(JSON_DISCLOSURE_ICON_SIZE), + ) + } else if (showDisclosureGutter && row.depth > 0) { + Spacer(modifier = Modifier.size(JSON_DISCLOSURE_ICON_SIZE)) + } + Text( + text = highlightedText, + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + ), + softWrap = false, + ) + } +} + +private data class JsonSyntaxColors( + val key: Color, + val string: Color, + val number: Color, + val boolean: Color, + val nullValue: Color, + val container: Color, + val punctuation: Color, +) + +@Composable +private fun jsonSyntaxColors(): JsonSyntaxColors { + val colorScheme = MaterialTheme.colorScheme + val isDark = colorScheme.surface.luminance() < 0.5f + return JsonSyntaxColors( + key = if (isDark) Color(0xFF90CAF9) else Color(0xFF1565C0), + string = if (isDark) Color(0xFFA5D6A7) else Color(0xFF2E7D32), + number = if (isDark) Color(0xFFCE93D8) else Color(0xFF7B1FA2), + boolean = if (isDark) Color(0xFFFFCC80) else Color(0xFFEF6C00), + nullValue = if (isDark) Color(0xFFBDBDBD) else Color(0xFF757575), + container = colorScheme.onSurface, + punctuation = colorScheme.onSurfaceVariant, + ) +} + +private fun JsonTreeRow.toAnnotatedString(colors: JsonSyntaxColors): AnnotatedString = buildAnnotatedString { + val keyText = key?.let { JsonPrimitive(it).toString() } + val prefix = keyText?.let { "$it: " }.orEmpty() + if (keyText != null) { + withStyle(SpanStyle(color = colors.key)) { append(keyText) } + withStyle(SpanStyle(color = colors.punctuation)) { append(": ") } + } + + val valueWithComma = text.removePrefix(prefix) + val hasTrailingComma = valueWithComma.endsWith(',') + val value = if (hasTrailingComma) valueWithComma.dropLast(1) else valueWithComma + withStyle(SpanStyle(color = colors.colorFor(tokenType))) { append(value) } + if (hasTrailingComma) { + withStyle(SpanStyle(color = colors.punctuation)) { append(',') } + } +} + +private fun JsonSyntaxColors.colorFor(tokenType: JsonTokenType): Color = when (tokenType) { + JsonTokenType.PUNCTUATION -> punctuation + JsonTokenType.CONTAINER -> container + JsonTokenType.STRING -> string + JsonTokenType.NUMBER -> number + JsonTokenType.BOOLEAN -> boolean + JsonTokenType.NULL -> nullValue } @Composable @@ -59,3 +259,6 @@ internal fun CodeViewer(text: String) { ), ) } + +private val JSON_DISCLOSURE_ICON_SIZE = 14.dp +private const val JSON_INDENT_DP = 14 diff --git a/dari/src/main/kotlin/com/easyhooon/dari/ui/components/JsonViewerModel.kt b/dari/src/main/kotlin/com/easyhooon/dari/ui/components/JsonViewerModel.kt new file mode 100644 index 0000000..047ee97 --- /dev/null +++ b/dari/src/main/kotlin/com/easyhooon/dari/ui/components/JsonViewerModel.kt @@ -0,0 +1,295 @@ +package com.easyhooon.dari.ui.components + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull + +internal sealed interface JsonViewerContent { + data class Structured(val element: JsonElement) : JsonViewerContent + + data class PlainText(val text: String) : JsonViewerContent +} + +internal fun parseJsonViewerContent(text: String): JsonViewerContent = + try { + JsonViewerContent.Structured(Json.parseToJsonElement(text)) + } catch (_: Exception) { + JsonViewerContent.PlainText(text) + } + +internal fun JsonElement.collapsedSummary(): String = when (this) { + is JsonArray -> "[${size} ${if (size == 1) "item" else "items"}]" + is JsonObject -> "{${size} ${if (size == 1) "field" else "fields"}}" + else -> toString() +} + +internal data class JsonTreeRow( + val id: String, + val depth: Int, + val text: String, + val key: String? = null, + val tokenType: JsonTokenType = JsonTokenType.PUNCTUATION, + val containerPath: String? = null, + val expanded: Boolean? = null, + val toggleLabel: String? = null, +) + +internal enum class JsonTokenType { + PUNCTUATION, + CONTAINER, + STRING, + NUMBER, + BOOLEAN, + NULL, +} + +internal fun buildJsonTreeRows( + element: JsonElement, + collapsedPaths: Set, + foldingEnabled: Boolean = true, +): List = buildList { + appendElement( + element = element, + path = ROOT_PATH, + label = "JSON root", + key = null, + depth = 0, + trailingComma = false, + collapsedPaths = collapsedPaths, + foldingEnabled = foldingEnabled, + ) +} + +internal fun defaultCollapsedPaths(element: JsonElement): Set = buildSet { + collectCollapsiblePaths( + element = element, + path = ROOT_PATH, + depth = 0, + ) +} + +private fun MutableList.appendElement( + element: JsonElement, + path: String, + label: String, + key: String?, + depth: Int, + trailingComma: Boolean, + collapsedPaths: Set, + foldingEnabled: Boolean, +) { + when (element) { + is JsonArray -> if (element.isEmpty()) { + appendEmptyContainer(path, key, depth, trailingComma, "[]") + } else { + appendArray( + array = element, + path = path, + label = label, + key = key, + depth = depth, + trailingComma = trailingComma, + collapsedPaths = collapsedPaths, + foldingEnabled = foldingEnabled, + ) + } + + is JsonObject -> if (element.isEmpty()) { + appendEmptyContainer(path, key, depth, trailingComma, "{}") + } else { + appendObject( + jsonObject = element, + path = path, + label = label, + key = key, + depth = depth, + trailingComma = trailingComma, + collapsedPaths = collapsedPaths, + foldingEnabled = foldingEnabled, + ) + } + + else -> add( + JsonTreeRow( + id = "$path:value", + depth = depth, + text = keyPrefix(key) + element + comma(trailingComma), + key = key, + tokenType = element.tokenType(), + ), + ) + } +} + +private fun MutableList.appendEmptyContainer( + path: String, + key: String?, + depth: Int, + trailingComma: Boolean, + brackets: String, +) { + add( + JsonTreeRow( + id = "$path:value", + depth = depth, + text = keyPrefix(key) + brackets + comma(trailingComma), + key = key, + tokenType = JsonTokenType.CONTAINER, + ), + ) +} + +private fun MutableList.appendArray( + array: JsonArray, + path: String, + label: String, + key: String?, + depth: Int, + trailingComma: Boolean, + collapsedPaths: Set, + foldingEnabled: Boolean, +) { + val collapsible = foldingEnabled && path != ROOT_PATH + val expanded = !collapsible || path !in collapsedPaths + appendContainerStart(array, path, label, key, depth, trailingComma, expanded, collapsible, "[") + if (!expanded) return + + array.forEachIndexed { index, child -> + appendElement( + element = child, + path = "$path/$index", + label = "item ${index + 1}", + key = null, + depth = depth + 1, + trailingComma = index < array.lastIndex, + collapsedPaths = collapsedPaths, + foldingEnabled = foldingEnabled, + ) + } + appendContainerEnd(path, depth, trailingComma, "]") +} + +private fun MutableList.appendObject( + jsonObject: JsonObject, + path: String, + label: String, + key: String?, + depth: Int, + trailingComma: Boolean, + collapsedPaths: Set, + foldingEnabled: Boolean, +) { + val collapsible = foldingEnabled && path != ROOT_PATH + val expanded = !collapsible || path !in collapsedPaths + appendContainerStart(jsonObject, path, label, key, depth, trailingComma, expanded, collapsible, "{") + if (!expanded) return + + jsonObject.entries.forEachIndexed { index, (childKey, child) -> + appendElement( + element = child, + path = "$path/${childKey.toJsonPointerSegment()}", + label = childKey, + key = childKey, + depth = depth + 1, + trailingComma = index < jsonObject.size - 1, + collapsedPaths = collapsedPaths, + foldingEnabled = foldingEnabled, + ) + } + appendContainerEnd(path, depth, trailingComma, "}") +} + +private fun MutableList.appendContainerStart( + element: JsonElement, + path: String, + label: String, + key: String?, + depth: Int, + trailingComma: Boolean, + expanded: Boolean, + collapsible: Boolean, + openingBracket: String, +) { + add( + JsonTreeRow( + id = "$path:container", + depth = depth, + text = keyPrefix(key) + + if (expanded) openingBracket else element.collapsedSummary() + comma(trailingComma), + key = key, + tokenType = JsonTokenType.CONTAINER, + containerPath = path.takeIf { collapsible }, + expanded = expanded.takeIf { collapsible }, + toggleLabel = label.takeIf { collapsible }, + ), + ) +} + +private fun MutableSet.collectCollapsiblePaths( + element: JsonElement, + path: String, + depth: Int, +) { + when (element) { + is JsonArray -> { + if (element.isEmpty()) return + if (depth >= DEFAULT_COLLAPSE_DEPTH) add(path) + element.forEachIndexed { index, child -> + collectCollapsiblePaths(child, "$path/$index", depth + 1) + } + } + + is JsonObject -> { + if (element.isEmpty()) return + if (depth >= DEFAULT_COLLAPSE_DEPTH) add(path) + element.forEach { (childKey, child) -> + collectCollapsiblePaths( + element = child, + path = "$path/${childKey.toJsonPointerSegment()}", + depth = depth + 1, + ) + } + } + + else -> Unit + } +} + +private fun JsonElement.tokenType(): JsonTokenType = when (this) { + JsonNull -> JsonTokenType.NULL + is JsonPrimitive -> when { + isString -> JsonTokenType.STRING + booleanOrNull != null -> JsonTokenType.BOOLEAN + else -> JsonTokenType.NUMBER + } + + else -> JsonTokenType.CONTAINER +} + +private fun MutableList.appendContainerEnd( + path: String, + depth: Int, + trailingComma: Boolean, + closingBracket: String, +) { + add( + JsonTreeRow( + id = "$path:end", + depth = depth, + text = closingBracket + comma(trailingComma), + ), + ) +} + +private fun keyPrefix(key: String?): String = key?.let { "${JsonPrimitive(it)}: " }.orEmpty() + +private fun comma(trailingComma: Boolean): String = if (trailingComma) "," else "" + +private fun String.toJsonPointerSegment(): String = replace("~", "~0").replace("/", "~1") + +private const val DEFAULT_COLLAPSE_DEPTH = 2 +private const val ROOT_PATH = "$" diff --git a/dari/src/main/kotlin/com/easyhooon/dari/ui/components/SettingsBottomSheet.kt b/dari/src/main/kotlin/com/easyhooon/dari/ui/components/SettingsBottomSheet.kt index 5532c9d..c7ed4d7 100644 --- a/dari/src/main/kotlin/com/easyhooon/dari/ui/components/SettingsBottomSheet.kt +++ b/dari/src/main/kotlin/com/easyhooon/dari/ui/components/SettingsBottomSheet.kt @@ -4,6 +4,8 @@ import android.content.ActivityNotFoundException import android.content.Intent import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -17,6 +19,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.filled.Brightness6 +import androidx.compose.material.icons.filled.Code import androidx.compose.material.icons.filled.DeleteOutline import androidx.compose.material.icons.filled.Vibration import androidx.compose.material3.ExperimentalMaterial3Api @@ -53,6 +56,8 @@ internal fun SettingsBottomSheet( onShakeToOpenChange: (Boolean) -> Unit, darkMode: Boolean?, onDarkModeChange: (Boolean?) -> Unit, + jsonFoldingEnabled: Boolean, + onJsonFoldingEnabledChange: (Boolean) -> Unit, onClearMessages: () -> Unit, onDismiss: () -> Unit, ) { @@ -67,6 +72,7 @@ internal fun SettingsBottomSheet( Column( modifier = Modifier .fillMaxWidth() + .verticalScroll(rememberScrollState()) .padding(bottom = 24.dp), ) { Text( @@ -90,6 +96,13 @@ internal fun SettingsBottomSheet( darkMode = darkMode, onDarkModeChange = onDarkModeChange, ) + SettingToggleRow( + icon = Icons.Default.Code, + title = "JSON folding", + description = "Show controls to collapse or expand nested objects and arrays", + checked = jsonFoldingEnabled, + onCheckedChange = onJsonFoldingEnabledChange, + ) HorizontalDivider( modifier = Modifier.padding(vertical = 12.dp), diff --git a/dari/src/test/kotlin/com/easyhooon/dari/ui/components/JsonViewerModelTest.kt b/dari/src/test/kotlin/com/easyhooon/dari/ui/components/JsonViewerModelTest.kt new file mode 100644 index 0000000..e9d6cc1 --- /dev/null +++ b/dari/src/test/kotlin/com/easyhooon/dari/ui/components/JsonViewerModelTest.kt @@ -0,0 +1,162 @@ +package com.easyhooon.dari.ui.components + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class JsonViewerModelTest { + @Test + fun `valid nested JSON is parsed as structured content`() { + val content = parseJsonViewerContent( + """{"items":[{"id":1,"name":"A"}],"meta":{"page":1}}""", + ) + + val root = assertType(content).element + val rootObject = assertType(root) + val items = assertType(rootObject["items"]) + assertType(items.single()) + assertType(rootObject["meta"]) + } + + @Test + fun `container summaries include collection sizes`() { + val content = parseJsonViewerContent( + """{"items":[1,2],"emptyItems":[],"meta":{"page":1},"emptyMeta":{}}""", + ) + val root = assertType( + assertType(content).element, + ) + + assertEquals("[2 items]", root.getValue("items").collapsedSummary()) + assertEquals("[0 items]", root.getValue("emptyItems").collapsedSummary()) + assertEquals("{1 field}", root.getValue("meta").collapsedSummary()) + assertEquals("{0 fields}", root.getValue("emptyMeta").collapsedSummary()) + } + + @Test + fun `invalid JSON is preserved as plain text`() { + val malformed = "{not-json" + + val content = assertType( + parseJsonViewerContent(malformed), + ) + + assertEquals(malformed, content.text) + } + + @Test + fun `collapsing an array hides only its descendants`() { + val root = structuredElement( + """{"items":[{"id":1}],"meta":{"page":1}}""", + ) + + val rows = buildJsonTreeRows(root, collapsedPaths = setOf("$/items")) + + val itemsRow = rows.single { it.containerPath == "$/items" } + assertEquals(false, itemsRow.expanded) + assertEquals("\"items\": [1 item],", itemsRow.text) + assertTrue(rows.none { it.text.contains("\"id\"") }) + assertTrue(rows.any { it.text == "\"page\": 1" }) + } + + @Test + fun `root remains expanded and is not collapsible`() { + val root = structuredElement( + """{"items":[],"meta":{}}""", + ) + + val rows = buildJsonTreeRows(root, collapsedPaths = setOf("$")) + + val rootRow = rows.first() + assertEquals("{", rootRow.text) + assertEquals(null, rootRow.containerPath) + assertEquals(null, rootRow.expanded) + assertTrue(rows.any { it.text == "\"items\": []," }) + assertTrue(rows.any { it.text == "\"meta\": {}" }) + } + + @Test + fun `root array remains expanded and is not collapsible`() { + val root = structuredElement("""[{"id":1}]""") + + val rows = buildJsonTreeRows(root, collapsedPaths = setOf("$")) + + val rootRow = rows.first() + assertEquals("[", rootRow.text) + assertEquals(null, rootRow.containerPath) + assertEquals(null, rootRow.expanded) + assertTrue(rows.any { it.text == "\"id\": 1" }) + } + + @Test + fun `default state collapses nonempty containers from the second level`() { + val root = structuredElement( + """{"trip":{"destination":{"city":"Jeju"},"days":[{"activities":[1]}]}}""", + ) + + val collapsedPaths = defaultCollapsedPaths(root) + + assertEquals( + setOf( + "$/trip/destination", + "$/trip/days", + "$/trip/days/0", + "$/trip/days/0/activities", + ), + collapsedPaths, + ) + } + + @Test + fun `empty containers render without collapse controls`() { + val root = structuredElement("""{"items":[],"meta":{}}""") + + val rows = buildJsonTreeRows(root, collapsedPaths = emptySet()) + + val emptyRows = rows.filter { it.key == "items" || it.key == "meta" } + assertEquals(listOf("\"items\": [],", "\"meta\": {}"), emptyRows.map { it.text }) + assertTrue(emptyRows.all { it.containerPath == null && it.expanded == null }) + } + + @Test + fun `primitive rows expose syntax token types`() { + val root = structuredElement( + """{"string":"value","number":1,"boolean":true,"null":null}""", + ) + + val tokenTypes = buildJsonTreeRows(root, collapsedPaths = emptySet()) + .filter { it.key != null } + .associate { it.key to it.tokenType } + + assertEquals(JsonTokenType.STRING, tokenTypes["string"]) + assertEquals(JsonTokenType.NUMBER, tokenTypes["number"]) + assertEquals(JsonTokenType.BOOLEAN, tokenTypes["boolean"]) + assertEquals(JsonTokenType.NULL, tokenTypes["null"]) + } + + @Test + fun `disabling folding expands every container without controls`() { + val root = structuredElement( + """{"trip":{"days":[{"activities":[1]}]}}""", + ) + + val rows = buildJsonTreeRows( + element = root, + collapsedPaths = defaultCollapsedPaths(root), + foldingEnabled = false, + ) + + assertTrue(rows.all { it.containerPath == null && it.expanded == null }) + assertTrue(rows.any { it.text == "1" }) + } + + private fun structuredElement(json: String) = + assertType(parseJsonViewerContent(json)).element + + private inline fun assertType(value: Any?): T { + assertTrue("Expected ${T::class.simpleName}, but was ${value?.let { it::class.simpleName }}", value is T) + return value as T + } +} diff --git a/sample/src/main/assets/sample.html b/sample/src/main/assets/sample.html index 14853ec..f1dfd5e 100644 --- a/sample/src/main/assets/sample.html +++ b/sample/src/main/assets/sample.html @@ -98,10 +98,16 @@

Dari Bridge Sample

Request containing explicit null values +
Expandable JSON
+ +
Large Payload (Truncation Test)
Status Test (In Progress / Error)
@@ -221,10 +227,16 @@

Dari Bridge Sample

function fetchLargeData() { var id = createRequestId(); - log('→ fetchLargeData (~2MB response)', 'req'); + log('→ fetchLargeData (~2MB, truncated)', 'req'); Android.onBridgeRequest('fetchLargeData', id, null); } + function fetchTravelItinerary() { + var id = createRequestId(); + log('→ fetchTravelItinerary (expandable JSON)', 'req'); + Android.onBridgeRequest('fetchTravelItinerary', id, null); + } + function simulateSlowResponse() { var id = createRequestId(); log('→ simulateSlowResponse (10s delay)', 'req'); diff --git a/sample/src/main/java/com/easyhooon/dari/sample/MainActivity.kt b/sample/src/main/java/com/easyhooon/dari/sample/MainActivity.kt index 27cd5f3..3fc26a1 100644 --- a/sample/src/main/java/com/easyhooon/dari/sample/MainActivity.kt +++ b/sample/src/main/java/com/easyhooon/dari/sample/MainActivity.kt @@ -125,6 +125,7 @@ class MainActivity : ComponentActivity() { "openAppSettings" -> handleOpenAppSettings(handlerName, requestId) "requestCameraPermission" -> handleRequestCameraPermission(requestId) "sendWithNullFields" -> handleSendWithNullFields(handlerName, requestId, data) + "fetchTravelItinerary" -> handleFetchTravelItinerary(handlerName, requestId) "fetchLargeData" -> handleFetchLargeData(handlerName, requestId) "simulateSlowResponse" -> handleSimulateSlowResponse(handlerName, requestId) "simulateError" -> handleSimulateError(handlerName, requestId, data) @@ -319,6 +320,129 @@ class MainActivity : ComponentActivity() { callJs(requestId, true, """{"size":${largePayload.length},"itemCount":10000}""") } + // Keep the complete payload together so its expandable shape stays easy to inspect. + @Suppress("LongMethod") + private fun handleFetchTravelItinerary(handlerName: String, requestId: String) { + val response = JSONObject( + """ + { + "trip": { + "id": "TRIP-2026-JEJU-03", + "title": "Jeju Summer Escape", + "destination": { + "city": "Jeju", + "country": "South Korea", + "coordinates": { + "latitude": 33.4996, + "longitude": 126.5312 + } + }, + "travelers": [ + { + "name": "Mina", + "role": "planner", + "preferences": { + "seat": "window", + "meal": "vegetarian" + } + }, + { + "name": "Joon", + "role": "photographer", + "preferences": { + "seat": "aisle", + "meal": "standard" + } + } + ], + "days": [ + { + "day": 1, + "date": "2026-08-14", + "theme": "Ocean & Sunset", + "activities": [ + { + "time": "10:30", + "title": "Check in at Aewol", + "location": { + "name": "Aewol Stay", + "district": "Aewol-eup" + }, + "tags": ["stay", "ocean-view"], + "reservation": { + "status": "confirmed", + "confirmationCode": "AWL-4815" + } + }, + { + "time": "18:40", + "title": "Sunset coastal walk", + "location": { + "name": "Handam Coastal Trail", + "district": "Aewol-eup" + }, + "tags": ["sunset", "walking"], + "reservation": null + } + ] + }, + { + "day": 2, + "date": "2026-08-15", + "theme": "Forest & Local Food", + "activities": [ + { + "time": "09:00", + "title": "Walk through Bijarim Forest", + "location": { + "name": "Bijarim Forest", + "district": "Gujwa-eup" + }, + "tags": ["forest", "nature"], + "reservation": { + "status": "not-required", + "confirmationCode": null + } + }, + { + "time": "13:00", + "title": "Jeju seasonal table", + "location": { + "name": "Sorang Kitchen", + "district": "Seogwipo-si" + }, + "tags": ["food", "local"], + "reservation": { + "status": "confirmed", + "confirmationCode": "SRG-1300" + } + } + ] + } + ], + "summary": { + "totalDays": 2, + "estimatedBudget": { + "amount": 780000, + "currency": "KRW" + }, + "highlights": [ + "ocean-view stay", + "coastal sunset", + "forest trail", + "local cuisine" + ] + } + } + } + """.trimIndent(), + ).apply { + put("requestId", requestId) + } + interceptor?.onWebToAppResponse(handlerName, requestId, response.toString(), true) + callJs(requestId, true, """{"tripId":"TRIP-2026-JEJU-03","dayCount":2}""") + } + private fun handleSimulateSlowResponse(handlerName: String, requestId: String) { lifecycleScope.launch { delay(10_000)