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
@@ -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 =
|
||||
|
||||
Reference in new issue
Block a user