From 9217f9f8b6cf4d9d6bc253b7211169bc86deca96 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:05:14 +0300 Subject: [PATCH] fix(sessions): tolerate integer and string boolean formats for pinned field (#966) (#967) Co-authored-by: M57 --- .../data/model/SessionListResponse.kt | 57 +++++++++++++++++++ .../data/model/ModelSerializationTest.kt | 41 +++++++++++++ 2 files changed, 98 insertions(+) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt index c3c479bf..5e2a6985 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt @@ -1,5 +1,60 @@ package com.m57.hermescontrol.data.model + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.intOrNull + +/** + * Lenient boolean serializer that decodes both JSON booleans (`true`/`false`) + * and raw SQLite numeric booleans (`1`/`0`), as well as string representations (`"true"`/`"1"`). + * + * This guards against backend versions (e.g. Hermes Agent <= 0.19.0) where database + * boolean flags like `pinned` are returned as raw integer values (issue #966). + */ +@OptIn(ExperimentalSerializationApi::class) +object LenientNullableBooleanSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("LenientNullableBoolean", PrimitiveKind.BOOLEAN) + + override fun deserialize(decoder: Decoder): Boolean? { + if (decoder is JsonDecoder) { + val element = decoder.decodeJsonElement() + if (element is JsonPrimitive) { + element.booleanOrNull?.let { return it } + element.intOrNull?.let { return it != 0 } + val str = element.content.trim().lowercase() + return when (str) { + "true", "1" -> true + "false", "0" -> false + "null", "" -> null + else -> null + } + } + return null + } + return decoder.decodeBoolean() + } + + override fun serialize( + encoder: Encoder, + value: Boolean?, + ) { + if (value == null) { + encoder.encodeNull() + } else { + encoder.encodeBoolean(value) + } + } +} @Serializable data class SessionListResponse( @@ -25,6 +80,8 @@ data class SessionInfo( val terminal_backend: String? = null, // Durable "keep" flag: pinned sessions are exempt from the auto-archive // sweep and are surfaced first in the history list (sorted client-side). + // Uses LenientNullableBooleanSerializer to tolerate both JSON booleans and raw SQLite ints (issue #966). + @Serializable(with = LenientNullableBooleanSerializer::class) val pinned: Boolean? = null, ) diff --git a/app/src/test/java/com/m57/hermescontrol/data/model/ModelSerializationTest.kt b/app/src/test/java/com/m57/hermescontrol/data/model/ModelSerializationTest.kt index 59ad5f36..94ce2d81 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/model/ModelSerializationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/model/ModelSerializationTest.kt @@ -271,6 +271,47 @@ class ModelSerializationTest { } } + @Test + fun testSessionInfoDeserialization_pinnedToleratesIntAndBooleanAndString() { + // Backend returns integer 0 (SQLite style) + val jsonIntZero = """{"id": "s1", "pinned": 0}""" + val s1 = json.decodeFromString(jsonIntZero) + assertEquals(false, s1.pinned) + + // Backend returns integer 1 + val jsonIntOne = """{"id": "s2", "pinned": 1}""" + val s2 = json.decodeFromString(jsonIntOne) + assertEquals(true, s2.pinned) + + // Backend returns boolean false + val jsonBoolFalse = """{"id": "s3", "pinned": false}""" + val s3 = json.decodeFromString(jsonBoolFalse) + assertEquals(false, s3.pinned) + + // Backend returns boolean true + val jsonBoolTrue = """{"id": "s4", "pinned": true}""" + val s4 = json.decodeFromString(jsonBoolTrue) + assertEquals(true, s4.pinned) + + // Backend returns null or omitted + val jsonNull = """{"id": "s5", "pinned": null}""" + val s5 = json.decodeFromString(jsonNull) + assertNull(s5.pinned) + + val jsonOmitted = """{"id": "s6"}""" + val s6 = json.decodeFromString(jsonOmitted) + assertNull(s6.pinned) + + // Backend returns string "1" / "0" / "true" / "false" + val jsonStrOne = """{"id": "s7", "pinned": "1"}""" + val s7 = json.decodeFromString(jsonStrOne) + assertEquals(true, s7.pinned) + + val jsonStrFalse = """{"id": "s8", "pinned": "false"}""" + val s8 = json.decodeFromString(jsonStrFalse) + assertEquals(false, s8.pinned) + } + @Test fun testSessionMessagesResponseDeserialization_missingMessages_causesNullOnNonNullableField() { val jsonStr = "{}"