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 =