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
2026-07-05 21:30:44 +03:00
committed by GitHub
parent 1be5ab2f6d
commit 5697405b19
64 changed files with 863 additions and 577 deletions
+2 -2
View File
@@ -151,8 +151,8 @@ dependencies {
implementation(libs.okhttp) implementation(libs.okhttp)
implementation(libs.okhttp.logging) implementation(libs.okhttp.logging)
implementation(libs.retrofit) implementation(libs.retrofit)
implementation(libs.retrofit.converter.gson) implementation(libs.retrofit.converter.kotlinx.serialization)
implementation(libs.gson) implementation(libs.kotlinx.serialization.json)
// Image loading // Image loading
implementation(libs.coil.compose) implementation(libs.coil.compose)
+1 -5
View File
@@ -18,11 +18,7 @@
-keep class com.m57.hermescontrol.ui.chat.MessageRole { *; } -keep class com.m57.hermescontrol.ui.chat.MessageRole { *; }
-keep class com.m57.hermescontrol.ui.chat.ToolStatus { *; } -keep class com.m57.hermescontrol.ui.chat.ToolStatus { *; }
# Gson itself # Generic type signatures used by serialization
-dontwarn com.google.gson.**
-keep class com.google.gson.** { *; }
# Generic type signatures used by Gson
-keepattributes Signature -keepattributes Signature
-keepattributes *Annotation* -keepattributes *Annotation*
-keepattributes EnclosingMethod -keepattributes EnclosingMethod
@@ -4,6 +4,9 @@ import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKeys import androidx.security.crypto.MasterKeys
import com.m57.hermescontrol.data.model.ConnectionProfile
import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.theme.BottomNavDisplayMode import com.m57.hermescontrol.theme.BottomNavDisplayMode
import com.m57.hermescontrol.theme.ThemePreference import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset import com.m57.hermescontrol.theme.ThemePreset
@@ -15,6 +18,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
/** /**
* Singleton that manages encrypted storage of the Hermes dashboard token * Singleton that manages encrypted storage of the Hermes dashboard token
@@ -69,8 +74,6 @@ object AuthManager {
private val _tokenFlow = MutableStateFlow<String?>(null) private val _tokenFlow = MutableStateFlow<String?>(null)
val tokenFlow: StateFlow<String?> = _tokenFlow.asStateFlow() val tokenFlow: StateFlow<String?> = _tokenFlow.asStateFlow()
private val gson = com.m57.hermescontrol.data.remote.OkHttpProvider.gson
/** /**
* Initialise the encrypted preferences. * Initialise the encrypted preferences.
* Call this once from Application.onCreate() or MainActivity.onCreate(). * Call this once from Application.onCreate() or MainActivity.onCreate().
@@ -160,14 +163,10 @@ object AuthManager {
// ── Connection Profiles ────────────────────────────────────────────── // ── Connection Profiles ──────────────────────────────────────────────
fun getConnectionProfiles(): List<com.m57.hermescontrol.data.model.ConnectionProfile> { fun getConnectionProfiles(): List<ConnectionProfile> {
val json = requirePrefs().getString(KEY_CONNECTION_PROFILES, null) ?: return emptyList() val json = requirePrefs().getString(KEY_CONNECTION_PROFILES, null) ?: return emptyList()
return try { return try {
val type = OkHttpProvider.json.decodeFromString<List<ConnectionProfile>>(json)
object : com.google.gson.reflect.TypeToken<
List<com.m57.hermescontrol.data.model.ConnectionProfile>,
>() {}.type
gson.fromJson(json, type) ?: emptyList()
} catch (e: Exception) { } catch (e: Exception) {
if (com.m57.hermescontrol.BuildConfig.DEBUG) { if (com.m57.hermescontrol.BuildConfig.DEBUG) {
android.util.Log.w("AuthManager", "Failed to parse connection profiles", e) android.util.Log.w("AuthManager", "Failed to parse connection profiles", e)
@@ -176,21 +175,17 @@ object AuthManager {
} }
} }
fun saveConnectionProfiles(profiles: List<com.m57.hermescontrol.data.model.ConnectionProfile>) { fun saveConnectionProfiles(profiles: List<ConnectionProfile>) {
val json = gson.toJson(profiles) val json = OkHttpProvider.json.encodeToString(profiles)
requirePrefs().edit().putString(KEY_CONNECTION_PROFILES, json).apply() requirePrefs().edit().putString(KEY_CONNECTION_PROFILES, json).apply()
} }
// ── Pinned Models ──────────────────────────────────────────────────── // ── Pinned Models ────────────────────────────────────────────────────
fun getPinnedModels(): List<com.m57.hermescontrol.data.model.PinnedModel> { fun getPinnedModels(): List<PinnedModel> {
val json = requirePrefs().getString(KEY_PINNED_MODELS, null) ?: return emptyList() val json = requirePrefs().getString(KEY_PINNED_MODELS, null) ?: return emptyList()
return try { return try {
val type = OkHttpProvider.json.decodeFromString<List<PinnedModel>>(json)
object : com.google.gson.reflect.TypeToken<
List<com.m57.hermescontrol.data.model.PinnedModel>,
>() {}.type
gson.fromJson(json, type) ?: emptyList()
} catch (e: Exception) { } catch (e: Exception) {
if (com.m57.hermescontrol.BuildConfig.DEBUG) { if (com.m57.hermescontrol.BuildConfig.DEBUG) {
android.util.Log.w("AuthManager", "Failed to parse pinned models", e) android.util.Log.w("AuthManager", "Failed to parse pinned models", e)
@@ -200,7 +195,9 @@ object AuthManager {
} }
fun savePinnedModels(pinned: List<com.m57.hermescontrol.data.model.PinnedModel>) { fun savePinnedModels(pinned: List<com.m57.hermescontrol.data.model.PinnedModel>) {
val json = gson.toJson(pinned) val json =
com.m57.hermescontrol.data.remote.OkHttpProvider.json
.encodeToString(pinned)
requirePrefs().edit().putString(KEY_PINNED_MODELS, json).apply() requirePrefs().edit().putString(KEY_PINNED_MODELS, json).apply()
} }
@@ -1,68 +1,73 @@
package com.m57.hermescontrol.data.model package com.m57.hermescontrol.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import com.google.gson.annotations.SerializedName @Serializable
data class AchievementsResponse( data class AchievementsResponse(
val achievements: List<Achievement>? = emptyList(), val achievements: List<Achievement>? = emptyList(),
@SerializedName("unlocked_count") val unlockedCount: Int = 0, @SerialName("unlocked_count") val unlockedCount: Int = 0,
@SerializedName("discovered_count") val discoveredCount: Int = 0, @SerialName("discovered_count") val discoveredCount: Int = 0,
@SerializedName("secret_count") val secretCount: Int = 0, @SerialName("secret_count") val secretCount: Int = 0,
@SerializedName("total_count") val totalCount: Int = 0, @SerialName("total_count") val totalCount: Int = 0,
@SerializedName("is_stale") val isStale: Boolean = false, @SerialName("is_stale") val isStale: Boolean = false,
@SerializedName("generated_at") val generatedAt: Long? = null, @SerialName("generated_at") val generatedAt: Long? = null,
@SerializedName("scan_meta") val scanMeta: ScanMeta? = null, @SerialName("scan_meta") val scanMeta: ScanMeta? = null,
val error: String? = null, val error: String? = null,
) )
@Serializable
data class ScanMeta( data class ScanMeta(
val status: ScanStatus? = null, val status: ScanStatus? = null,
) )
@Serializable
data class ScanStatus( data class ScanStatus(
val state: String = "idle", val state: String = "idle",
@SerializedName("started_at") val startedAt: Long? = null, @SerialName("started_at") val startedAt: Long? = null,
@SerializedName("finished_at") val finishedAt: Long? = null, @SerialName("finished_at") val finishedAt: Long? = null,
@SerializedName("last_error") val lastError: String? = null, @SerialName("last_error") val lastError: String? = null,
@SerializedName("last_duration_ms") val lastDurationMs: Long? = null, @SerialName("last_duration_ms") val lastDurationMs: Long? = null,
@SerializedName("run_count") val runCount: Int = 0, @SerialName("run_count") val runCount: Int = 0,
) )
@Serializable
data class RecentUnlock( data class RecentUnlock(
val id: String, val id: String,
val name: String, val name: String,
val description: String?, val description: String? = null,
val category: String?, val category: String? = null,
val kind: String?, val kind: String? = null,
val icon: String?, val icon: String? = null,
val unlocked: Boolean, val unlocked: Boolean,
val discovered: Boolean, val discovered: Boolean,
val state: String?, val state: String? = null,
val tier: String?, val tier: String? = null,
val progress: Double?, val progress: Double? = null,
@SerializedName("next_tier") val nextTier: String?, @SerialName("next_tier") val nextTier: String? = null,
@SerializedName("next_threshold") val nextThreshold: Double?, @SerialName("next_threshold") val nextThreshold: Double? = null,
@SerializedName("progress_pct") val progressPct: Double?, @SerialName("progress_pct") val progressPct: Double? = null,
@SerializedName("unlocked_at") val unlockedAt: Long?, @SerialName("unlocked_at") val unlockedAt: Long? = null,
val secret: Boolean?, val secret: Boolean? = null,
val criteria: String?, val criteria: String? = null,
) )
@Serializable
data class Achievement( data class Achievement(
val id: String, val id: String,
val name: String, val name: String,
val description: String?, val description: String? = null,
val category: String?, val category: String? = null,
val kind: String?, val kind: String? = null,
val icon: String?, val icon: String? = null,
val unlocked: Boolean, val unlocked: Boolean,
val discovered: Boolean, val discovered: Boolean,
val state: String?, val state: String? = null,
val tier: String?, val tier: String? = null,
val progress: Double?, val progress: Double? = null,
@SerializedName("next_tier") val nextTier: String?, @SerialName("next_tier") val nextTier: String? = null,
@SerializedName("next_threshold") val nextThreshold: Double?, @SerialName("next_threshold") val nextThreshold: Double? = null,
@SerializedName("progress_pct") val progressPct: Double?, @SerialName("progress_pct") val progressPct: Double? = null,
@SerializedName("unlocked_at") val unlockedAt: Long?, @SerialName("unlocked_at") val unlockedAt: Long? = null,
val secret: Boolean?, val secret: Boolean? = null,
val criteria: String?, val criteria: String? = null,
) )
@@ -1,5 +1,7 @@
package com.m57.hermescontrol.data.model package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
@Serializable
data class ActionResponse( data class ActionResponse(
val archive: String? = null, val archive: String? = null,
val name: String? = null, val name: String? = null,
@@ -1,5 +1,7 @@
package com.m57.hermescontrol.data.model package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
@Serializable
data class ActionStatusResponse( data class ActionStatusResponse(
val exit_code: Int? = null, val exit_code: Int? = null,
val lines: List<String>? = null, val lines: List<String>? = null,
@@ -1,4 +1,5 @@
package com.m57.hermescontrol.data.model package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
/** /**
* Represents a file attached to a chat message. * Represents a file attached to a chat message.
@@ -9,6 +10,7 @@ package com.m57.hermescontrol.data.model
* @param size File size in bytes * @param size File size in bytes
* @param localPath Optional local file system path for direct access * @param localPath Optional local file system path for direct access
*/ */
@Serializable
data class Attachment( data class Attachment(
val uri: String, val uri: String,
val name: String, val name: String,
@@ -1,10 +1,13 @@
package com.m57.hermescontrol.data.model package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
@Serializable
data class CheckpointsResponse( data class CheckpointsResponse(
val sessions: List<CheckpointSession>? = null, val sessions: List<CheckpointSession>? = null,
val total_bytes: Long? = null, val total_bytes: Long? = null,
) )
@Serializable
data class CheckpointSession( data class CheckpointSession(
val session: String? = null, val session: String? = null,
val files: Int? = null, val files: Int? = null,
@@ -1,22 +1,26 @@
package com.m57.hermescontrol.data.model package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import com.google.gson.JsonElement @Serializable
data class RawConfigResponse( data class RawConfigResponse(
val path: String?, val path: String? = null,
val yaml: String?, val yaml: String? = null,
) )
@Serializable
data class UpdateRawConfigRequest( data class UpdateRawConfigRequest(
val yaml_text: String, val yaml_text: String,
val profile: String? = null, val profile: String? = null,
) )
@Serializable
data class ConfigSchemaResponse( data class ConfigSchemaResponse(
val fields: Map<String, SchemaField>, val fields: Map<String, SchemaField>,
val category_order: List<String>, val category_order: List<String>,
) )
@Serializable
data class SchemaField( data class SchemaField(
val type: String, val type: String,
val description: String? = null, val description: String? = null,
@@ -24,6 +28,7 @@ data class SchemaField(
val options: List<String>? = null, val options: List<String>? = null,
) )
@Serializable
data class ConfigUpdateRequest( data class ConfigUpdateRequest(
val config: Map<String, JsonElement>, val config: Map<String, JsonElement>,
val profile: String? = null, val profile: String? = null,
@@ -1,7 +1,8 @@
package com.m57.hermescontrol.data.model package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
import java.util.UUID import java.util.UUID
@Serializable
data class ConnectionProfile( data class ConnectionProfile(
val id: String = UUID.randomUUID().toString(), val id: String = UUID.randomUUID().toString(),
val name: String, val name: String,
@@ -1,14 +1,18 @@
package com.m57.hermescontrol.data.model package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
@Serializable
data class CredentialPoolResponse( data class CredentialPoolResponse(
val providers: List<CredentialPoolProvider>? = null, val providers: List<CredentialPoolProvider>? = null,
) )
@Serializable
data class CredentialPoolProvider( data class CredentialPoolProvider(
val provider: String? = null, val provider: String? = null,
val entries: List<CredentialPoolEntry>? = null, val entries: List<CredentialPoolEntry>? = null,
) )
@Serializable
data class CredentialPoolEntry( data class CredentialPoolEntry(
val index: Int? = null, val index: Int? = null,
val label: String? = null, val label: String? = null,
@@ -1,17 +1,23 @@
package com.m57.hermescontrol.data.model package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
@Serializable
data class CronJobRepeat( data class CronJobRepeat(
val times: Int? = null, val times: Int? = null,
val completed: Int = 0, val completed: Int = 0,
) )
@Serializable
data class CronJob( data class CronJob(
val id: String, val id: String,
val name: String, val name: String,
val schedule: Any?, val schedule: JsonElement? = null,
val state: String?, val state: String? = null,
val last_run_status: String?, val last_run_status: String? = null,
val next_run: String?, val next_run: String? = null,
val schedule_display: String? = null, val schedule_display: String? = null,
val last_status: String? = null, val last_status: String? = null,
val next_run_at: String? = null, val next_run_at: String? = null,
@@ -36,8 +42,8 @@ data class CronJob(
val scheduleText: String val scheduleText: String
get() = get() =
when (schedule) { when (schedule) {
is String -> schedule is JsonPrimitive -> schedule.content
is Map<*, *> -> (schedule["display"] as? String) ?: schedule_display ?: "" is JsonObject -> (schedule["display"] as? JsonPrimitive)?.content ?: schedule_display ?: ""
else -> schedule_display ?: "" else -> schedule_display ?: ""
} }
@@ -1,5 +1,8 @@
package com.m57.hermescontrol.data.model package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
@Serializable
data class CreateCronJobRequest( data class CreateCronJobRequest(
val name: String = "", val name: String = "",
val schedule: String, val schedule: String,
@@ -17,6 +20,7 @@ data class CreateCronJobRequest(
val repeat: Int? = null, val repeat: Int? = null,
) )
@Serializable
data class UpdateCronJobRequest( data class UpdateCronJobRequest(
val updates: Map<String, Any?>, val updates: Map<String, JsonElement>,
) )
@@ -1,5 +1,7 @@
package com.m57.hermescontrol.data.model package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
@Serializable
data class CuratorResponse( data class CuratorResponse(
val enabled: Boolean? = null, val enabled: Boolean? = null,
val paused: Boolean? = null, val paused: Boolean? = null,
@@ -1,5 +1,7 @@
package com.m57.hermescontrol.data.model package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
@Serializable
data class DebugShareResponse( data class DebugShareResponse(
val ok: Boolean? = null, val ok: Boolean? = null,
val urls: Map<String, String>? = null, val urls: Map<String, String>? = null,
@@ -1,7 +1,9 @@
package com.m57.hermescontrol.data.model package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
@Serializable
data class DoctorResponse( data class DoctorResponse(
val ok: Boolean, val ok: Boolean,
val pid: Int?, val pid: Int? = null,
val name: String?, val name: String? = null,
) )
@@ -1,32 +1,37 @@
package com.m57.hermescontrol.data.model package com.m57.hermescontrol.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import com.google.gson.annotations.SerializedName @Serializable
data class EnvVarConfig( data class EnvVarConfig(
@SerializedName("is_set") val isSet: Boolean, @SerialName("is_set") val isSet: Boolean,
@SerializedName("redacted_value") val redactedValue: String?, @SerialName("redacted_value") val redactedValue: String? = null,
val description: String?, val description: String? = null,
val url: String?, val url: String? = null,
val category: String?, val category: String? = null,
@SerializedName("is_password") val isPassword: Boolean, @SerialName("is_password") val isPassword: Boolean,
) )
@Serializable
data class EnvVarRevealRequest( data class EnvVarRevealRequest(
val key: String, val key: String,
val profile: String? = null, val profile: String? = null,
) )
@Serializable
data class EnvVarRevealResponse( data class EnvVarRevealResponse(
val key: String, val key: String,
val value: String, val value: String,
) )
@Serializable
data class EnvVarUpdate( data class EnvVarUpdate(
val key: String, val key: String,
val value: String, val value: String,
val profile: String? = null, val profile: String? = null,
) )
@Serializable
data class EnvVarDeleteRequest( data class EnvVarDeleteRequest(
val key: String, val key: String,
val profile: String? = null, val profile: String? = null,
@@ -1,10 +1,13 @@
package com.m57.hermescontrol.data.model package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
@Serializable
data class HookResponse( data class HookResponse(
val hooks: List<HookEntry>? = null, val hooks: List<HookEntry>? = null,
val valid_events: List<String>? = null, val valid_events: List<String>? = null,
) )
@Serializable
data class HookEntry( data class HookEntry(
val event: String? = null, val event: String? = null,
val matcher: String? = null, val matcher: String? = null,
@@ -1,37 +1,43 @@
package com.m57.hermescontrol.data.model package com.m57.hermescontrol.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import com.google.gson.annotations.SerializedName @Serializable
data class KanbanBoard( data class KanbanBoard(
@SerializedName("slug") val id: String, @SerialName("slug") val id: String,
val name: String, val name: String,
val description: String?, val description: String? = null,
) )
@Serializable
data class KanbanTask( data class KanbanTask(
val id: String, val id: String,
val title: String, val title: String,
@SerializedName("body") val description: String?, @SerialName("body") val description: String? = null,
val status: String, val status: String,
@SerializedName("assignee") val assignedTo: String?, @SerialName("assignee") val assignedTo: String? = null,
) )
@Serializable
data class CreateTaskBody( data class CreateTaskBody(
val title: String, val title: String,
val body: String?, val body: String? = null,
) )
@Serializable
data class KanbanColumn( data class KanbanColumn(
val name: String, val name: String,
val tasks: List<KanbanTask>, val tasks: List<KanbanTask>,
) )
@Serializable
data class KanbanBoardResponse( data class KanbanBoardResponse(
val columns: List<KanbanColumn>, val columns: List<KanbanColumn>,
val assignees: List<String>?, val assignees: List<String>? = null,
val tenants: List<String>?, val tenants: List<String>? = null,
) )
@Serializable
data class KanbanBoardsResponse( data class KanbanBoardsResponse(
val boards: List<KanbanBoard>, val boards: List<KanbanBoard>,
val current: String? = null, val current: String? = null,
@@ -1,5 +1,7 @@
package com.m57.hermescontrol.data.model package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
@Serializable
data class LogResponse( data class LogResponse(
val lines: List<String>? = null, val lines: List<String>? = null,
val logs: List<String>? = null, val logs: List<String>? = null,
@@ -1,25 +1,30 @@
package com.m57.hermescontrol.data.model package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
@Serializable
data class McpServersResponse( data class McpServersResponse(
val servers: List<McpServer>, val servers: List<McpServer>,
) )
@Serializable
data class McpServer( data class McpServer(
val name: String, val name: String,
val transport: String?, val transport: String? = null,
val url: String?, val url: String? = null,
val command: String?, val command: String? = null,
val args: List<String>?, val args: List<String>? = null,
val env: Map<String, String>?, val env: Map<String, String>? = null,
val enabled: Boolean, val enabled: Boolean,
val status: String? = null, val status: String? = null,
val error: String? = null, val error: String? = null,
) )
@Serializable
data class McpServerToggleRequest( data class McpServerToggleRequest(
val enabled: Boolean, val enabled: Boolean,
) )
@Serializable
data class AddMcpServerRequest( data class AddMcpServerRequest(
val name: String, val name: String,
val url: String? = null, val url: String? = null,
@@ -28,10 +33,12 @@ data class AddMcpServerRequest(
val env: Map<String, String>? = null, val env: Map<String, String>? = null,
) )
@Serializable
data class McpCatalogResponse( data class McpCatalogResponse(
val entries: List<McpCatalogEntry>, val entries: List<McpCatalogEntry>,
) )
@Serializable
data class McpCatalogEntry( data class McpCatalogEntry(
val name: String, val name: String,
val description: String? = null, val description: String? = null,
@@ -42,6 +49,7 @@ data class McpCatalogEntry(
val env: List<McpCatalogEnvVar>? = null, val env: List<McpCatalogEnvVar>? = null,
) )
@Serializable
data class McpCatalogEnvVar( data class McpCatalogEnvVar(
val key: String, val key: String,
val label: String? = null, val label: String? = null,
@@ -49,6 +57,7 @@ data class McpCatalogEnvVar(
val required: Boolean = false, val required: Boolean = false,
) )
@Serializable
data class McpCatalogInstallRequest( data class McpCatalogInstallRequest(
val name: String, val name: String,
val env: Map<String, String>? = null, val env: Map<String, String>? = null,
@@ -1,10 +1,13 @@
package com.m57.hermescontrol.data.model package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
@Serializable
data class MemoryResponse( data class MemoryResponse(
val active: String? = null, val active: String? = null,
val builtin_files: BuiltinFileSizes? = null, val builtin_files: BuiltinFileSizes? = null,
) )
@Serializable
data class BuiltinFileSizes( data class BuiltinFileSizes(
val memory: Long? = null, val memory: Long? = null,
val user: Long? = null, val user: Long? = null,
@@ -1,57 +1,63 @@
package com.m57.hermescontrol.data.model package com.m57.hermescontrol.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import com.google.gson.annotations.SerializedName @Serializable
data class MessagingPlatform( data class MessagingPlatform(
val id: String, val id: String,
val name: String, val name: String,
val description: String? = null, val description: String? = null,
@SerializedName("docs_url") val docsUrl: String? = null, @SerialName("docs_url") val docsUrl: String? = null,
val enabled: Boolean, val enabled: Boolean,
val configured: Boolean, val configured: Boolean,
@SerializedName("gateway_running") val gatewayRunning: Boolean = false, @SerialName("gateway_running") val gatewayRunning: Boolean = false,
val state: String = "disabled", val state: String = "disabled",
@SerializedName("error_code") val errorCode: String? = null, @SerialName("error_code") val errorCode: String? = null,
@SerializedName("error_message") val errorMessage: String? = null, @SerialName("error_message") val errorMessage: String? = null,
@SerializedName("env_vars") val envVars: List<EnvVarField>? = null, @SerialName("env_vars") val envVars: List<EnvVarField>? = null,
@SerializedName("updated_at") val updatedAt: String? = null, @SerialName("updated_at") val updatedAt: String? = null,
@SerializedName("home_channel") val homeChannel: HomeChannelInfo? = null, @SerialName("home_channel") val homeChannel: HomeChannelInfo? = null,
) )
@Serializable
data class HomeChannelInfo( data class HomeChannelInfo(
val platform: String, val platform: String,
@SerializedName("chat_id") val chatId: String, @SerialName("chat_id") val chatId: String,
val name: String, val name: String,
@SerializedName("thread_id") val threadId: String? = null, @SerialName("thread_id") val threadId: String? = null,
) )
@Serializable
data class EnvVarField( data class EnvVarField(
val key: String, val key: String,
val required: Boolean, val required: Boolean,
@SerializedName("is_set") val isSet: Boolean, @SerialName("is_set") val isSet: Boolean,
@SerializedName("redacted_value") val redactedValue: String? = null, @SerialName("redacted_value") val redactedValue: String? = null,
val description: String? = null, val description: String? = null,
val prompt: String? = null, val prompt: String? = null,
val help: String? = null, val help: String? = null,
@SerializedName("is_password") val isPassword: Boolean, @SerialName("is_password") val isPassword: Boolean,
val advanced: Boolean = false, val advanced: Boolean = false,
val url: String? = null, val url: String? = null,
@SerializedName("expires_at") val expiresAt: String? = null, @SerialName("expires_at") val expiresAt: String? = null,
) )
@Serializable
data class MessagingPlatformResponse( data class MessagingPlatformResponse(
@SerializedName("env_path") val envPath: String, @SerialName("env_path") val envPath: String,
@SerializedName("gateway_start_command") val gatewayStartCommand: String, @SerialName("gateway_start_command") val gatewayStartCommand: String,
val platforms: List<MessagingPlatform>, val platforms: List<MessagingPlatform>,
) )
@Serializable
data class MessagingPlatformUpdate( data class MessagingPlatformUpdate(
val enabled: Boolean? = null, val enabled: Boolean? = null,
val env: Map<String, String> = emptyMap(), val env: Map<String, String> = emptyMap(),
@SerializedName("clear_env") val clearEnv: List<String> = emptyList(), @SerialName("clear_env") val clearEnv: List<String> = emptyList(),
val profile: String? = null, val profile: String? = null,
) )
@Serializable
data class MessagingPlatformTestResult( data class MessagingPlatformTestResult(
val ok: Boolean, val ok: Boolean,
val state: String, val state: String,
@@ -60,37 +66,42 @@ data class MessagingPlatformTestResult(
// ── Telegram onboarding ───────────────────────────────────────────────── // ── Telegram onboarding ─────────────────────────────────────────────────
@Serializable
data class TelegramOnboardingStartRequest( data class TelegramOnboardingStartRequest(
@SerializedName("bot_name") val botName: String = "Hermes Agent", @SerialName("bot_name") val botName: String = "Hermes Agent",
) )
@Serializable
data class TelegramOnboardingStartResponse( data class TelegramOnboardingStartResponse(
@SerializedName("pairing_id") val pairingId: String, @SerialName("pairing_id") val pairingId: String,
@SerializedName("suggested_username") val suggestedUsername: String, @SerialName("suggested_username") val suggestedUsername: String,
@SerializedName("deep_link") val deepLink: String, @SerialName("deep_link") val deepLink: String,
@SerializedName("qr_payload") val qrPayload: String, @SerialName("qr_payload") val qrPayload: String,
@SerializedName("expires_at") val expiresAt: String, @SerialName("expires_at") val expiresAt: String,
) )
@Serializable
data class TelegramOnboardingStatusResponse( data class TelegramOnboardingStatusResponse(
val status: String, val status: String,
@SerializedName("bot_username") val botUsername: String? = null, @SerialName("bot_username") val botUsername: String? = null,
@SerializedName("owner_user_id") val ownerUserId: String? = null, @SerialName("owner_user_id") val ownerUserId: String? = null,
@SerializedName("expires_at") val expiresAt: String? = null, @SerialName("expires_at") val expiresAt: String? = null,
) )
@Serializable
data class TelegramOnboardingApplyRequest( data class TelegramOnboardingApplyRequest(
@SerializedName("allowed_user_ids") val allowedUserIds: List<String>, @SerialName("allowed_user_ids") val allowedUserIds: List<String>,
val profile: String? = null, val profile: String? = null,
) )
@Serializable
data class TelegramOnboardingApplyResponse( data class TelegramOnboardingApplyResponse(
val ok: Boolean, val ok: Boolean,
val platform: String, val platform: String,
@SerializedName("bot_username") val botUsername: String? = null, @SerialName("bot_username") val botUsername: String? = null,
@SerializedName("needs_restart") val needsRestart: Boolean, @SerialName("needs_restart") val needsRestart: Boolean,
@SerializedName("restart_started") val restartStarted: Boolean? = null, @SerialName("restart_started") val restartStarted: Boolean? = null,
@SerializedName("restart_action") val restartAction: String? = null, @SerialName("restart_action") val restartAction: String? = null,
@SerializedName("restart_pid") val restartPid: Int? = null, @SerialName("restart_pid") val restartPid: Int? = null,
@SerializedName("restart_error") val restartError: String? = null, @SerialName("restart_error") val restartError: String? = null,
) )
@@ -1,5 +1,7 @@
package com.m57.hermescontrol.data.model package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
@Serializable
data class AuxiliaryTaskAssignment( data class AuxiliaryTaskAssignment(
val task: String, val task: String,
val provider: String, val provider: String,
@@ -7,21 +9,25 @@ data class AuxiliaryTaskAssignment(
val base_url: String? = null, val base_url: String? = null,
) )
@Serializable
data class AuxiliaryModelsResponse( data class AuxiliaryModelsResponse(
val tasks: List<AuxiliaryTaskAssignment>, val tasks: List<AuxiliaryTaskAssignment>,
val main: MainModelAssignment, val main: MainModelAssignment,
) )
@Serializable
data class MainModelAssignment( data class MainModelAssignment(
val provider: String, val provider: String,
val model: String, val model: String,
) )
@Serializable
data class MoaModelSlot( data class MoaModelSlot(
val provider: String, val provider: String,
val model: String, val model: String,
) )
@Serializable
data class MoaConfigPreset( data class MoaConfigPreset(
val reference_models: List<MoaModelSlot>, val reference_models: List<MoaModelSlot>,
val aggregator: MoaModelSlot, val aggregator: MoaModelSlot,
@@ -31,6 +37,7 @@ data class MoaConfigPreset(
val enabled: Boolean = true, val enabled: Boolean = true,
) )
@Serializable
data class MoaConfigResponse( data class MoaConfigResponse(
val default_preset: String = "", val default_preset: String = "",
val active_preset: String = "", val active_preset: String = "",
@@ -43,6 +50,7 @@ data class MoaConfigResponse(
val enabled: Boolean = true, val enabled: Boolean = true,
) )
@Serializable
data class ModelAssignmentRequest( data class ModelAssignmentRequest(
val confirm_expensive_model: Boolean = false, val confirm_expensive_model: Boolean = false,
val scope: String, val scope: String,
@@ -52,6 +60,7 @@ data class ModelAssignmentRequest(
val task: String? = null, val task: String? = null,
) )
@Serializable
data class ModelAssignmentResponse( data class ModelAssignmentResponse(
val confirm_message: String? = null, val confirm_message: String? = null,
val confirm_required: Boolean? = null, val confirm_required: Boolean? = null,
@@ -1,22 +1,26 @@
package com.m57.hermescontrol.data.model package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
@Serializable
data class ModelOptionsResponse( data class ModelOptionsResponse(
val providers: List<ModelProvider>, val providers: List<ModelProvider>,
) )
@Serializable
data class ModelProvider( data class ModelProvider(
val slug: String, val slug: String,
val name: String, val name: String,
val is_current: Boolean?, val is_current: Boolean? = null,
val is_user_defined: Boolean?, val is_user_defined: Boolean? = null,
val models: List<String>?, val models: List<String>? = null,
val total_models: Int?, val total_models: Int? = null,
val source: String?, val source: String? = null,
val authenticated: Boolean?, val authenticated: Boolean? = null,
val auth_type: String?, val auth_type: String? = null,
val warning: String?, val warning: String? = null,
) )
@Serializable
data class PinnedModel( data class PinnedModel(
val providerSlug: String, val providerSlug: String,
val modelName: String, val modelName: String,
@@ -1,24 +1,29 @@
package com.m57.hermescontrol.data.model package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
@Serializable
data class PairingResponse( data class PairingResponse(
val pending: List<PairingItem>, val pending: List<PairingItem>,
val approved: List<PairingItem>, val approved: List<PairingItem>,
) )
@Serializable
data class PairingItem( data class PairingItem(
val platform: String, val platform: String,
val user_id: String?, val user_id: String? = null,
val username: String?, val username: String? = null,
val display_name: String?, val display_name: String? = null,
val code: String?, val code: String? = null,
val created_at: String?, val created_at: String? = null,
) )
@Serializable
data class PairingApproveRequest( data class PairingApproveRequest(
val platform: String, val platform: String,
val code: String, val code: String,
) )
@Serializable
data class PairingRevokeRequest( data class PairingRevokeRequest(
val platform: String, val platform: String,
val user_id: String, val user_id: String,
@@ -1,20 +1,21 @@
package com.m57.hermescontrol.data.model package com.m57.hermescontrol.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import com.google.gson.annotations.SerializedName @Serializable
data class PluginInfo( data class PluginInfo(
val name: String, val name: String,
val description: String?, val description: String? = null,
val version: String?, val version: String? = null,
val source: String?, val source: String? = null,
@SerializedName("runtime_status") val runtimeStatus: String?, @SerialName("runtime_status") val runtimeStatus: String? = null,
@SerializedName("has_dashboard_manifest") val hasDashboardManifest: Boolean = false, @SerialName("has_dashboard_manifest") val hasDashboardManifest: Boolean = false,
@SerializedName("dashboard_manifest") val dashboardManifest: PluginManifestData? = null, @SerialName("dashboard_manifest") val dashboardManifest: PluginManifestData? = null,
@SerializedName("can_remove") val canRemove: Boolean = false, @SerialName("can_remove") val canRemove: Boolean = false,
@SerializedName("can_update_git") val canUpdateGit: Boolean = false, @SerialName("can_update_git") val canUpdateGit: Boolean = false,
@SerializedName("auth_required") val authRequired: Boolean = false, @SerialName("auth_required") val authRequired: Boolean = false,
@SerializedName("auth_command") val authCommand: String? = null, @SerialName("auth_command") val authCommand: String? = null,
@SerializedName("user_hidden") val userHidden: Boolean = false, @SerialName("user_hidden") val userHidden: Boolean = false,
) { ) {
val enabled: Boolean val enabled: Boolean
get() = runtimeStatus.equals("enabled", ignoreCase = true) get() = runtimeStatus.equals("enabled", ignoreCase = true)
@@ -25,6 +26,7 @@ data class PluginInfo(
runtimeStatus.equals("disabled", ignoreCase = true) runtimeStatus.equals("disabled", ignoreCase = true)
} }
@Serializable
data class PluginManifestData( data class PluginManifestData(
val name: String? = null, val name: String? = null,
val label: String? = null, val label: String? = null,
@@ -35,10 +37,11 @@ data class PluginManifestData(
val slots: List<String>? = null, val slots: List<String>? = null,
val entry: String? = null, val entry: String? = null,
val css: String? = null, val css: String? = null,
@SerializedName("has_api") val hasApi: Boolean = false, @SerialName("has_api") val hasApi: Boolean = false,
val source: String? = null, val source: String? = null,
) )
@Serializable
data class PluginTabInfo( data class PluginTabInfo(
val path: String? = null, val path: String? = null,
val position: String? = null, val position: String? = null,
@@ -46,6 +49,7 @@ data class PluginTabInfo(
val hidden: Boolean = false, val hidden: Boolean = false,
) )
@Serializable
data class DashboardPluginMeta( data class DashboardPluginMeta(
val name: String? = null, val name: String? = null,
val label: String? = null, val label: String? = null,
@@ -53,31 +57,36 @@ data class DashboardPluginMeta(
val tab: PluginTabInfo? = null, val tab: PluginTabInfo? = null,
) )
@Serializable
data class PluginsHubProviders( data class PluginsHubProviders(
@SerializedName("memory_provider") val memoryProvider: String? = null, @SerialName("memory_provider") val memoryProvider: String? = null,
@SerializedName("memory_options") val memoryOptions: List<ProviderOption> = emptyList(), @SerialName("memory_options") val memoryOptions: List<ProviderOption> = emptyList(),
@SerializedName("context_engine") val contextEngine: String? = null, @SerialName("context_engine") val contextEngine: String? = null,
@SerializedName("context_options") val contextOptions: List<ProviderOption> = emptyList(), @SerialName("context_options") val contextOptions: List<ProviderOption> = emptyList(),
) )
@Serializable
data class ProviderOption( data class ProviderOption(
val name: String, val name: String,
val description: String? = null, val description: String? = null,
) )
@Serializable
data class PluginsHubResponse( data class PluginsHubResponse(
val plugins: List<PluginInfo>, val plugins: List<PluginInfo>,
@SerializedName("orphan_dashboard_plugins") val orphanDashboardPlugins: List<DashboardPluginMeta> = emptyList(), @SerialName("orphan_dashboard_plugins") val orphanDashboardPlugins: List<DashboardPluginMeta> = emptyList(),
val providers: PluginsHubProviders? = null, val providers: PluginsHubProviders? = null,
) )
@Serializable
data class AgentPluginInstallBody( data class AgentPluginInstallBody(
val identifier: String, val identifier: String,
val force: Boolean = false, val force: Boolean = false,
val enable: Boolean = true, val enable: Boolean = true,
) )
@Serializable
data class PluginProvidersPutRequest( data class PluginProvidersPutRequest(
@SerializedName("memory_provider") val memoryProvider: String? = null, @SerialName("memory_provider") val memoryProvider: String? = null,
@SerializedName("context_engine") val contextEngine: String? = null, @SerialName("context_engine") val contextEngine: String? = null,
) )
@@ -1,5 +1,7 @@
package com.m57.hermescontrol.data.model package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
@Serializable
data class PortalResponse( data class PortalResponse(
val logged_in: Boolean? = null, val logged_in: Boolean? = null,
val portal_url: String? = null, val portal_url: String? = null,
@@ -9,6 +11,7 @@ data class PortalResponse(
val features: List<PortalFeature>? = null, val features: List<PortalFeature>? = null,
) )
@Serializable
data class PortalFeature( data class PortalFeature(
val label: String? = null, val label: String? = null,
val state: String? = null, val state: String? = null,
@@ -1,9 +1,12 @@
package com.m57.hermescontrol.data.model package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
@Serializable
data class ProfilesResponse( data class ProfilesResponse(
val profiles: List<ProfileInfo>, val profiles: List<ProfileInfo>,
) )
@Serializable
data class ProfileInfo( data class ProfileInfo(
val name: String, val name: String,
val path: String? = null, val path: String? = null,
@@ -17,46 +20,55 @@ data class ProfileInfo(
val description_auto: Boolean? = null, val description_auto: Boolean? = null,
) )
@Serializable
data class ActiveProfileResponse( data class ActiveProfileResponse(
val active: String, val active: String,
val current: String?, val current: String? = null,
) )
@Serializable
data class SetActiveProfileRequest( data class SetActiveProfileRequest(
val name: String, val name: String,
) )
@Serializable
data class ProfileSoulResponse( data class ProfileSoulResponse(
val content: String, val content: String,
) )
@Serializable
data class UpdateProfileSoulRequest( data class UpdateProfileSoulRequest(
val content: String, val content: String,
) )
@Serializable
data class UpdateProfileModelRequest( data class UpdateProfileModelRequest(
val provider: String, val provider: String,
val model: String, val model: String,
) )
@Serializable
data class CloneProfileRequest( data class CloneProfileRequest(
val name: String, val name: String,
) )
@Serializable
data class UpdateProfileDescriptionRequest( data class UpdateProfileDescriptionRequest(
val description: String, val description: String,
) )
@Serializable
data class CreateProfileRequest( data class CreateProfileRequest(
val name: String, val name: String,
val description: String?, val description: String? = null,
val provider: String?, val provider: String? = null,
val model: String?, val model: String? = null,
val mcp_servers: List<McpServerConfigInput>?, val mcp_servers: List<McpServerConfigInput>? = null,
val keep_skills: Boolean?, val keep_skills: Boolean? = null,
val hub_skills: List<String>?, val hub_skills: List<String>? = null,
) )
@Serializable
data class McpServerConfigInput( data class McpServerConfigInput(
val name: String, val name: String,
val transport: String, val transport: String,
@@ -1,5 +1,7 @@
package com.m57.hermescontrol.data.model package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
@Serializable
data class SaveSkillContentRequest( data class SaveSkillContentRequest(
val name: String, val name: String,
val content: String, val content: String,
@@ -1,5 +1,7 @@
package com.m57.hermescontrol.data.model package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
@Serializable
data class SessionListResponse( data class SessionListResponse(
val sessions: List<SessionInfo>, val sessions: List<SessionInfo>,
val total: Int = 0, val total: Int = 0,
@@ -7,35 +9,41 @@ data class SessionListResponse(
val offset: Int = 0, val offset: Int = 0,
) )
@Serializable
data class SessionInfo( data class SessionInfo(
val id: String, val id: String,
val title: String?, val title: String? = null,
val created_at: String?, val created_at: String? = null,
val message_count: Int?, val message_count: Int? = null,
val status: String?, val status: String? = null,
val preview: String? = null, val preview: String? = null,
val started_at: Double? = null, val started_at: Double? = null,
val source: String? = null, val source: String? = null,
) )
@Serializable
data class SessionStatsResponse( data class SessionStatsResponse(
val total: Int = 0, val total: Int = 0,
val active: Int = 0, val active: Int = 0,
) )
@Serializable
data class SessionRenameRequest( data class SessionRenameRequest(
val title: String, val title: String,
) )
@Serializable
data class BulkDeleteRequest( data class BulkDeleteRequest(
val ids: List<String>, val ids: List<String>,
val delete_all: Boolean = false, val delete_all: Boolean = false,
) )
@Serializable
data class PruneRequest( data class PruneRequest(
val days: Int, val days: Int,
) )
@Serializable
data class SessionPromptResponse( data class SessionPromptResponse(
val prompt: String? = null, val prompt: String? = null,
val id: String? = null, val id: String? = null,
@@ -1,12 +1,20 @@
package com.m57.hermescontrol.data.model package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
@Serializable
data class SessionMessagesResponse( data class SessionMessagesResponse(
val messages: List<SessionMessage>, val messages: List<SessionMessage>,
) )
@Serializable
data class SessionMessage( data class SessionMessage(
val role: String?, val role: String? = null,
val content: String?, val content: String? = null,
val timestamp: String?, val timestamp: JsonElement? = null,
val type: String?, val type: String? = null,
) ) {
val timestampText: String?
get() = (timestamp as? JsonPrimitive)?.content
}
@@ -1,11 +1,12 @@
package com.m57.hermescontrol.data.model package com.m57.hermescontrol.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import com.google.gson.annotations.SerializedName @Serializable
data class Skill( data class Skill(
val name: String, val name: String,
val description: String?, val description: String? = null,
val category: String?, val category: String? = null,
val enabled: Boolean, val enabled: Boolean,
val content: String? = null, val content: String? = null,
val source: String? = null, // built-in, hub, optional val source: String? = null, // built-in, hub, optional
@@ -13,40 +14,46 @@ data class Skill(
val linked_files: List<String>? = null, val linked_files: List<String>? = null,
) )
@Serializable
data class HubSkill( data class HubSkill(
val name: String, val name: String,
val description: String?, val description: String? = null,
val source: String?, val source: String? = null,
val category: String? = null, val category: String? = null,
val identifier: String? = null, val identifier: String? = null,
@SerializedName("trust_level") val trustLevel: String? = null, @SerialName("trust_level") val trustLevel: String? = null,
val repo: String? = null, val repo: String? = null,
val tags: List<String>? = null, val tags: List<String>? = null,
) )
@Serializable
data class SkillHubSearchResponse( data class SkillHubSearchResponse(
val results: List<HubSkill>, val results: List<HubSkill>,
@SerializedName("source_counts") val sourceCounts: Map<String, Int>? = null, @SerialName("source_counts") val sourceCounts: Map<String, Int>? = null,
@SerializedName("timed_out") val timedOut: List<String>? = null, @SerialName("timed_out") val timedOut: List<String>? = null,
val installed: Map<String, SkillHubInstalledEntry>? = null, val installed: Map<String, SkillHubInstalledEntry>? = null,
) )
@Serializable
data class SkillHubInstalledEntry( data class SkillHubInstalledEntry(
val name: String? = null, val name: String? = null,
@SerializedName("trust_level") val trustLevel: String? = null, @SerialName("trust_level") val trustLevel: String? = null,
@SerializedName("scan_verdict") val scanVerdict: String? = null, @SerialName("scan_verdict") val scanVerdict: String? = null,
) )
@Serializable
data class SkillHubInstallRequest( data class SkillHubInstallRequest(
val identifier: String, val identifier: String,
val profile: String? = null, val profile: String? = null,
) )
@Serializable
data class SkillHubUninstallRequest( data class SkillHubUninstallRequest(
val name: String, val name: String,
val profile: String? = null, val profile: String? = null,
) )
@Serializable
data class SkillScanResponse( data class SkillScanResponse(
val identifier: String, val identifier: String,
val passed: Boolean? = null, val passed: Boolean? = null,
@@ -1,5 +1,7 @@
package com.m57.hermescontrol.data.model package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
@Serializable
data class SkillContentResponse( data class SkillContentResponse(
val name: String, val name: String,
val content: String, val content: String,
@@ -1,14 +1,17 @@
package com.m57.hermescontrol.data.model package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
@Serializable
data class StatusResponse( data class StatusResponse(
val version: String?, val version: String? = null,
val gateway_running: Boolean?, val gateway_running: Boolean? = null,
val active_sessions: Int?, val active_sessions: Int? = null,
val auth_required: Boolean?, val auth_required: Boolean? = null,
val gateway_platforms: Map<String, PlatformStatus>?, val gateway_platforms: Map<String, PlatformStatus?>? = null,
) )
@Serializable
data class PlatformStatus( data class PlatformStatus(
val state: String?, val state: String? = null,
val error_code: String? = null, val error_code: String? = null,
) )
@@ -1,5 +1,7 @@
package com.m57.hermescontrol.data.model package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
@Serializable
data class SystemStatsResponse( data class SystemStatsResponse(
val os: String? = null, val os: String? = null,
val os_release: String? = null, val os_release: String? = null,
@@ -20,6 +22,7 @@ data class SystemStatsResponse(
val process: ProcessStats? = null, val process: ProcessStats? = null,
) )
@Serializable
data class MemoryStats( data class MemoryStats(
val total: Long? = null, val total: Long? = null,
val available: Long? = null, val available: Long? = null,
@@ -27,6 +30,7 @@ data class MemoryStats(
val percent: Double? = null, val percent: Double? = null,
) )
@Serializable
data class DiskStats( data class DiskStats(
val total: Long? = null, val total: Long? = null,
val used: Long? = null, val used: Long? = null,
@@ -34,6 +38,7 @@ data class DiskStats(
val percent: Double? = null, val percent: Double? = null,
) )
@Serializable
data class ProcessStats( data class ProcessStats(
val pid: Int? = null, val pid: Int? = null,
val rss: Long? = null, val rss: Long? = null,
@@ -1,5 +1,7 @@
package com.m57.hermescontrol.data.model package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
@Serializable
data class ToggleSkillRequest( data class ToggleSkillRequest(
val name: String, val name: String,
val enabled: Boolean, val enabled: Boolean,
@@ -1,15 +1,18 @@
package com.m57.hermescontrol.data.model package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
@Serializable
data class Toolset( data class Toolset(
val name: String, val name: String,
val label: String?, val label: String? = null,
val description: String?, val description: String? = null,
val enabled: Boolean, val enabled: Boolean,
val available: Boolean?, val available: Boolean? = null,
val configured: Boolean?, val configured: Boolean? = null,
val tools: List<String>?, val tools: List<String>? = null,
) )
@Serializable
data class ToolsetToggleRequest( data class ToolsetToggleRequest(
val enabled: Boolean, val enabled: Boolean,
val profile: String? = null, val profile: String? = null,
@@ -1,5 +1,7 @@
package com.m57.hermescontrol.data.model package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
@Serializable
data class UpdateCheckResponse( data class UpdateCheckResponse(
val install_method: String? = null, val install_method: String? = null,
val current_version: String? = null, val current_version: String? = null,
@@ -1,25 +1,29 @@
package com.m57.hermescontrol.data.model package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
@Serializable
data class WebhooksResponse( data class WebhooksResponse(
val enabled: Boolean, val enabled: Boolean,
val base_url: String?, val base_url: String? = null,
val subscriptions: List<WebhookSubscription>?, val subscriptions: List<WebhookSubscription>? = null,
) )
@Serializable
data class WebhookSubscription( data class WebhookSubscription(
val name: String, val name: String,
val description: String?, val description: String? = null,
val events: List<String>?, val events: List<String>? = null,
val deliver: String?, val deliver: String? = null,
val deliver_only: Boolean?, val deliver_only: Boolean? = null,
val prompt: String?, val prompt: String? = null,
val skills: List<String>?, val skills: List<String>? = null,
val created_at: String?, val created_at: String? = null,
val url: String, val url: String,
val secret_set: Boolean?, val secret_set: Boolean? = null,
val enabled: Boolean?, val enabled: Boolean? = null,
) )
@Serializable
data class CreateWebhookRequest( data class CreateWebhookRequest(
val name: String, val name: String,
val description: String? = null, val description: String? = null,
@@ -32,14 +36,17 @@ data class CreateWebhookRequest(
val secret: String? = null, val secret: String? = null,
) )
@Serializable
data class WebhooksToggleRequest( data class WebhooksToggleRequest(
val enabled: Boolean, val enabled: Boolean,
) )
@Serializable
data class WebhookToggleSubscriptionRequest( data class WebhookToggleSubscriptionRequest(
val enabled: Boolean, val enabled: Boolean,
) )
@Serializable
data class DeleteWebhookResponse( data class DeleteWebhookResponse(
val ok: Boolean, val ok: Boolean,
) )
@@ -4,9 +4,10 @@ import com.m57.hermescontrol.BuildConfig
import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.AuthManager
import okhttp3.CertificatePinner import okhttp3.CertificatePinner
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.kotlinx.serialization.asConverterFactory
/** /**
* Provides a Retrofit-backed [HermesApiService]. * Provides a Retrofit-backed [HermesApiService].
@@ -71,7 +72,7 @@ object ApiClient {
.Builder() .Builder()
.baseUrl("http://$host:$port/") .baseUrl("http://$host:$port/")
.client(tempOkHttp) .client(tempOkHttp)
.addConverterFactory(GsonConverterFactory.create(OkHttpProvider.gson)) .addConverterFactory(OkHttpProvider.json.asConverterFactory("application/json".toMediaType()))
.build() .build()
return tempRetrofit.create(HermesApiService::class.java) return tempRetrofit.create(HermesApiService::class.java)
@@ -135,7 +136,7 @@ object ApiClient {
.Builder() .Builder()
.baseUrl(AuthManager.baseUrl()) .baseUrl(AuthManager.baseUrl())
.client(okHttp) .client(okHttp)
.addConverterFactory(GsonConverterFactory.create(OkHttpProvider.gson)) .addConverterFactory(OkHttpProvider.json.asConverterFactory("application/json".toMediaType()))
.build() .build()
.also { retrofit = it } .also { retrofit = it }
@@ -1,6 +1,5 @@
package com.m57.hermescontrol.data.remote package com.m57.hermescontrol.data.remote
import com.google.gson.JsonElement
import com.m57.hermescontrol.data.model.AchievementsResponse import com.m57.hermescontrol.data.model.AchievementsResponse
import com.m57.hermescontrol.data.model.ActionResponse import com.m57.hermescontrol.data.model.ActionResponse
import com.m57.hermescontrol.data.model.ActionStatusResponse import com.m57.hermescontrol.data.model.ActionStatusResponse
@@ -91,6 +90,7 @@ import com.m57.hermescontrol.data.model.WebhookSubscription
import com.m57.hermescontrol.data.model.WebhookToggleSubscriptionRequest import com.m57.hermescontrol.data.model.WebhookToggleSubscriptionRequest
import com.m57.hermescontrol.data.model.WebhooksResponse import com.m57.hermescontrol.data.model.WebhooksResponse
import com.m57.hermescontrol.data.model.WebhooksToggleRequest import com.m57.hermescontrol.data.model.WebhooksToggleRequest
import kotlinx.serialization.json.JsonElement
import okhttp3.ResponseBody import okhttp3.ResponseBody
import retrofit2.Response import retrofit2.Response
import retrofit2.http.Body import retrofit2.http.Body
@@ -1,7 +1,8 @@
package com.m57.hermescontrol.data.remote package com.m57.hermescontrol.data.remote
import com.google.gson.JsonParser
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import retrofit2.Response import retrofit2.Response
import java.io.IOException import java.io.IOException
@@ -49,11 +50,8 @@ fun mapHttpError(
val detail = val detail =
if (errorBody != null) { if (errorBody != null) {
try { try {
val parsed = val parsed = OkHttpProvider.json.parseToJsonElement(errorBody).jsonObject
com.google.gson.JsonParser parsed["detail"]?.jsonPrimitive?.content
.parseString(errorBody)
.asJsonObject
parsed.get("detail")?.asString
} catch (_: Exception) { } catch (_: Exception) {
null null
} }
@@ -1,7 +1,8 @@
package com.m57.hermescontrol.data.remote package com.m57.hermescontrol.data.remote
import com.google.gson.Gson import kotlinx.serialization.ExperimentalSerializationApi
import com.google.gson.GsonBuilder import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNamingStrategy
import okhttp3.ConnectionPool import okhttp3.ConnectionPool
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -42,6 +43,10 @@ object OkHttpProvider {
.followRedirects(false) .followRedirects(false)
.build() .build()
// Shared Gson — thread-safe, zero-config @OptIn(ExperimentalSerializationApi::class)
val gson: Gson = GsonBuilder().create() val json: Json =
Json {
ignoreUnknownKeys = true
namingStrategy = JsonNamingStrategy.SnakeCase
}
} }
@@ -1,9 +1,12 @@
package com.m57.hermescontrol.data.ws package com.m57.hermescontrol.data.ws
import kotlinx.serialization.Serializable
/** /**
* Response from `commands.catalog` RPC — full catalog of available * Response from `commands.catalog` RPC — full catalog of available
* slash commands, subcommands, and alias mappings. * slash commands, subcommands, and alias mappings.
*/ */
@Serializable
data class CommandCatalog( data class CommandCatalog(
val pairs: List<List<String>> = emptyList(), val pairs: List<List<String>> = emptyList(),
val sub: Map<String, List<String>> = emptyMap(), val sub: Map<String, List<String>> = emptyMap(),
@@ -16,6 +19,7 @@ data class CommandCatalog(
/** /**
* A named category within the command catalog. * A named category within the command catalog.
*/ */
@Serializable
data class CommandCategory( data class CommandCategory(
val name: String, val name: String,
val pairs: List<List<String>>, val pairs: List<List<String>>,
@@ -24,12 +24,13 @@ object EventParser {
return if (response.error != null) { return if (response.error != null) {
WsEvent.RpcError(id, response.error) WsEvent.RpcError(id, response.error)
} else { } else {
WsEvent.RpcResult(id, response.result) WsEvent.RpcResult(id, response.result?.toAny())
} }
} }
// ── Notification / event (no id, has method) ───────────────────── // ── 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) val eventType = params["type"] as? String ?: return WsEvent.Unknown(rawJson)
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
@@ -24,6 +24,8 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response import okhttp3.Response
@@ -123,7 +125,7 @@ object HermesWsClient {
.buffer(Channel.BUFFERED) .buffer(Channel.BUFFERED)
.map { text -> .map { text ->
try { try {
val rpc = OkHttpProvider.gson.fromJson(text, JsonRpcResponse::class.java) val rpc = OkHttpProvider.json.decodeFromString<JsonRpcResponse>(text)
EventParser.parse(rpc, text) EventParser.parse(rpc, text)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to parse message", e) Log.e(TAG, "Failed to parse message", e)
@@ -231,8 +233,8 @@ object HermesWsClient {
): String { ): String {
val id = requestId.incrementAndGet().toString() val id = requestId.incrementAndGet().toString()
onSent?.invoke(id) onSent?.invoke(id)
val request = JsonRpcRequest(id = id, method = method, params = params) val request = JsonRpcRequest(id = id, method = method, params = params.mapValues { it.value.toJsonElement() })
val json = OkHttpProvider.gson.toJson(request) val json = OkHttpProvider.json.encodeToString(request)
if (BuildConfig.DEBUG) Log.d(TAG, "→ $json") if (BuildConfig.DEBUG) Log.d(TAG, "→ $json")
val ws = webSocket val ws = webSocket
if (ws != null && connected.get()) { if (ws != null && connected.get()) {
@@ -1,32 +1,77 @@
package com.m57.hermescontrol.data.ws 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. * Outgoing JSON-RPC 2.0 request sent over the WebSocket.
*/ */
@Serializable
data class JsonRpcRequest( data class JsonRpcRequest(
val jsonrpc: String = "2.0", val jsonrpc: String = "2.0",
val id: String, val id: String,
val method: 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. * 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( data class JsonRpcResponse(
val jsonrpc: String?, val jsonrpc: String? = null,
val id: String?, val id: String? = null,
val result: Any?, val result: JsonElement? = null,
val error: JsonRpcError?, val error: JsonRpcError? = null,
val method: String?, val method: String? = null,
val params: Map<String, Any?>?, val params: JsonObject? = null,
) )
@Serializable
data class JsonRpcError( data class JsonRpcError(
val code: Int, val code: Int,
val message: String, 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() }
}
}
@@ -82,6 +82,7 @@ import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment 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.AssistantBubble
import com.m57.hermescontrol.theme.AssistantBubbleLight import com.m57.hermescontrol.theme.AssistantBubbleLight
import com.m57.hermescontrol.theme.StatusGreen import com.m57.hermescontrol.theme.StatusGreen
@@ -92,6 +93,14 @@ import com.m57.hermescontrol.theme.ToolChipColorLight
import com.m57.hermescontrol.theme.UserBubble import com.m57.hermescontrol.theme.UserBubble
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch 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.Instant
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@@ -537,9 +546,7 @@ fun parseToolOutput(
val trimmed = content.trim() val trimmed = content.trim()
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return null if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return null
return try { return try {
val element = val element = OkHttpProvider.json.parseToJsonElement(trimmed)
com.google.gson.JsonParser
.parseString(trimmed)
if (!element.isJsonObject) return null if (!element.isJsonObject) return null
val obj = element.asJsonObject 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.HermesWsClient
import com.m57.hermescontrol.data.ws.WsEvent import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods import com.m57.hermescontrol.data.ws.WsMethods
import com.m57.hermescontrol.data.ws.toJsonElement
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -32,6 +33,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.decodeFromJsonElement
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
private const val TAG = "ChatViewModel" private const val TAG = "ChatViewModel"
@@ -845,7 +847,9 @@ class ChatViewModel(
val body = result.data val body = result.data
val platformsStr = val platformsStr =
body.gateway_platforms?.entries?.joinToString("\n") { (k, v) -> 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" } ?: "No active platforms"
addAssistantMessage( addAssistantMessage(
""" """
@@ -983,14 +987,16 @@ class ChatViewModel(
fun refreshCurrentSession() { fun refreshCurrentSession() {
val sessionId = _uiState.value.currentSessionId ?: return val sessionId = _uiState.value.currentSessionId ?: return
loadCachedMessages(sessionId) viewModelScope.launch {
loadCachedMessages(sessionId).join()
// Skip the REST call if we already have messages — the WebSocket keeps // 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 // 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), // WS session ID doesn't round-trip through the resolver (issue #366),
// showing a misleading error banner while the connection is fine. // showing a misleading error banner while the connection is fine.
if (_uiState.value.messages.isNotEmpty()) return if (_uiState.value.messages.isNotEmpty()) return@launch
loadSessionMessages(sessionId) loadSessionMessages(sessionId)
} }
}
fun refreshSettings() { fun refreshSettings() {
_uiState.update { state -> _uiState.update { state ->
@@ -1004,8 +1010,8 @@ class ChatViewModel(
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
private fun parseCommandCatalog(map: Map<*, *>): CommandCatalog? = private fun parseCommandCatalog(map: Map<*, *>): CommandCatalog? =
try { try {
val json = OkHttpProvider.gson.toJson(map) val jsonElement = map.toJsonElement()
OkHttpProvider.gson.fromJson(json, CommandCatalog::class.java) OkHttpProvider.json.decodeFromJsonElement<CommandCatalog>(jsonElement)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to parse command catalog", e) Log.e(TAG, "Failed to parse command catalog", e)
null null
@@ -1030,10 +1036,11 @@ class ChatViewModel(
) )
} }
_streamingState.update { StreamingState() } _streamingState.update { StreamingState() }
viewModelScope.launch {
// Step 1: Load cached messages first — instant display // Step 1: Load cached messages first — instant display
loadCachedMessages(sessionId) loadCachedMessages(sessionId).join()
// Step 2: Resume session on server // Step 2: Resume session on server
viewModelScope.launch(Dispatchers.IO) { launch(Dispatchers.IO) {
wsClient.send( wsClient.send(
WsMethods.SESSION_RESUME, WsMethods.SESSION_RESUME,
mapOf("session_id" to sessionId), mapOf("session_id" to sessionId),
@@ -1045,8 +1052,9 @@ class ChatViewModel(
// Step 4: Refresh session list to get latest titles // Step 4: Refresh session list to get latest titles
loadSessions() loadSessions()
} }
}
private fun loadCachedMessages(sessionId: String) { private fun loadCachedMessages(sessionId: String): Job =
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val cachedMessages = repo.loadMessages(sessionId) val cachedMessages = repo.loadMessages(sessionId)
_uiState.update { state -> _uiState.update { state ->
@@ -1058,7 +1066,6 @@ class ChatViewModel(
} }
} }
} }
}
private fun loadSessionMessages(sessionId: String) { private fun loadSessionMessages(sessionId: String) {
viewModelScope.launch { viewModelScope.launch {
@@ -1082,7 +1089,7 @@ class ChatViewModel(
// Room duplicates when re-loading the same session. // Room duplicates when re-loading the same session.
val stableId = "rest-$sessionId-$index" val stableId = "rest-$sessionId-$index"
// Preserve original timestamp from the API when available // Preserve original timestamp from the API when available
val ts = msg.timestamp?.toLongOrNull() ?: System.currentTimeMillis() val ts = msg.timestampText?.toLongOrNull() ?: System.currentTimeMillis()
ChatMessage( ChatMessage(
id = stableId, id = stableId,
role = role, role = role,
@@ -2,6 +2,8 @@ package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.ws.WsEvent import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.toJsonElement
import kotlinx.serialization.encodeToString
/** /**
* Pure state reducer for WebSocket events. * Pure state reducer for WebSocket events.
@@ -225,7 +227,7 @@ object ChatWsEventReducer {
): ReducerResult { ): ReducerResult {
val contentJson = val contentJson =
event.data?.let { event.data?.let {
OkHttpProvider.gson.toJson(it) OkHttpProvider.json.encodeToString(it.toJsonElement())
} ?: "" } ?: ""
val toolMessage = val toolMessage =
ChatMessage( ChatMessage(
@@ -271,7 +273,7 @@ object ChatWsEventReducer {
): ReducerResult { ): ReducerResult {
val contentJson = val contentJson =
event.data?.let { event.data?.let {
OkHttpProvider.gson.toJson(it) OkHttpProvider.json.encodeToString(it.toJsonElement())
} ?: "" } ?: ""
val messages = state.messages.toMutableList() val messages = state.messages.toMutableList()
val toolIdx = val toolIdx =
@@ -53,8 +53,6 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel 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.R
import com.m57.hermescontrol.data.model.SchemaField import com.m57.hermescontrol.data.model.SchemaField
import com.m57.hermescontrol.ui.common.ErrorState 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.NavIcon
import com.m57.hermescontrol.ui.common.SearchBar import com.m57.hermescontrol.ui.common.SearchBar
import com.m57.hermescontrol.ui.common.ToastEffect 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -580,7 +583,7 @@ private fun ConfigField(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
Switch( Switch(
checked = currentValue?.asBoolean ?: false, checked = (currentValue as? JsonPrimitive)?.booleanOrNull ?: false,
onCheckedChange = { onChange(JsonPrimitive(it)) }, onCheckedChange = { onChange(JsonPrimitive(it)) },
) )
} }
@@ -590,7 +593,7 @@ private fun ConfigField(
DropdownField( DropdownField(
label = label, label = label,
options = field.options ?: emptyList(), options = field.options ?: emptyList(),
selectedValue = currentValue?.asString ?: "", selectedValue = (currentValue as? JsonPrimitive)?.content ?: "",
onOptionSelected = { onChange(JsonPrimitive(it)) }, onOptionSelected = { onChange(JsonPrimitive(it)) },
) )
if (description != label) { if (description != label) {
@@ -607,7 +610,7 @@ private fun ConfigField(
OutlinedTextField( OutlinedTextField(
value = value =
currentValue?.let { currentValue?.let {
if (it.isJsonPrimitive) it.asString else "" if (it is JsonPrimitive) it.content else ""
} ?: "", } ?: "",
onValueChange = { text -> onValueChange = { text ->
text.toDoubleOrNull()?.let { doubleVal -> text.toDoubleOrNull()?.let { doubleVal ->
@@ -630,10 +633,10 @@ private fun ConfigField(
// String or other type // String or other type
val textValue = val textValue =
currentValue?.let { currentValue?.let {
when { when (it) {
it.isJsonPrimitive -> it.asString is JsonPrimitive -> it.content
it.isJsonObject -> it.toString() is JsonObject -> it.toString()
it.isJsonArray -> it.toString() is JsonArray -> it.toString()
else -> "" else -> ""
} }
} ?: "" } ?: ""
@@ -708,20 +711,17 @@ private fun collectUncoveredPaths(
val dotPath = if (prefix.isEmpty()) key else "$prefix.$key" val dotPath = if (prefix.isEmpty()) key else "$prefix.$key"
// If this exact path is in the schema, it's covered — skip entirely // If this exact path is in the schema, it's covered — skip entirely
if (dotPath in schemaPaths) continue if (dotPath in schemaPaths) continue
if (value is com.google.gson.JsonObject) { if (value is JsonObject) {
// Recurse into nested objects to find uncovered leaves
val subKeys = value.keySet()
// Before recursing deeper, check if this whole sub-tree has any schema coverage // Before recursing deeper, check if this whole sub-tree has any schema coverage
val hasSchemaCoverage = schemaPaths.any { it == dotPath || it.startsWith("$dotPath.") } val hasSchemaCoverage = schemaPaths.any { it == dotPath || it.startsWith("$dotPath.") }
if (hasSchemaCoverage) { if (hasSchemaCoverage) {
// Schema covers some sub-paths — recurse to find what's NOT covered // Schema covers some sub-paths — recurse to find what's NOT covered
val subMap = subKeys.associateWith { value.get(it) } result.addAll(collectUncoveredPaths(value, schemaPaths, dotPath))
result.addAll(collectUncoveredPaths(subMap, schemaPaths, dotPath))
} else { } else {
// No schema coverage at all for this subtree — add the whole thing // No schema coverage at all for this subtree — add the whole thing
result.add(dotPath) 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 // Arrays: check if the path is in schema; if not, add it
if (dotPath !in schemaPaths) { if (dotPath !in schemaPaths) {
result.add(dotPath) result.add(dotPath)
@@ -746,7 +746,7 @@ private fun getJsonValue(
if (i == 0) { if (i == 0) {
current = config[key] current = config[key]
} else { } else {
current = current?.asJsonObject?.get(key) current = (current as? JsonObject)?.get(key)
} }
if (current == null) return null if (current == null) return null
} }
@@ -2,8 +2,6 @@ package com.m57.hermescontrol.ui.config
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.ConfigSchemaResponse
import com.m57.hermescontrol.data.model.ConfigUpdateRequest import com.m57.hermescontrol.data.model.ConfigUpdateRequest
import com.m57.hermescontrol.data.model.UpdateRawConfigRequest import com.m57.hermescontrol.data.model.UpdateRawConfigRequest
@@ -19,6 +17,8 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
data class ConfigUiState( data class ConfigUiState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
@@ -296,15 +296,14 @@ class ConfigViewModel :
val topKey = parts.first() val topKey = parts.first()
val rest = parts.drop(1).joinToString(".") val rest = parts.drop(1).joinToString(".")
val topValue = config[topKey] val topValue = config[topKey]
val nestedJson = topValue?.asJsonObject ?: JsonObject() val nestedJson = (topValue as? JsonObject) ?: JsonObject(emptyMap())
val updatedNested = val updatedNested =
applyNestedValue( applyNestedValue(
nestedJson.entrySet().associate { it.key to it.value }, nestedJson,
rest, rest,
value, value,
) )
val nestedObj = JsonObject() val nestedObj = JsonObject(updatedNested)
updatedNested.forEach { (k, v) -> nestedObj.add(k, v) }
val mutable = config.toMutableMap() val mutable = config.toMutableMap()
mutable[topKey] = nestedObj mutable[topKey] = nestedObj
return mutable return mutable
@@ -312,23 +311,38 @@ class ConfigViewModel :
/** Build a nested JSON changeset from dot-path pending changes. */ /** Build a nested JSON changeset from dot-path pending changes. */
private fun buildChangeset(changes: Map<String, JsonElement>): Map<String, JsonElement> { private fun buildChangeset(changes: Map<String, JsonElement>): Map<String, JsonElement> {
val root = JsonObject() val root = mutableMapOf<String, Any>()
for ((dotPath, value) in changes) { for ((dotPath, value) in changes) {
val parts = dotPath.split(".") val parts = dotPath.split(".")
var current = root var current = root
for (i in 0 until parts.size - 1) { for (i in 0 until parts.size - 1) {
val key = parts[i] val key = parts[i]
val existing = current.get(key) val existing = current[key]
if (existing == null || !existing.isJsonObject) { if (existing !is MutableMap<*, *>) {
val newObj = JsonObject() val newMap = mutableMapOf<String, Any>()
current.add(key, newObj) current[key] = newMap
current = newObj current = newMap
} else { } 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.ApiClient
import com.m57.hermescontrol.data.remote.NetworkError import com.m57.hermescontrol.data.remote.NetworkError
import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.remote.safeApiCall
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -17,6 +18,8 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
data class ConnectUiState( data class ConnectUiState(
val token: String = "", val token: String = "",
@@ -317,13 +320,10 @@ class ConnectViewModel(
val decodedBytes = android.util.Base64.decode(trimmed, android.util.Base64.DEFAULT) val decodedBytes = android.util.Base64.decode(trimmed, android.util.Base64.DEFAULT)
val decodedString = String(decodedBytes, Charsets.UTF_8) val decodedString = String(decodedBytes, Charsets.UTF_8)
if (decodedString.startsWith("{") && decodedString.endsWith("}")) { if (decodedString.startsWith("{") && decodedString.endsWith("}")) {
val json = val json = OkHttpProvider.json.parseToJsonElement(decodedString).jsonObject
com.google.gson.JsonParser val host = json["host"]?.jsonPrimitive?.content
.parseString(decodedString) val port = json["port"]?.jsonPrimitive?.content
.asJsonObject val token = json["token"]?.jsonPrimitive?.content
val host = json.get("host")?.asString
val port = json.get("port")?.asInt?.toString() ?: json.get("port")?.asString
val token = json.get("token")?.asString
if (host != null && port != null && token != null) { if (host != null && port != null && token != null) {
_uiState.update { _uiState.update {
it.copy( 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.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall 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.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad import com.m57.hermescontrol.ui.common.safeLaunchLoad
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -18,6 +19,8 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
data class CronJobsUiState( data class CronJobsUiState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
@@ -292,7 +295,7 @@ class CronJobsViewModel :
safeApiCall { safeApiCall {
ApiClient.hermesApi.updateCronJob( ApiClient.hermesApi.updateCronJob(
editor.jobId ?: "", editor.jobId ?: "",
UpdateCronJobRequest(updates = updates), UpdateCronJobRequest(updates = updates.mapValues { it.value.toJsonElement() }),
) )
} }
} }
@@ -336,8 +339,8 @@ class CronJobsViewModel :
private fun extractScheduleString(job: CronJob): String { private fun extractScheduleString(job: CronJob): String {
val s = job.schedule val s = job.schedule
return when (s) { return when (s) {
is String -> s is JsonPrimitive -> s.content
is Map<*, *> -> (s["value"] as? String) ?: job.scheduleText is JsonObject -> (s["value"] as? JsonPrimitive)?.content ?: job.scheduleText
else -> job.scheduleText else -> job.scheduleText
} }
} }
@@ -267,7 +267,7 @@ fun GatewayScreen(
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
) )
val stateText = pStatus.state?.uppercase() ?: "UNKNOWN" val stateText = pStatus?.state?.uppercase() ?: "UNKNOWN"
val badgeColor = val badgeColor =
when (stateText) { when (stateText) {
"RUNNING" -> Color(0xFF4CAF50) "RUNNING" -> Color(0xFF4CAF50)
@@ -6,12 +6,12 @@ import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.google.gson.JsonParser
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkError import com.m57.hermescontrol.data.remote.NetworkError
import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.remote.safeApiCall
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -20,6 +20,8 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
/** /**
* UI state for the pairing code entry screen. * UI state for the pairing code entry screen.
@@ -88,12 +90,10 @@ class PairingCodeEntryViewModel(
val decodedBytes = Base64.decode(trimmed, Base64.DEFAULT) val decodedBytes = Base64.decode(trimmed, Base64.DEFAULT)
val decodedString = String(decodedBytes, Charsets.UTF_8) val decodedString = String(decodedBytes, Charsets.UTF_8)
if (decodedString.startsWith("{") && decodedString.endsWith("}")) { if (decodedString.startsWith("{") && decodedString.endsWith("}")) {
val json = JsonParser.parseString(decodedString).asJsonObject val json = OkHttpProvider.json.parseToJsonElement(decodedString).jsonObject
val host = json.get("host")?.asString val host = json["host"]?.jsonPrimitive?.content
val port = val port = json["port"]?.jsonPrimitive?.content
json.get("port")?.asInt?.toString() val token = json["token"]?.jsonPrimitive?.content
?: json.get("port")?.asString
val token = json.get("token")?.asString
// Accept raw "host" + "port" + "token", or a composite "pairing" payload // Accept raw "host" + "port" + "token", or a composite "pairing" payload
if (host != null && port != null && token != null) { if (host != null && port != null && token != null) {
connectToDashboard(host, port.toIntOrNull() ?: 9119, token) connectToDashboard(host, port.toIntOrNull() ?: 9119, token)
@@ -786,7 +786,7 @@ private fun LazyListScope.gatewaySection(
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
platform.state?.let { platformState -> platform?.state?.let { platformState ->
StatusBadge( StatusBadge(
text = platformState, text = platformState,
status = status =
@@ -61,6 +61,7 @@ import io.mockk.*
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.* import kotlinx.coroutines.test.*
import kotlinx.serialization.json.JsonPrimitive
import okhttp3.ResponseBody.Companion.toResponseBody import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.After import org.junit.After
import org.junit.Assert.* import org.junit.Assert.*
@@ -332,7 +333,10 @@ class E2eIntegrationTest {
advanceUntilIdle() advanceUntilIdle()
assertFalse(viewModel.uiState.value.isHubSearching) assertFalse(viewModel.uiState.value.isHubSearching)
assertTrue(viewModel.uiState.value.hubResults.isEmpty()) assertTrue(
viewModel.uiState.value.hubResults
.isEmpty(),
)
assertNull(viewModel.uiState.value.hubSearchError) assertNull(viewModel.uiState.value.hubSearchError)
} }
@@ -349,7 +353,8 @@ class E2eIntegrationTest {
assertFalse(viewModel.uiState.value.isHubSearching) assertFalse(viewModel.uiState.value.isHubSearching)
assertNotNull(viewModel.uiState.value.hubSearchError) assertNotNull(viewModel.uiState.value.hubSearchError)
assertTrue( assertTrue(
viewModel.uiState.value.hubSearchError!!.contains("HTTP 500"), viewModel.uiState.value.hubSearchError!!
.contains("HTTP 500"),
) )
} }
@@ -360,7 +365,10 @@ class E2eIntegrationTest {
viewModel.searchHub("") viewModel.searchHub("")
// Empty query should clear immediately without API call // Empty query should clear immediately without API call
assertTrue(viewModel.uiState.value.hubResults.isEmpty()) assertTrue(
viewModel.uiState.value.hubResults
.isEmpty(),
)
assertFalse(viewModel.uiState.value.isHubSearching) assertFalse(viewModel.uiState.value.isHubSearching)
assertNull(viewModel.uiState.value.hubSearchError) assertNull(viewModel.uiState.value.hubSearchError)
} }
@@ -384,7 +392,8 @@ class E2eIntegrationTest {
assertNull(viewModel.uiState.value.installingSkillName) assertNull(viewModel.uiState.value.installingSkillName)
assertNotNull(viewModel.uiState.value.toastMessage) assertNotNull(viewModel.uiState.value.toastMessage)
assertTrue( assertTrue(
viewModel.uiState.value.toastMessage!!.contains("Installed"), viewModel.uiState.value.toastMessage!!
.contains("Installed"),
) )
// Refresh was called // Refresh was called
assertFalse(viewModel.uiState.value.isLoading) assertFalse(viewModel.uiState.value.isLoading)
@@ -405,7 +414,8 @@ class E2eIntegrationTest {
assertNull(viewModel.uiState.value.installingSkillName) assertNull(viewModel.uiState.value.installingSkillName)
assertNotNull(viewModel.uiState.value.toastMessage) assertNotNull(viewModel.uiState.value.toastMessage)
assertTrue( assertTrue(
viewModel.uiState.value.toastMessage!!.contains("HTTP 500"), viewModel.uiState.value.toastMessage!!
.contains("HTTP 500"),
) )
} }
@@ -425,7 +435,8 @@ class E2eIntegrationTest {
assertNull(viewModel.uiState.value.uninstallingSkillName) assertNull(viewModel.uiState.value.uninstallingSkillName)
assertNotNull(viewModel.uiState.value.toastMessage) assertNotNull(viewModel.uiState.value.toastMessage)
assertTrue( assertTrue(
viewModel.uiState.value.toastMessage!!.contains("Uninstalled"), viewModel.uiState.value.toastMessage!!
.contains("Uninstalled"),
) )
// Refresh was called — skills list is now empty // Refresh was called — skills list is now empty
assertEquals(0, viewModel.uiState.value.skills.size) assertEquals(0, viewModel.uiState.value.skills.size)
@@ -445,7 +456,8 @@ class E2eIntegrationTest {
assertNull(viewModel.uiState.value.uninstallingSkillName) assertNull(viewModel.uiState.value.uninstallingSkillName)
assertNotNull(viewModel.uiState.value.toastMessage) assertNotNull(viewModel.uiState.value.toastMessage)
assertTrue( assertTrue(
viewModel.uiState.value.toastMessage!!.contains("HTTP 500"), viewModel.uiState.value.toastMessage!!
.contains("HTTP 500"),
) )
} }
@@ -487,7 +499,8 @@ class E2eIntegrationTest {
assertNull(viewModel.uiState.value.previewSkillContent) assertNull(viewModel.uiState.value.previewSkillContent)
assertNotNull(viewModel.uiState.value.toastMessage) assertNotNull(viewModel.uiState.value.toastMessage)
assertTrue( assertTrue(
viewModel.uiState.value.toastMessage!!.contains("HTTP 500"), viewModel.uiState.value.toastMessage!!
.contains("HTTP 500"),
) )
} }
@@ -495,7 +508,7 @@ class E2eIntegrationTest {
@Test @Test
fun testCronJobsListing_success() = fun testCronJobsListing_success() =
runTest { runTest {
val job = CronJob("id1", "Job 1", "*/5 * * * *", "active", "success", "2026-06-15T15:10:00Z") val job = CronJob("id1", "Job 1", JsonPrimitive("*/5 * * * *"), "active", "success", "2026-06-15T15:10:00Z")
coEvery { mockApiService.getCronJobs() } returns Response.success(listOf(job)) coEvery { mockApiService.getCronJobs() } returns Response.success(listOf(job))
val viewModel = CronJobsViewModel() val viewModel = CronJobsViewModel()
@@ -536,7 +549,7 @@ class E2eIntegrationTest {
@Test @Test
fun testCronJobPause_success() = fun testCronJobPause_success() =
runTest { runTest {
val job = CronJob("id1", "Job 1", "*/5 * * * *", "active", null, null) val job = CronJob("id1", "Job 1", JsonPrimitive("*/5 * * * *"), "active", null, null)
coEvery { mockApiService.getCronJobs() } returns Response.success(listOf(job)) coEvery { mockApiService.getCronJobs() } returns Response.success(listOf(job))
coEvery { mockApiService.pauseCronJob("id1") } returns Response.success(Unit) coEvery { mockApiService.pauseCronJob("id1") } returns Response.success(Unit)
@@ -563,7 +576,7 @@ class E2eIntegrationTest {
@Test @Test
fun testCronJobResume_success() = fun testCronJobResume_success() =
runTest { runTest {
val job = CronJob("id1", "Job 1", "*/5 * * * *", "paused", null, null) val job = CronJob("id1", "Job 1", JsonPrimitive("*/5 * * * *"), "paused", null, null)
coEvery { mockApiService.getCronJobs() } returns Response.success(listOf(job)) coEvery { mockApiService.getCronJobs() } returns Response.success(listOf(job))
coEvery { mockApiService.resumeCronJob("id1") } returns Response.success(Unit) coEvery { mockApiService.resumeCronJob("id1") } returns Response.success(Unit)
@@ -1,19 +1,40 @@
package com.m57.hermescontrol.data.model package com.m57.hermescontrol.data.model
import com.google.gson.Gson import com.m57.hermescontrol.data.remote.OkHttpProvider
import org.junit.jupiter.api.Assertions.assertEquals import kotlinx.serialization.decodeFromString
import org.junit.jupiter.api.Assertions.assertFalse import kotlinx.serialization.encodeToString
import org.junit.jupiter.api.Assertions.assertNotNull import kotlinx.serialization.json.JsonElement
import org.junit.jupiter.api.Assertions.assertNull import kotlinx.serialization.json.JsonPrimitive
import org.junit.jupiter.api.Assertions.assertTrue import kotlinx.serialization.json.booleanOrNull
import org.junit.jupiter.api.Test import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ModelSerializationTest { class ModelSerializationTest {
private val gson = Gson() private val json = OkHttpProvider.json
@Test @Test
fun testSkillDeserialization_allFields() { fun testSkillSerialization() {
val json = val skill =
Skill(
name = "weather",
description = "Get current weather info",
category = "utility",
enabled = true,
)
val jsonStr = json.encodeToString(skill)
val deserialized = json.decodeFromString<Skill>(jsonStr)
assertEquals(skill.name, deserialized.name)
assertEquals(skill.description, deserialized.description)
assertEquals(skill.category, deserialized.category)
assertEquals(skill.enabled, deserialized.enabled)
}
@Test
fun testSkillDeserialization() {
val jsonStr =
""" """
{ {
"name": "weather", "name": "weather",
@@ -22,7 +43,7 @@ class ModelSerializationTest {
"enabled": true "enabled": true
} }
""".trimIndent() """.trimIndent()
val skill = gson.fromJson(json, Skill::class.java) val skill = json.decodeFromString<Skill>(jsonStr)
assertEquals("weather", skill.name) assertEquals("weather", skill.name)
assertEquals("Get current weather info", skill.description) assertEquals("Get current weather info", skill.description)
assertEquals("utility", skill.category) assertEquals("utility", skill.category)
@@ -30,170 +51,145 @@ class ModelSerializationTest {
} }
@Test @Test
fun testSkillDeserialization_optionalFieldsNull() { fun testCronJobSerialization() {
val json = val job =
""" CronJob(
{ id = "job-123",
"name": "light_control", name = "Daily Backup",
"description": null, schedule = JsonPrimitive("0 0 * * *"),
"category": null, state = "active",
"enabled": false last_run_status = null,
} next_run = null,
""".trimIndent() )
val skill = gson.fromJson(json, Skill::class.java) val jsonStr = json.encodeToString(job)
assertEquals("light_control", skill.name) val deserialized = json.decodeFromString<CronJob>(jsonStr)
assertNull(skill.description) assertEquals(job.id, deserialized.id)
assertNull(skill.category) assertEquals(job.name, deserialized.name)
assertEquals(false, skill.enabled) assertEquals(job.schedule, deserialized.schedule)
assertEquals(job.state, deserialized.state)
} }
@Test @Test
fun testCronJobDeserialization_allFields() { fun testCronJobDeserialization() {
val json = val jsonStr =
""" """
{ {
"id": "job-123", "id": "job-123",
"name": "Daily Backup", "name": "Daily Backup",
"schedule": "0 0 * * *", "schedule": "0 0 * * *",
"state": "active", "state": "active"
"last_run_status": "success",
"next_run": "2026-06-16T00:00:00Z"
} }
""".trimIndent() """.trimIndent()
val job = gson.fromJson(json, CronJob::class.java) val job = json.decodeFromString<CronJob>(jsonStr)
assertEquals("job-123", job.id) assertEquals("job-123", job.id)
assertEquals("Daily Backup", job.name) assertEquals("Daily Backup", job.name)
assertEquals("0 0 * * *", job.schedule) assertEquals("0 0 * * *", job.scheduleText)
assertEquals("active", job.state) assertEquals("active", job.state)
assertEquals("success", job.last_run_status)
assertEquals("2026-06-16T00:00:00Z", job.next_run)
}
@Test
fun testCronJobDeserialization_optionalFieldsNull() {
val json =
"""
{
"id": "job-456",
"name": "One-off Task",
"schedule": null,
"state": null,
"last_run_status": null,
"next_run": null
}
""".trimIndent()
val job = gson.fromJson(json, CronJob::class.java)
assertEquals("job-456", job.id)
assertEquals("One-off Task", job.name)
assertNull(job.schedule)
assertNull(job.state)
assertNull(job.last_run_status)
assertNull(job.next_run)
} }
@Test @Test
fun testToggleSkillRequestSerialization() { fun testToggleSkillRequestSerialization() {
val request = ToggleSkillRequest(name = "weather", enabled = false) val request = ToggleSkillRequest(name = "weather", enabled = false)
val json = gson.toJson(request) val jsonStr = json.encodeToString(request)
// Ensure serialization produces correct JSON structure // Ensure serialization produces correct JSON structure
val parsed = gson.fromJson(json, Map::class.java) val parsed = json.decodeFromString<Map<String, JsonElement>>(jsonStr)
assertEquals("weather", parsed["name"]) assertEquals("weather", (parsed["name"] as? JsonPrimitive)?.content)
assertEquals(false, parsed["enabled"]) assertEquals(false, (parsed["enabled"] as? JsonPrimitive)?.booleanOrNull)
} }
@Test @Test
fun testSkillDeserialization_missingRequiredFields() { fun testSkillDeserialization_missingRequiredFields() {
val json = "{}" val jsonStr = "{}"
val skill = gson.fromJson(json, Skill::class.java) org.junit.jupiter.api.Assertions.assertThrows(kotlinx.serialization.SerializationException::class.java) {
// Verify null-safety issue: Gson allows non-nullable 'name' to be null at runtime json.decodeFromString<Skill>(jsonStr)
assertNull(skill.name) }
// Primitive boolean defaults to false in Gson/JVM when missing
assertEquals(false, skill.enabled)
} }
@Test @Test
fun testSkillDeserialization_explicitNullForNonNullable() { fun testSkillDeserialization_explicitNullForNonNullable() {
val json = val jsonStr =
""" """
{ {
"name": null, "name": null,
"enabled": null "enabled": null
} }
""".trimIndent() """.trimIndent()
val skill = gson.fromJson(json, Skill::class.java) org.junit.jupiter.api.Assertions.assertThrows(kotlinx.serialization.SerializationException::class.java) {
assertNull(skill.name) json.decodeFromString<Skill>(jsonStr)
assertEquals(false, skill.enabled) }
} }
@Test @Test
fun testCronJobDeserialization_missingRequiredFields() { fun testCronJobDeserialization_missingRequiredFields() {
val json = "{}" val jsonStr = "{}"
val job = gson.fromJson(json, CronJob::class.java) org.junit.jupiter.api.Assertions.assertThrows(kotlinx.serialization.SerializationException::class.java) {
assertNull(job.id) json.decodeFromString<CronJob>(jsonStr)
assertNull(job.name) }
} }
@Test @Test
fun testSessionListResponseDeserialization_missingRequiredFields() { fun testSessionListResponseDeserialization_missingRequiredFields() {
val json = "{}" val jsonStr = "{}"
val response = gson.fromJson(json, SessionListResponse::class.java) org.junit.jupiter.api.Assertions.assertThrows(kotlinx.serialization.SerializationException::class.java) {
assertNull(response.sessions) json.decodeFromString<SessionListResponse>(jsonStr)
}
} }
@Test @Test
fun testSessionInfoDeserialization_missingRequiredFields() { fun testSessionInfoDeserialization_missingRequiredFields() {
val json = "{}" val jsonStr = "{}"
val info = gson.fromJson(json, SessionInfo::class.java) org.junit.jupiter.api.Assertions.assertThrows(kotlinx.serialization.SerializationException::class.java) {
assertNull(info.id) json.decodeFromString<SessionInfo>(jsonStr)
assertNull(info.title) }
} }
@Test @Test
fun testSessionMessagesResponseDeserialization_missingRequiredFields() { fun testSessionMessagesResponseDeserialization_missingRequiredFields() {
val json = "{}" val jsonStr = "{}"
val response = gson.fromJson(json, SessionMessagesResponse::class.java) org.junit.jupiter.api.Assertions.assertThrows(kotlinx.serialization.SerializationException::class.java) {
assertNull(response.messages) json.decodeFromString<SessionMessagesResponse>(jsonStr)
}
} }
@Test @Test
fun testSkillDeserialization_malformedJson() { fun testSkillDeserialization_malformedJson() {
val json = "{\"name\": \"weather\"," val jsonStr = "{\"name\": \"weather\","
org.junit.jupiter.api.Assertions.assertThrows(com.google.gson.JsonSyntaxException::class.java) { org.junit.jupiter.api.Assertions.assertThrows(kotlinx.serialization.SerializationException::class.java) {
gson.fromJson(json, Skill::class.java) json.decodeFromString<Skill>(jsonStr)
} }
} }
@Test @Test
fun testSkillDeserialization_typeMismatchObject() { fun testSkillDeserialization_typeMismatchObject() {
val json = val jsonStr =
""" """
{ {
"name": "weather", "name": "weather",
"enabled": {} "enabled": {}
} }
""".trimIndent() """.trimIndent()
org.junit.jupiter.api.Assertions.assertThrows(com.google.gson.JsonSyntaxException::class.java) { org.junit.jupiter.api.Assertions.assertThrows(kotlinx.serialization.SerializationException::class.java) {
gson.fromJson(json, Skill::class.java) json.decodeFromString<Skill>(jsonStr)
} }
} }
@Test @Test
fun testSkillDeserialization_typeMismatchArray() { fun testSkillDeserialization_typeMismatchArray() {
val json = val jsonStr =
""" """
{ {
"name": "weather", "name": "weather",
"enabled": [] "enabled": []
} }
""".trimIndent() """.trimIndent()
org.junit.jupiter.api.Assertions.assertThrows(com.google.gson.JsonSyntaxException::class.java) { org.junit.jupiter.api.Assertions.assertThrows(kotlinx.serialization.SerializationException::class.java) {
gson.fromJson(json, Skill::class.java) json.decodeFromString<Skill>(jsonStr)
} }
} }
@Test @Test
fun testSkillDeserialization_missingName_causesNullOnNonNullableField() { fun testSkillDeserialization_missingName_causesNullOnNonNullableField() {
val json = val jsonStr =
""" """
{ {
"description": "Get current weather info", "description": "Get current weather info",
@@ -201,15 +197,14 @@ class ModelSerializationTest {
"enabled": true "enabled": true
} }
""".trimIndent() """.trimIndent()
val skill = gson.fromJson(json, Skill::class.java) org.junit.jupiter.api.Assertions.assertThrows(kotlinx.serialization.SerializationException::class.java) {
// Gson bypasses Kotlin null-safety constraints, resulting in null on non-nullable field json.decodeFromString<Skill>(jsonStr)
val nameNullable: String? = skill.name }
assertNull(nameNullable)
} }
@Test @Test
fun testSkillDeserialization_nullName_causesNullOnNonNullableField() { fun testSkillDeserialization_nullName_causesNullOnNonNullableField() {
val json = val jsonStr =
""" """
{ {
"name": null, "name": null,
@@ -218,14 +213,14 @@ class ModelSerializationTest {
"enabled": true "enabled": true
} }
""".trimIndent() """.trimIndent()
val skill = gson.fromJson(json, Skill::class.java) org.junit.jupiter.api.Assertions.assertThrows(kotlinx.serialization.SerializationException::class.java) {
val nameNullable: String? = skill.name json.decodeFromString<Skill>(jsonStr)
assertNull(nameNullable) }
} }
@Test @Test
fun testSkillDeserialization_missingEnabled_usesJvmDefaultValue() { fun testSkillDeserialization_missingEnabled_usesJvmDefaultValue() {
val json = val jsonStr =
""" """
{ {
"name": "weather", "name": "weather",
@@ -233,14 +228,14 @@ class ModelSerializationTest {
"category": "utility" "category": "utility"
} }
""".trimIndent() """.trimIndent()
val skill = gson.fromJson(json, Skill::class.java) org.junit.jupiter.api.Assertions.assertThrows(kotlinx.serialization.SerializationException::class.java) {
// Boolean primitive defaults to false json.decodeFromString<Skill>(jsonStr)
assertEquals(false, skill.enabled) }
} }
@Test @Test
fun testCronJobDeserialization_missingId_causesNullOnNonNullableField() { fun testCronJobDeserialization_missingId_causesNullOnNonNullableField() {
val json = val jsonStr =
""" """
{ {
"name": "Daily Backup", "name": "Daily Backup",
@@ -248,52 +243,52 @@ class ModelSerializationTest {
"state": "active" "state": "active"
} }
""".trimIndent() """.trimIndent()
val job = gson.fromJson(json, CronJob::class.java) org.junit.jupiter.api.Assertions.assertThrows(kotlinx.serialization.SerializationException::class.java) {
val idNullable: String? = job.id json.decodeFromString<CronJob>(jsonStr)
assertNull(idNullable) }
} }
@Test @Test
fun testSessionListResponseDeserialization_missingSessions_causesNullOnNonNullableField() { fun testSessionListResponseDeserialization_missingSessions_causesNullOnNonNullableField() {
val json = "{}" val jsonStr = "{}"
val response = gson.fromJson(json, SessionListResponse::class.java) org.junit.jupiter.api.Assertions.assertThrows(kotlinx.serialization.SerializationException::class.java) {
val sessionsNullable: List<SessionInfo>? = response.sessions json.decodeFromString<SessionListResponse>(jsonStr)
assertNull(sessionsNullable) }
} }
@Test @Test
fun testSessionInfoDeserialization_missingId_causesNullOnNonNullableField() { fun testSessionInfoDeserialization_missingId_causesNullOnNonNullableField() {
val json = val jsonStr =
""" """
{ {
"title": "My Session", "title": "My Session",
"created_at": "2026-06-15T12:00:00Z" "created_at": "2026-06-15T12:00:00Z"
} }
""".trimIndent() """.trimIndent()
val info = gson.fromJson(json, SessionInfo::class.java) org.junit.jupiter.api.Assertions.assertThrows(kotlinx.serialization.SerializationException::class.java) {
val idNullable: String? = info.id json.decodeFromString<SessionInfo>(jsonStr)
assertNull(idNullable) }
} }
@Test @Test
fun testSessionMessagesResponseDeserialization_missingMessages_causesNullOnNonNullableField() { fun testSessionMessagesResponseDeserialization_missingMessages_causesNullOnNonNullableField() {
val json = "{}" val jsonStr = "{}"
val response = gson.fromJson(json, SessionMessagesResponse::class.java) org.junit.jupiter.api.Assertions.assertThrows(kotlinx.serialization.SerializationException::class.java) {
val messagesNullable: List<SessionMessage>? = response.messages json.decodeFromString<SessionMessagesResponse>(jsonStr)
assertNull(messagesNullable) }
} }
@Test @Test
fun testDeserialization_malformedJson_throwsJsonSyntaxException() { fun testDeserialization_malformedJson_throwsJsonSyntaxException() {
val json = """{"name": "weather", "enabled": """ val jsonStr = """{"name": "weather", "enabled": """
org.junit.jupiter.api.Assertions.assertThrows(com.google.gson.JsonSyntaxException::class.java) { org.junit.jupiter.api.Assertions.assertThrows(kotlinx.serialization.SerializationException::class.java) {
gson.fromJson(json, Skill::class.java) json.decodeFromString<Skill>(jsonStr)
} }
} }
@Test @Test
fun testStatusResponseDeserialization_allFields() { fun testStatusResponseDeserialization_allFields() {
val json = val jsonStr =
""" """
{ {
"version": "1.0.0", "version": "1.0.0",
@@ -312,7 +307,7 @@ class ModelSerializationTest {
} }
} }
""".trimIndent() """.trimIndent()
val response = gson.fromJson(json, StatusResponse::class.java) val response = json.decodeFromString<StatusResponse>(jsonStr)
assertEquals("1.0.0", response.version) assertEquals("1.0.0", response.version)
assertEquals(true, response.gateway_running) assertEquals(true, response.gateway_running)
assertEquals(5, response.active_sessions) assertEquals(5, response.active_sessions)
@@ -326,8 +321,8 @@ class ModelSerializationTest {
@Test @Test
fun testStatusResponseDeserialization_missingFields() { fun testStatusResponseDeserialization_missingFields() {
val json = "{}" val jsonStr = "{}"
val response = gson.fromJson(json, StatusResponse::class.java) val response = json.decodeFromString<StatusResponse>(jsonStr)
assertNull(response.version) assertNull(response.version)
assertNull(response.gateway_running) assertNull(response.gateway_running)
assertNull(response.active_sessions) assertNull(response.active_sessions)
@@ -337,7 +332,7 @@ class ModelSerializationTest {
@Test @Test
fun testStatusResponseDeserialization_gatewayPlatformsNullValue() { fun testStatusResponseDeserialization_gatewayPlatformsNullValue() {
val json = val jsonStr =
""" """
{ {
"gateway_platforms": { "gateway_platforms": {
@@ -348,7 +343,7 @@ class ModelSerializationTest {
} }
} }
""".trimIndent() """.trimIndent()
val response = gson.fromJson(json, StatusResponse::class.java) val response = json.decodeFromString<StatusResponse>(jsonStr)
val platforms = response.gateway_platforms val platforms = response.gateway_platforms
assertNotNull(platforms) assertNotNull(platforms)
assertNull(platforms!!["android"]) assertNull(platforms!!["android"])
@@ -360,20 +355,20 @@ class ModelSerializationTest {
@Test @Test
fun testStatusResponseDeserialization_typeMismatchInGatewayPlatforms() { fun testStatusResponseDeserialization_typeMismatchInGatewayPlatforms() {
val json = val jsonStr =
""" """
{ {
"gateway_platforms": "not_a_map" "gateway_platforms": "not_a_map"
} }
""".trimIndent() """.trimIndent()
org.junit.jupiter.api.Assertions.assertThrows(com.google.gson.JsonSyntaxException::class.java) { org.junit.jupiter.api.Assertions.assertThrows(kotlinx.serialization.SerializationException::class.java) {
gson.fromJson(json, StatusResponse::class.java) json.decodeFromString<StatusResponse>(jsonStr)
} }
} }
@Test @Test
fun testSystemStatsResponse_deserialization() { fun testSystemStatsResponse_deserialization() {
val json = val jsonStr =
""" """
{ {
"os": "Linux", "os": "Linux",
@@ -402,7 +397,7 @@ class ModelSerializationTest {
} }
} }
""".trimIndent() """.trimIndent()
val response = gson.fromJson(json, SystemStatsResponse::class.java) val response = json.decodeFromString<SystemStatsResponse>(jsonStr)
assertEquals("Linux", response.os) assertEquals("Linux", response.os)
assertEquals("aarch64", response.arch) assertEquals("aarch64", response.arch)
assertEquals("hermes-box", response.hostname) assertEquals("hermes-box", response.hostname)
@@ -413,36 +408,31 @@ class ModelSerializationTest {
assertEquals(1.5, response.load_avg?.get(0) ?: 0.0, 0.01) assertEquals(1.5, response.load_avg?.get(0) ?: 0.0, 0.01)
assertEquals(86400.0, response.uptime_seconds ?: 0.0, 0.01) assertEquals(86400.0, response.uptime_seconds ?: 0.0, 0.01)
assertEquals(50.0, response.memory?.percent ?: 0.0, 0.01) assertEquals(50.0, response.memory?.percent ?: 0.0, 0.01)
assertEquals(8388608, response.memory?.total) assertEquals(8388608L, response.memory?.total)
assertEquals(50.0, response.disk?.percent ?: 0.0, 0.01) assertEquals(50.0, response.disk?.percent ?: 0.0, 0.01)
} }
@Test @Test
fun testToggleSkillRequestDeserialization_missingRequiredFields() { fun testToggleSkillRequestDeserialization_missingRequiredFields() {
val json = """{"enabled":true}""" val jsonStr = """{"enabled":true}"""
val request = gson.fromJson(json, ToggleSkillRequest::class.java) org.junit.jupiter.api.Assertions.assertThrows(kotlinx.serialization.SerializationException::class.java) {
val nameNullable: String? = request.name json.decodeFromString<ToggleSkillRequest>(jsonStr)
assertNull(nameNullable) }
assertEquals(true, request.enabled)
} }
@Test @Test
fun testToggleSkillRequestDeserialization_explicitNull() { fun testToggleSkillRequestDeserialization_explicitNull() {
val json = """{"name":null,"enabled":null}""" val jsonStr = """{"name":null,"enabled":null}"""
val request = gson.fromJson(json, ToggleSkillRequest::class.java) org.junit.jupiter.api.Assertions.assertThrows(kotlinx.serialization.SerializationException::class.java) {
val nameNullable: String? = request.name json.decodeFromString<ToggleSkillRequest>(jsonStr)
assertNull(nameNullable) }
assertEquals(false, request.enabled)
} }
@Test @Test
fun testToggleSkillRequestSerialization_withNullName() { fun testToggleSkillRequestSerialization_withNullName() {
val jsonInput = """{"name":null,"enabled":true}""" val jsonInput = """{"name":null,"enabled":true}"""
val request = gson.fromJson(jsonInput, ToggleSkillRequest::class.java) org.junit.jupiter.api.Assertions.assertThrows(kotlinx.serialization.SerializationException::class.java) {
json.decodeFromString<ToggleSkillRequest>(jsonInput)
val jsonOutput = gson.toJson(request) }
val parsed = gson.fromJson(jsonOutput, Map::class.java)
assertFalse(parsed.containsKey("name"))
assertEquals(true, parsed["enabled"])
} }
} }
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.data.remote
import com.m57.hermescontrol.data.model.SessionMessage import com.m57.hermescontrol.data.model.SessionMessage
import com.m57.hermescontrol.data.model.StatusResponse import com.m57.hermescontrol.data.model.StatusResponse
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer import okhttp3.mockwebserver.MockWebServer
import org.junit.After import org.junit.After
@@ -13,7 +14,7 @@ import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.kotlinx.serialization.asConverterFactory
/** /**
* MockWebServer-based tests for [HermesApiService]. * MockWebServer-based tests for [HermesApiService].
@@ -35,7 +36,7 @@ class HermesApiServiceMockWebServerTest {
Retrofit Retrofit
.Builder() .Builder()
.baseUrl(mockServer.url("/")) .baseUrl(mockServer.url("/"))
.addConverterFactory(GsonConverterFactory.create(OkHttpProvider.gson)) .addConverterFactory(OkHttpProvider.json.asConverterFactory("application/json".toMediaType()))
.build() .build()
.create(HermesApiService::class.java) .create(HermesApiService::class.java)
} }
@@ -244,7 +245,7 @@ class HermesApiServiceMockWebServerTest {
assertEquals("user", messages[0].role) assertEquals("user", messages[0].role)
assertEquals("Hello Hermes", messages[0].content) assertEquals("Hello Hermes", messages[0].content)
assertEquals("1718000000", messages[0].timestamp) assertEquals("1718000000", messages[0].timestampText)
assertEquals("assistant", messages[1].role) assertEquals("assistant", messages[1].role)
assertEquals("Hi! How can I help?", messages[1].content) assertEquals("Hi! How can I help?", messages[1].content)
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.data.remote
import com.m57.hermescontrol.data.model.ToggleSkillRequest import com.m57.hermescontrol.data.model.ToggleSkillRequest
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer import okhttp3.mockwebserver.MockWebServer
import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.AfterEach
@@ -13,7 +14,7 @@ import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.kotlinx.serialization.asConverterFactory
class HermesApiServiceTest { class HermesApiServiceTest {
private lateinit var mockWebServer: MockWebServer private lateinit var mockWebServer: MockWebServer
@@ -28,7 +29,7 @@ class HermesApiServiceTest {
Retrofit Retrofit
.Builder() .Builder()
.baseUrl(mockWebServer.url("/")) .baseUrl(mockWebServer.url("/"))
.addConverterFactory(GsonConverterFactory.create()) .addConverterFactory(OkHttpProvider.json.asConverterFactory("application/json".toMediaType()))
.build() .build()
apiService = retrofit.create(HermesApiService::class.java) apiService = retrofit.create(HermesApiService::class.java)
@@ -151,57 +152,48 @@ class HermesApiServiceTest {
} }
@Test @Test
fun testGetSkills_missingFieldsInResponse() = fun testGetSkills_missingFieldsInResponse() {
runTest {
mockWebServer.enqueue( mockWebServer.enqueue(
MockResponse() MockResponse()
.setResponseCode(200) .setResponseCode(200)
.setBody("""[{"description":"info"}]"""), .setBody("""[{"description":"info"}]"""),
) )
val response = apiService.getSkills() assertThrows(kotlinx.serialization.SerializationException::class.java) {
assertTrue(response.isSuccessful) runTest {
val skills = response.body() apiService.getSkills()
assertEquals(1, skills?.size) }
val skill = skills?.get(0) }
org.junit.jupiter.api.Assertions
.assertNull(skill?.name)
assertEquals(false, skill?.enabled)
} }
@Test @Test
fun testGetCronJobs_missingFieldsInResponse() = fun testGetCronJobs_missingFieldsInResponse() {
runTest {
mockWebServer.enqueue( mockWebServer.enqueue(
MockResponse() MockResponse()
.setResponseCode(200) .setResponseCode(200)
.setBody("""[{"schedule":"* * * * *"}]"""), .setBody("""[{"schedule":"* * * * *"}]"""),
) )
val response = apiService.getCronJobs() assertThrows(kotlinx.serialization.SerializationException::class.java) {
assertTrue(response.isSuccessful) runTest {
val jobs = response.body() apiService.getCronJobs()
assertEquals(1, jobs?.size) }
val job = jobs?.get(0) }
org.junit.jupiter.api.Assertions
.assertNull(job?.id)
org.junit.jupiter.api.Assertions
.assertNull(job?.name)
} }
@Test @Test
fun testGetSessions_missingSessionsField() = fun testGetSessions_missingSessionsField() {
runTest {
mockWebServer.enqueue( mockWebServer.enqueue(
MockResponse() MockResponse()
.setResponseCode(200) .setResponseCode(200)
.setBody("""{}"""), .setBody("""{}"""),
) )
val response = apiService.getSessions() assertThrows(kotlinx.serialization.SerializationException::class.java) {
assertTrue(response.isSuccessful) runTest {
org.junit.jupiter.api.Assertions apiService.getSessions()
.assertNull(response.body()?.sessions) }
}
} }
@Test @Test
@@ -212,7 +204,7 @@ class HermesApiServiceTest {
.setBody("""{"messages": ["""), .setBody("""{"messages": ["""),
) )
assertThrows(java.io.EOFException::class.java) { assertThrows(kotlinx.serialization.SerializationException::class.java) {
runTest { runTest {
apiService.getSessionMessages("session_1") apiService.getSessionMessages("session_1")
} }
@@ -220,18 +212,18 @@ class HermesApiServiceTest {
} }
@Test @Test
fun testGetSessionMessages_missingMessagesField() = fun testGetSessionMessages_missingMessagesField() {
runTest {
mockWebServer.enqueue( mockWebServer.enqueue(
MockResponse() MockResponse()
.setResponseCode(200) .setResponseCode(200)
.setBody("""{}"""), .setBody("""{}"""),
) )
val response = apiService.getSessionMessages("session_1") assertThrows(kotlinx.serialization.SerializationException::class.java) {
assertTrue(response.isSuccessful) runTest {
org.junit.jupiter.api.Assertions apiService.getSessionMessages("session_1")
.assertNull(response.body()?.messages) }
}
} }
@Test @Test
@@ -243,7 +235,7 @@ class HermesApiServiceTest {
.setBody("""[{"name":"weather""""), .setBody("""[{"name":"weather""""),
) )
org.junit.jupiter.api.Assertions.assertThrows(java.io.EOFException::class.java) { org.junit.jupiter.api.Assertions.assertThrows(kotlinx.serialization.SerializationException::class.java) {
kotlinx.coroutines.runBlocking { kotlinx.coroutines.runBlocking {
apiService.getSkills() apiService.getSkills()
} }
@@ -259,7 +251,7 @@ class HermesApiServiceTest {
.setBody("""[{"name":"weather","enabled":{}}]"""), .setBody("""[{"name":"weather","enabled":{}}]"""),
) )
org.junit.jupiter.api.Assertions.assertThrows(com.google.gson.JsonSyntaxException::class.java) { org.junit.jupiter.api.Assertions.assertThrows(kotlinx.serialization.SerializationException::class.java) {
kotlinx.coroutines.runBlocking { kotlinx.coroutines.runBlocking {
apiService.getSkills() apiService.getSkills()
} }
@@ -3,12 +3,31 @@ package com.m57.hermescontrol.data.ws
import io.mockk.every import io.mockk.every
import io.mockk.mockkStatic import io.mockk.mockkStatic
import io.mockk.unmockkAll import io.mockk.unmockkAll
import kotlinx.serialization.json.JsonObject
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
@Suppress("FunctionName")
private fun createJsonRpcResponse(
jsonrpc: String,
id: String?,
result: Any? = null,
error: JsonRpcError? = null,
method: String? = null,
params: Any? = null,
): JsonRpcResponse =
JsonRpcResponse(
jsonrpc = jsonrpc,
id = id,
result = result?.toJsonElement(),
error = error,
method = method,
params = params?.toJsonElement() as? JsonObject,
)
class EventParserTest { class EventParserTest {
@Before @Before
fun setUp() { fun setUp() {
@@ -24,7 +43,7 @@ class EventParserTest {
@Test @Test
fun testParseRpcResult_returnsRpcResultEvent() { fun testParseRpcResult_returnsRpcResultEvent() {
val response = val response =
JsonRpcResponse( createJsonRpcResponse(
jsonrpc = "2.0", jsonrpc = "2.0",
id = "123", id = "123",
result = mapOf("status" to "success"), result = mapOf("status" to "success"),
@@ -43,7 +62,7 @@ class EventParserTest {
fun testParseRpcError_returnsRpcErrorEvent() { fun testParseRpcError_returnsRpcErrorEvent() {
val error = JsonRpcError(code = -32600, message = "Invalid Request") val error = JsonRpcError(code = -32600, message = "Invalid Request")
val response = val response =
JsonRpcResponse( createJsonRpcResponse(
jsonrpc = "2.0", jsonrpc = "2.0",
id = "456", id = "456",
result = null, result = null,
@@ -62,7 +81,7 @@ class EventParserTest {
@Test @Test
fun testParseGatewayReady_returnsGatewayReadyEvent() { fun testParseGatewayReady_returnsGatewayReadyEvent() {
val response = val response =
JsonRpcResponse( createJsonRpcResponse(
jsonrpc = "2.0", jsonrpc = "2.0",
id = null, id = null,
result = null, result = null,
@@ -83,7 +102,7 @@ class EventParserTest {
@Test @Test
fun testParseMessageToken_returnsMessageTokenEvent() { fun testParseMessageToken_returnsMessageTokenEvent() {
val response = val response =
JsonRpcResponse( createJsonRpcResponse(
jsonrpc = "2.0", jsonrpc = "2.0",
id = null, id = null,
result = null, result = null,
@@ -105,7 +124,7 @@ class EventParserTest {
@Test @Test
fun testParseMessageToken_withSessionIdInParams_returnsMessageTokenEvent() { fun testParseMessageToken_withSessionIdInParams_returnsMessageTokenEvent() {
val response = val response =
JsonRpcResponse( createJsonRpcResponse(
jsonrpc = "2.0", jsonrpc = "2.0",
id = null, id = null,
result = null, result = null,
@@ -128,7 +147,7 @@ class EventParserTest {
@Test @Test
fun testParseThinkingDelta_returnsThinkingDeltaEvent() { fun testParseThinkingDelta_returnsThinkingDeltaEvent() {
val response = val response =
JsonRpcResponse( createJsonRpcResponse(
jsonrpc = "2.0", jsonrpc = "2.0",
id = null, id = null,
result = null, result = null,
@@ -150,7 +169,7 @@ class EventParserTest {
@Test @Test
fun testParseClarifyRequest_returnsClarifyRequestEvent() { fun testParseClarifyRequest_returnsClarifyRequestEvent() {
val response = val response =
JsonRpcResponse( createJsonRpcResponse(
jsonrpc = "2.0", jsonrpc = "2.0",
id = null, id = null,
result = null, result = null,
@@ -176,7 +195,7 @@ class EventParserTest {
@Test @Test
fun testParseClarifyRequest_withQuestionFields_parsesSuccessfully() { fun testParseClarifyRequest_withQuestionFields_parsesSuccessfully() {
val response = val response =
JsonRpcResponse( createJsonRpcResponse(
jsonrpc = "2.0", jsonrpc = "2.0",
id = null, id = null,
result = null, result = null,
@@ -204,7 +223,7 @@ class EventParserTest {
@Test @Test
fun testParseSessionInfo_returnsSessionInfoEvent() { fun testParseSessionInfo_returnsSessionInfoEvent() {
val response = val response =
JsonRpcResponse( createJsonRpcResponse(
jsonrpc = "2.0", jsonrpc = "2.0",
id = null, id = null,
result = null, result = null,
@@ -220,7 +239,7 @@ class EventParserTest {
@Test @Test
fun testParseMessageStart_returnsMessageStartEvent() { fun testParseMessageStart_returnsMessageStartEvent() {
val response = val response =
JsonRpcResponse( createJsonRpcResponse(
jsonrpc = "2.0", jsonrpc = "2.0",
id = null, id = null,
result = null, result = null,
@@ -241,7 +260,7 @@ class EventParserTest {
@Test @Test
fun testParseMessageDelta_returnsMessageTokenEvent() { fun testParseMessageDelta_returnsMessageTokenEvent() {
val response = val response =
JsonRpcResponse( createJsonRpcResponse(
jsonrpc = "2.0", jsonrpc = "2.0",
id = null, id = null,
result = null, result = null,
@@ -258,7 +277,7 @@ class EventParserTest {
@Test @Test
fun testParseMessageComplete_returnsMessageCompleteEvent() { fun testParseMessageComplete_returnsMessageCompleteEvent() {
val response = val response =
JsonRpcResponse( createJsonRpcResponse(
jsonrpc = "2.0", jsonrpc = "2.0",
id = null, id = null,
result = null, result = null,
@@ -274,7 +293,7 @@ class EventParserTest {
@Test @Test
fun testParseMessageDone_returnsMessageDoneEvent() { fun testParseMessageDone_returnsMessageDoneEvent() {
val response = val response =
JsonRpcResponse( createJsonRpcResponse(
jsonrpc = "2.0", jsonrpc = "2.0",
id = null, id = null,
result = null, result = null,
@@ -289,7 +308,7 @@ class EventParserTest {
@Test @Test
fun testParseToolStart_returnsToolStartEvent() { fun testParseToolStart_returnsToolStartEvent() {
val response = val response =
JsonRpcResponse( createJsonRpcResponse(
jsonrpc = "2.0", jsonrpc = "2.0",
id = null, id = null,
result = null, result = null,
@@ -311,7 +330,7 @@ class EventParserTest {
@Test @Test
fun testParseToolComplete_returnsToolCompleteEvent() { fun testParseToolComplete_returnsToolCompleteEvent() {
val response = val response =
JsonRpcResponse( createJsonRpcResponse(
jsonrpc = "2.0", jsonrpc = "2.0",
id = null, id = null,
result = null, result = null,
@@ -333,7 +352,7 @@ class EventParserTest {
@Test @Test
fun testParseStatusUpdate_returnsStatusUpdateEvent() { fun testParseStatusUpdate_returnsStatusUpdateEvent() {
val response = val response =
JsonRpcResponse( createJsonRpcResponse(
jsonrpc = "2.0", jsonrpc = "2.0",
id = null, id = null,
result = null, result = null,
@@ -349,7 +368,7 @@ class EventParserTest {
@Test @Test
fun testParseSessionUpdated_returnsSessionUpdatedEvent() { fun testParseSessionUpdated_returnsSessionUpdatedEvent() {
val response = val response =
JsonRpcResponse( createJsonRpcResponse(
jsonrpc = "2.0", jsonrpc = "2.0",
id = null, id = null,
result = null, result = null,
@@ -365,7 +384,7 @@ class EventParserTest {
@Test @Test
fun testParseUnknownType_returnsUnknownEvent() { fun testParseUnknownType_returnsUnknownEvent() {
val response = val response =
JsonRpcResponse( createJsonRpcResponse(
jsonrpc = "2.0", jsonrpc = "2.0",
id = null, id = null,
result = null, result = null,
@@ -380,7 +399,7 @@ class EventParserTest {
@Test @Test
fun testParseNullParams_returnsUnknownEvent() { fun testParseNullParams_returnsUnknownEvent() {
val response = val response =
JsonRpcResponse( createJsonRpcResponse(
jsonrpc = "2.0", jsonrpc = "2.0",
id = null, id = null,
result = null, result = null,
@@ -395,7 +414,7 @@ class EventParserTest {
@Test @Test
fun testParseNullTypeInParams_returnsUnknownEvent() { fun testParseNullTypeInParams_returnsUnknownEvent() {
val response = val response =
JsonRpcResponse( createJsonRpcResponse(
jsonrpc = "2.0", jsonrpc = "2.0",
id = null, id = null,
result = null, result = null,
+2 -3
View File
@@ -21,7 +21,6 @@ lifecycleViewmodelNav3 = "2.10.0"
leakcanary = "2.14" leakcanary = "2.14"
okhttp = "5.4.0" okhttp = "5.4.0"
retrofit = "3.0.0" retrofit = "3.0.0"
gson = "2.14.0"
securityCrypto = "1.0.0" securityCrypto = "1.0.0"
mockk = "1.14.11" mockk = "1.14.11"
turbine = "1.2.0" turbine = "1.2.0"
@@ -66,8 +65,8 @@ okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" } okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
retrofit-converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" } retrofit-converter-kotlinx-serialization = { module = "com.squareup.retrofit2:converter-kotlinx-serialization", version.ref = "retrofit" }
gson = { module = "com.google.code.gson:gson", version.ref = "gson" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version = "1.8.0" }
# Security # Security
androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "securityCrypto" } androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "securityCrypto" }