diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/OAuthProvider.kt b/app/src/main/java/com/m57/hermescontrol/data/model/OAuthProvider.kt index f5525b2e..3315dd19 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/OAuthProvider.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/OAuthProvider.kt @@ -1,7 +1,17 @@ package com.m57.hermescontrol.data.model +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName 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.JsonNull +import kotlinx.serialization.json.JsonPrimitive /** * OAuth provider catalog entry returned by `GET /api/providers/oauth`. @@ -10,7 +20,8 @@ import kotlinx.serialization.Serializable * id, name, flow ("pkce" | "device_code" | "external"), cli_command, * docs_url, disconnect_hint, disconnect_command, disconnectable, status. * - * `expires_at` in the status comes back as an ISO timestamp string. + * `expires_at` / `last_refresh` come back as an ISO string or a numeric epoch depending on the + * credential source, so they decode through [LenientStringSerializer]. */ @Serializable data class OAuthProvider( @@ -31,9 +42,13 @@ data class OAuthProviderStatus( val source: String? = null, @SerialName("source_label") val sourceLabel: String? = null, @SerialName("token_preview") val tokenPreview: String? = null, - @SerialName("expires_at") val expiresAt: String? = null, + @SerialName("expires_at") + @Serializable(with = LenientStringSerializer::class) + val expiresAt: String? = null, @SerialName("has_refresh_token") val hasRefreshToken: Boolean? = null, - @SerialName("last_refresh") val lastRefresh: String? = null, + @SerialName("last_refresh") + @Serializable(with = LenientStringSerializer::class) + val lastRefresh: String? = null, val error: String? = null, ) @@ -98,3 +113,25 @@ data class OAuthCancelResponse( @SerialName("session_id") val sessionId: String? = null, val message: String? = null, ) + +/** Decodes a JSON string or number (e.g. epoch millis) into its string form; null stays null. */ +@OptIn(ExperimentalSerializationApi::class) +object LenientStringSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("LenientString", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): String? { + if (decoder is JsonDecoder) { + val element = decoder.decodeJsonElement() + return if (element is JsonPrimitive && element !is JsonNull) element.content else null + } + return decoder.decodeString() + } + + override fun serialize( + encoder: Encoder, + value: String?, + ) { + if (value == null) encoder.encodeNull() else encoder.encodeString(value) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceMockWebServerTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceMockWebServerTest.kt index a221a389..8e7aaacf 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceMockWebServerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceMockWebServerTest.kt @@ -598,6 +598,27 @@ class HermesApiServiceMockWebServerTest { assertEquals(404, response.code()) } + @Test + fun getOAuthProviders_toleratesNumericExpiresAt() = + runBlocking { + mockServer.enqueue( + MockResponse() + .setResponseCode(200) + .setBody( + """ + {"providers":[{"id":"codex","name":"Codex","flow":"external", + "status":{"logged_in":true,"expires_at":1790766533634,"last_refresh":1790766000000}}]} + """.trimIndent(), + ), + ) + + val response = api.getOAuthProviders() + assertTrue(response.isSuccessful) + val status = response.body()!!.providers[0].status + assertEquals("1790766533634", status.expiresAt) + assertEquals("1790766000000", status.lastRefresh) + } + @Test fun getOAuthProviders_parsesList() = runBlocking {