refactor: migrate Gson serialization to kotlinx.serialization (#475)

* refactor: migrate Gson serialization to kotlinx.serialization

* fix(dto): resolve serializing Any? and Map<String, Any?> with JsonElement

* test(refactor): fix unit test issues under kotlinx.serialization and update nullable DTO defaults

* fix(chat): support flexible numeric and string decoding for message timestamps

* fix(chat): sequentialize cache loading to avoid overwriting network state
This commit is contained in:
M57 authored and GitHub committed 2026-07-05 21:30:44 +03:00
1 parent 1be5ab2f6d
commit 5697405b19
64 files changed
+863 -577

No files matched your search

@@ -53,8 +53,6 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.gson.JsonElement
import com.google.gson.JsonPrimitive
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.SchemaField
import com.m57.hermescontrol.ui.common.ErrorState
@@ -63,6 +61,11 @@ import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.ui.common.SearchBar
import com.m57.hermescontrol.ui.common.ToastEffect
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -580,7 +583,7 @@ private fun ConfigField(
modifier = Modifier.weight(1f),
)
Switch(
checked = currentValue?.asBoolean ?: false,
checked = (currentValue as? JsonPrimitive)?.booleanOrNull ?: false,
onCheckedChange = { onChange(JsonPrimitive(it)) },
)
}
@@ -590,7 +593,7 @@ private fun ConfigField(
DropdownField(
label = label,
options = field.options ?: emptyList(),
selectedValue = currentValue?.asString ?: "",
selectedValue = (currentValue as? JsonPrimitive)?.content ?: "",
onOptionSelected = { onChange(JsonPrimitive(it)) },
)
if (description != label) {
@@ -607,7 +610,7 @@ private fun ConfigField(
OutlinedTextField(
value =
currentValue?.let {
if (it.isJsonPrimitive) it.asString else ""
if (it is JsonPrimitive) it.content else ""
} ?: "",
onValueChange = { text ->
text.toDoubleOrNull()?.let { doubleVal ->
@@ -630,10 +633,10 @@ private fun ConfigField(
// String or other type
val textValue =
currentValue?.let {
when {
it.isJsonPrimitive -> it.asString
it.isJsonObject -> it.toString()
it.isJsonArray -> it.toString()
when (it) {
is JsonPrimitive -> it.content
is JsonObject -> it.toString()
is JsonArray -> it.toString()
else -> ""
}
} ?: ""
@@ -708,20 +711,17 @@ private fun collectUncoveredPaths(
val dotPath = if (prefix.isEmpty()) key else "$prefix.$key"
// If this exact path is in the schema, it's covered — skip entirely
if (dotPath in schemaPaths) continue
if (value is com.google.gson.JsonObject) {
// Recurse into nested objects to find uncovered leaves
val subKeys = value.keySet()
if (value is JsonObject) {
// Before recursing deeper, check if this whole sub-tree has any schema coverage
val hasSchemaCoverage = schemaPaths.any { it == dotPath || it.startsWith("$dotPath.") }
if (hasSchemaCoverage) {
// Schema covers some sub-paths — recurse to find what's NOT covered
val subMap = subKeys.associateWith { value.get(it) }
result.addAll(collectUncoveredPaths(subMap, schemaPaths, dotPath))
result.addAll(collectUncoveredPaths(value, schemaPaths, dotPath))
} else {
// No schema coverage at all for this subtree — add the whole thing
result.add(dotPath)
}
} else if (value is com.google.gson.JsonArray) {
} else if (value is JsonArray) {
// Arrays: check if the path is in schema; if not, add it
if (dotPath !in schemaPaths) {
result.add(dotPath)
@@ -746,7 +746,7 @@ private fun getJsonValue(
if (i == 0) {
current = config[key]
} else {
current = current?.asJsonObject?.get(key)
current = (current as? JsonObject)?.get(key)
}
if (current == null) return null
}
@@ -2,8 +2,6 @@ package com.m57.hermescontrol.ui.config
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import com.m57.hermescontrol.data.model.ConfigSchemaResponse
import com.m57.hermescontrol.data.model.ConfigUpdateRequest
import com.m57.hermescontrol.data.model.UpdateRawConfigRequest
@@ -19,6 +17,8 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
data class ConfigUiState(
val isLoading: Boolean = false,
@@ -296,15 +296,14 @@ class ConfigViewModel :
val topKey = parts.first()
val rest = parts.drop(1).joinToString(".")
val topValue = config[topKey]
val nestedJson = topValue?.asJsonObject ?: JsonObject()
val nestedJson = (topValue as? JsonObject) ?: JsonObject(emptyMap())
val updatedNested =
applyNestedValue(
nestedJson.entrySet().associate { it.key to it.value },
nestedJson,
rest,
value,
)
val nestedObj = JsonObject()
updatedNested.forEach { (k, v) -> nestedObj.add(k, v) }
val nestedObj = JsonObject(updatedNested)
val mutable = config.toMutableMap()
mutable[topKey] = nestedObj
return mutable
@@ -312,23 +311,38 @@ class ConfigViewModel :
/** Build a nested JSON changeset from dot-path pending changes. */
private fun buildChangeset(changes: Map<String, JsonElement>): Map<String, JsonElement> {
val root = JsonObject()
val root = mutableMapOf<String, Any>()
for ((dotPath, value) in changes) {
val parts = dotPath.split(".")
var current = root
for (i in 0 until parts.size - 1) {
val key = parts[i]
val existing = current.get(key)
if (existing == null || !existing.isJsonObject) {
val newObj = JsonObject()
current.add(key, newObj)
current = newObj
val existing = current[key]
if (existing !is MutableMap<*, *>) {
val newMap = mutableMapOf<String, Any>()
current[key] = newMap
current = newMap
} else {
current = existing.asJsonObject
@Suppress("UNCHECKED_CAST")
current = existing as MutableMap<String, Any>
}
}
current.add(parts.last(), value)
current[parts.last()] = value
}
return root.entrySet().associate { it.key to it.value as JsonElement }
fun toJsonObject(map: Map<String, Any>): JsonObject {
val content =
map.mapValues { (_, v) ->
if (v is Map<*, *>) {
@Suppress("UNCHECKED_CAST")
toJsonObject(v as Map<String, Any>)
} else {
v as JsonElement
}
}
return JsonObject(content)
}
return toJsonObject(root)
}
}