refactor: migrate Gson serialization to kotlinx.serialization (#475)

* refactor: migrate Gson serialization to kotlinx.serialization

* fix(dto): resolve serializing Any? and Map<String, Any?> with JsonElement

* test(refactor): fix unit test issues under kotlinx.serialization and update nullable DTO defaults

* fix(chat): support flexible numeric and string decoding for message timestamps

* fix(chat): sequentialize cache loading to avoid overwriting network state
This commit is contained in:
M57 authored and GitHub committed 2026-07-05 21:30:44 +03:00
1 parent 1be5ab2f6d
commit 5697405b19
64 files changed
+863 -577

No files matched your search

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