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

@@ -82,6 +82,7 @@ import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.theme.AssistantBubble
import com.m57.hermescontrol.theme.AssistantBubbleLight
import com.m57.hermescontrol.theme.StatusGreen
@@ -92,6 +93,14 @@ import com.m57.hermescontrol.theme.ToolChipColorLight
import com.m57.hermescontrol.theme.UserBubble
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
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.boolean
import kotlinx.serialization.json.double
import kotlinx.serialization.json.int
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@@ -537,9 +546,7 @@ fun parseToolOutput(
val trimmed = content.trim()
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return null
return try {
val element =
com.google.gson.JsonParser
.parseString(trimmed)
val element = OkHttpProvider.json.parseToJsonElement(trimmed)
if (!element.isJsonObject) return null
val obj = element.asJsonObject
@@ -2301,3 +2308,23 @@ private fun InlineAttachment(
}
}
}
// Extensions for backward compatibility with Gson API under kotlinx.serialization
private val JsonElement.isJsonObject: Boolean get() = this is JsonObject
private val JsonElement.asJsonObject: JsonObject get() = this as JsonObject
private val JsonElement.isJsonArray: Boolean get() = this is JsonArray
private val JsonElement.asJsonArray: JsonArray get() = this as JsonArray
private val JsonElement.isJsonNull: Boolean get() = this is JsonNull
private val JsonElement.isJsonPrimitive: Boolean get() = this is JsonPrimitive
private val JsonElement.asString: String get() = (this as JsonPrimitive).content
private val JsonElement.asDouble: Double get() = (this as JsonPrimitive).double
private val JsonElement.asInt: Int get() = (this as JsonPrimitive).int
private val JsonElement.asBoolean: Boolean get() = (this as JsonPrimitive).boolean
private fun JsonElement.has(key: String): Boolean = (this as? JsonObject)?.containsKey(key) == true
private fun JsonObject.entrySet(): Set<Map.Entry<String, JsonElement>> = entries
private fun JsonObject.keySet(): Set<String> = keys
private fun JsonArray.size(): Int = size
@@ -18,6 +18,7 @@ import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods
import com.m57.hermescontrol.data.ws.toJsonElement
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -32,6 +33,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.decodeFromJsonElement
import java.util.concurrent.ConcurrentHashMap
private const val TAG = "ChatViewModel"
@@ -845,7 +847,9 @@ class ChatViewModel(
val body = result.data
val platformsStr =
body.gateway_platforms?.entries?.joinToString("\n") { (k, v) ->
" • **$k**: ${v.state ?: "Unknown"}${if (v.error_code != null) " (Error: ${v.error_code})" else ""}"
val status = v?.state ?: "Unknown"
val err = v?.error_code?.let { " (Error: $it)" } ?: ""
" • **$k**: $status$err"
} ?: "No active platforms"
addAssistantMessage(
"""
@@ -983,13 +987,15 @@ class ChatViewModel(
fun refreshCurrentSession() {
val sessionId = _uiState.value.currentSessionId ?: return
loadCachedMessages(sessionId)
// Skip the REST call if we already have messages — the WebSocket keeps
// the UI current. The REST endpoint can 404 on tab switch-back when the
// WS session ID doesn't round-trip through the resolver (issue #366),
// showing a misleading error banner while the connection is fine.
if (_uiState.value.messages.isNotEmpty()) return
loadSessionMessages(sessionId)
viewModelScope.launch {
loadCachedMessages(sessionId).join()
// Skip the REST call if we already have messages — the WebSocket keeps
// the UI current. The REST endpoint can 404 on tab switch-back when the
// WS session ID doesn't round-trip through the resolver (issue #366),
// showing a misleading error banner while the connection is fine.
if (_uiState.value.messages.isNotEmpty()) return@launch
loadSessionMessages(sessionId)
}
}
fun refreshSettings() {
@@ -1004,8 +1010,8 @@ class ChatViewModel(
@Suppress("UNCHECKED_CAST")
private fun parseCommandCatalog(map: Map<*, *>): CommandCatalog? =
try {
val json = OkHttpProvider.gson.toJson(map)
OkHttpProvider.gson.fromJson(json, CommandCatalog::class.java)
val jsonElement = map.toJsonElement()
OkHttpProvider.json.decodeFromJsonElement<CommandCatalog>(jsonElement)
} catch (e: Exception) {
Log.e(TAG, "Failed to parse command catalog", e)
null
@@ -1030,23 +1036,25 @@ class ChatViewModel(
)
}
_streamingState.update { StreamingState() }
// Step 1: Load cached messages first — instant display
loadCachedMessages(sessionId)
// Step 2: Resume session on server
viewModelScope.launch(Dispatchers.IO) {
wsClient.send(
WsMethods.SESSION_RESUME,
mapOf("session_id" to sessionId),
onSent = { id -> trackRequest(id, WsMethods.SESSION_RESUME) },
)
viewModelScope.launch {
// Step 1: Load cached messages first — instant display
loadCachedMessages(sessionId).join()
// Step 2: Resume session on server
launch(Dispatchers.IO) {
wsClient.send(
WsMethods.SESSION_RESUME,
mapOf("session_id" to sessionId),
onSent = { id -> trackRequest(id, WsMethods.SESSION_RESUME) },
)
}
// Step 3: Load fresh messages from REST and merge
loadSessionMessages(sessionId)
// Step 4: Refresh session list to get latest titles
loadSessions()
}
// Step 3: Load fresh messages from REST and merge
loadSessionMessages(sessionId)
// Step 4: Refresh session list to get latest titles
loadSessions()
}
private fun loadCachedMessages(sessionId: String) {
private fun loadCachedMessages(sessionId: String): Job =
viewModelScope.launch(Dispatchers.IO) {
val cachedMessages = repo.loadMessages(sessionId)
_uiState.update { state ->
@@ -1058,7 +1066,6 @@ class ChatViewModel(
}
}
}
}
private fun loadSessionMessages(sessionId: String) {
viewModelScope.launch {
@@ -1082,7 +1089,7 @@ class ChatViewModel(
// Room duplicates when re-loading the same session.
val stableId = "rest-$sessionId-$index"
// Preserve original timestamp from the API when available
val ts = msg.timestamp?.toLongOrNull() ?: System.currentTimeMillis()
val ts = msg.timestampText?.toLongOrNull() ?: System.currentTimeMillis()
ChatMessage(
id = stableId,
role = role,
@@ -2,6 +2,8 @@ package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.toJsonElement
import kotlinx.serialization.encodeToString
/**
* Pure state reducer for WebSocket events.
@@ -225,7 +227,7 @@ object ChatWsEventReducer {
): ReducerResult {
val contentJson =
event.data?.let {
OkHttpProvider.gson.toJson(it)
OkHttpProvider.json.encodeToString(it.toJsonElement())
} ?: ""
val toolMessage =
ChatMessage(
@@ -271,7 +273,7 @@ object ChatWsEventReducer {
): ReducerResult {
val contentJson =
event.data?.let {
OkHttpProvider.gson.toJson(it)
OkHttpProvider.json.encodeToString(it.toJsonElement())
} ?: ""
val messages = state.messages.toMutableList()
val toolIdx =
@@ -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)
}
}
@@ -9,6 +9,7 @@ import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkError
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.safeApiCall
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@@ -17,6 +18,8 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
data class ConnectUiState(
val token: String = "",
@@ -317,13 +320,10 @@ class ConnectViewModel(
val decodedBytes = android.util.Base64.decode(trimmed, android.util.Base64.DEFAULT)
val decodedString = String(decodedBytes, Charsets.UTF_8)
if (decodedString.startsWith("{") && decodedString.endsWith("}")) {
val json =
com.google.gson.JsonParser
.parseString(decodedString)
.asJsonObject
val host = json.get("host")?.asString
val port = json.get("port")?.asInt?.toString() ?: json.get("port")?.asString
val token = json.get("token")?.asString
val json = OkHttpProvider.json.parseToJsonElement(decodedString).jsonObject
val host = json["host"]?.jsonPrimitive?.content
val port = json["port"]?.jsonPrimitive?.content
val token = json["token"]?.jsonPrimitive?.content
if (host != null && port != null && token != null) {
_uiState.update {
it.copy(
@@ -8,6 +8,7 @@ import com.m57.hermescontrol.data.model.UpdateCronJobRequest
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.ws.toJsonElement
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import kotlinx.coroutines.Dispatchers
@@ -18,6 +19,8 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
data class CronJobsUiState(
val isLoading: Boolean = false,
@@ -292,7 +295,7 @@ class CronJobsViewModel :
safeApiCall {
ApiClient.hermesApi.updateCronJob(
editor.jobId ?: "",
UpdateCronJobRequest(updates = updates),
UpdateCronJobRequest(updates = updates.mapValues { it.value.toJsonElement() }),
)
}
}
@@ -336,8 +339,8 @@ class CronJobsViewModel :
private fun extractScheduleString(job: CronJob): String {
val s = job.schedule
return when (s) {
is String -> s
is Map<*, *> -> (s["value"] as? String) ?: job.scheduleText
is JsonPrimitive -> s.content
is JsonObject -> (s["value"] as? JsonPrimitive)?.content ?: job.scheduleText
else -> job.scheduleText
}
}
@@ -267,7 +267,7 @@ fun GatewayScreen(
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
)
val stateText = pStatus.state?.uppercase() ?: "UNKNOWN"
val stateText = pStatus?.state?.uppercase() ?: "UNKNOWN"
val badgeColor =
when (stateText) {
"RUNNING" -> Color(0xFF4CAF50)
@@ -6,12 +6,12 @@ import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.google.gson.JsonParser
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkError
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.safeApiCall
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@@ -20,6 +20,8 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
/**
* UI state for the pairing code entry screen.
@@ -88,12 +90,10 @@ class PairingCodeEntryViewModel(
val decodedBytes = Base64.decode(trimmed, Base64.DEFAULT)
val decodedString = String(decodedBytes, Charsets.UTF_8)
if (decodedString.startsWith("{") && decodedString.endsWith("}")) {
val json = JsonParser.parseString(decodedString).asJsonObject
val host = json.get("host")?.asString
val port =
json.get("port")?.asInt?.toString()
?: json.get("port")?.asString
val token = json.get("token")?.asString
val json = OkHttpProvider.json.parseToJsonElement(decodedString).jsonObject
val host = json["host"]?.jsonPrimitive?.content
val port = json["port"]?.jsonPrimitive?.content
val token = json["token"]?.jsonPrimitive?.content
// Accept raw "host" + "port" + "token", or a composite "pairing" payload
if (host != null && port != null && token != null) {
connectToDashboard(host, port.toIntOrNull() ?: 9119, token)
@@ -786,7 +786,7 @@ private fun LazyListScope.gatewaySection(
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
platform.state?.let { platformState ->
platform?.state?.let { platformState ->
StatusBadge(
text = platformState,
status =