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

@@ -1,9 +1,12 @@
package com.m57.hermescontrol.data.ws
import kotlinx.serialization.Serializable
/**
* Response from `commands.catalog` RPC — full catalog of available
* slash commands, subcommands, and alias mappings.
*/
@Serializable
data class CommandCatalog(
val pairs: List<List<String>> = emptyList(),
val sub: Map<String, List<String>> = emptyMap(),
@@ -16,6 +19,7 @@ data class CommandCatalog(
/**
* A named category within the command catalog.
*/
@Serializable
data class CommandCategory(
val name: String,
val pairs: List<List<String>>,
@@ -24,12 +24,13 @@ object EventParser {
return if (response.error != null) {
WsEvent.RpcError(id, response.error)
} else {
WsEvent.RpcResult(id, response.result)
WsEvent.RpcResult(id, response.result?.toAny())
}
}
// ── Notification / event (no id, has method) ─────────────────────
val params = response.params ?: return WsEvent.Unknown(rawJson)
@Suppress("UNCHECKED_CAST")
val params = response.params?.toAny() as? Map<String, Any?> ?: return WsEvent.Unknown(rawJson)
val eventType = params["type"] as? String ?: return WsEvent.Unknown(rawJson)
@Suppress("UNCHECKED_CAST")
@@ -24,6 +24,8 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
@@ -123,7 +125,7 @@ object HermesWsClient {
.buffer(Channel.BUFFERED)
.map { text ->
try {
val rpc = OkHttpProvider.gson.fromJson(text, JsonRpcResponse::class.java)
val rpc = OkHttpProvider.json.decodeFromString<JsonRpcResponse>(text)
EventParser.parse(rpc, text)
} catch (e: Exception) {
Log.e(TAG, "Failed to parse message", e)
@@ -231,8 +233,8 @@ object HermesWsClient {
): String {
val id = requestId.incrementAndGet().toString()
onSent?.invoke(id)
val request = JsonRpcRequest(id = id, method = method, params = params)
val json = OkHttpProvider.gson.toJson(request)
val request = JsonRpcRequest(id = id, method = method, params = params.mapValues { it.value.toJsonElement() })
val json = OkHttpProvider.json.encodeToString(request)
if (BuildConfig.DEBUG) Log.d(TAG, "→ $json")
val ws = webSocket
if (ws != null && connected.get()) {
@@ -1,32 +1,77 @@
package com.m57.hermescontrol.data.ws
import kotlinx.serialization.Serializable
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.booleanOrNull
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.longOrNull
/**
* Outgoing JSON-RPC 2.0 request sent over the WebSocket.
*/
@Serializable
data class JsonRpcRequest(
val jsonrpc: String = "2.0",
val id: String,
val method: String,
val params: Map<String, Any> = emptyMap(),
val params: Map<String, JsonElement> = emptyMap(),
)
/**
* Incoming JSON-RPC 2.0 response **or** server-pushed notification.
*
* - RPC result/error → [id] is non-null, [result] or [error] is populated.
* - Event notification → [id] is null, [method] is `"event"`, event data in [params].
*/
@Serializable
data class JsonRpcResponse(
val jsonrpc: String?,
val id: String?,
val result: Any?,
val error: JsonRpcError?,
val method: String?,
val params: Map<String, Any?>?,
val jsonrpc: String? = null,
val id: String? = null,
val result: JsonElement? = null,
val error: JsonRpcError? = null,
val method: String? = null,
val params: JsonObject? = null,
)
@Serializable
data class JsonRpcError(
val code: Int,
val message: String,
val data: Any? = null,
val data: JsonElement? = null,
)
fun Any?.toJsonElement(): JsonElement =
when (this) {
null -> JsonNull
is JsonElement -> this
is Boolean -> JsonPrimitive(this)
is Number -> JsonPrimitive(this)
is String -> JsonPrimitive(this)
is Map<*, *> -> JsonObject(this.map { it.key.toString() to it.value.toJsonElement() }.toMap())
is List<*> -> JsonArray(this.map { it.toJsonElement() })
else -> JsonPrimitive(this.toString())
}
fun JsonElement.toAny(): Any? =
when (this) {
is JsonNull -> {
null
}
is JsonPrimitive -> {
if (isString) {
content
} else {
booleanOrNull ?: doubleOrNull ?: longOrNull ?: content
}
}
is JsonObject -> {
mapValues { it.value.toAny() }
}
is JsonArray -> {
map { it.toAny() }
}
}