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:
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user