diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/serialization/moshi/StreamCoreMoshiProvider.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/serialization/moshi/StreamCoreMoshiProvider.kt index ab5e52c3..110ffc28 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/serialization/moshi/StreamCoreMoshiProvider.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/serialization/moshi/StreamCoreMoshiProvider.kt @@ -34,10 +34,13 @@ internal class StreamCoreMoshiProvider { /** * Adapter for [Date] fields on internal WS events. * - * Writes epoch millis, so the wire format of outbound messages is unchanged. Reads leniently: - * gateways send dates either as epoch millis (number) or as RFC3339/ISO-8601 strings — e.g. the - * video coordinator's `connection.ok` carries `"created_at": "2026-07-06T07:47:26.592958Z"` at - * `$.me.created_at` — so both encodings are accepted. + * Reads both encodings the gateways use. The v2 gateway (`/api/v2/connect`, used by Feeds) + * sends dates as epoch nanoseconds (e.g. `"created_at": 1755586996702859000`); the video + * coordinator sends RFC3339 strings (e.g. `"created_at": "2026-07-06T07:47:26.592958Z"`). No + * gateway sends numeric dates in any other unit. + * + * Writes epoch millis, so the wire format of outbound messages (the health check echoes the + * connected event) is unchanged. */ object LenientDateAdapter : JsonAdapter() { private val rfc3339 = Rfc3339DateJsonAdapter() @@ -45,11 +48,11 @@ internal class StreamCoreMoshiProvider { override fun fromJson(reader: JsonReader): Date? = when (reader.peek()) { JsonReader.Token.NULL -> reader.nextNull() - JsonReader.Token.NUMBER -> Date(reader.nextLong()) + JsonReader.Token.NUMBER -> Date(reader.nextLong() / NANOS_PER_MILLI) JsonReader.Token.STRING -> rfc3339.fromJson(reader) else -> throw JsonDataException( - "Expected a date as epoch millis or an RFC3339 string " + + "Expected a date as epoch nanoseconds or an RFC3339 string " + "but was ${reader.peek()} at path ${reader.path}" ) } @@ -57,6 +60,8 @@ internal class StreamCoreMoshiProvider { override fun toJson(writer: JsonWriter, value: Date?) { writer.value(value?.time) } + + private const val NANOS_PER_MILLI = 1_000_000L } fun builder(configure: (Moshi.Builder) -> Unit): Moshi.Builder { diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/serialization/moshi/MoshiProviderTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/serialization/moshi/MoshiProviderTest.kt index 7da2313d..1521f9b0 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/internal/serialization/moshi/MoshiProviderTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/serialization/moshi/MoshiProviderTest.kt @@ -39,17 +39,14 @@ class MoshiProviderTest { val json = adapter.toJson(date) assertEquals("1704000123456", json) - // round-trip - val parsed = adapter.fromJson(json) - assertEquals(date.time, parsed!!.time) } @Test - fun `DateMillisAdapter fromJson reads epoch millis`() { + fun `DateMillisAdapter fromJson reads epoch nanos`() { val m = moshi() val adapter = m.adapter(Date::class.java) - val parsed = adapter.fromJson("123456789") + val parsed = adapter.fromJson("123456789000000") assertNotNull(parsed) assertEquals(123456789L, parsed!!.time) } @@ -141,14 +138,14 @@ class MoshiProviderTest { } @Test - fun `fromJson returns Date for non-null millis`() { - val millis = 1734567890000L - val date = StreamCoreMoshiProvider.LenientDateAdapter.fromJson("$millis") - assertEquals(Date(millis), date) + fun `fromJson returns Date truncated to millis for non-null nanos`() { + val nanos = 1734567890123456789L + val date = StreamCoreMoshiProvider.LenientDateAdapter.fromJson("$nanos") + assertEquals(Date(1734567890123L), date) } @Test - fun `fromJson returns null for null millis`() { + fun `fromJson returns null for null`() { val date = StreamCoreMoshiProvider.LenientDateAdapter.fromJson("null") assertNull(date) } diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/serialization/moshi/StreamCoreMoshiProviderDateParsingTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/serialization/moshi/StreamCoreMoshiProviderDateParsingTest.kt index 29c8662a..097e9501 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/internal/serialization/moshi/StreamCoreMoshiProviderDateParsingTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/serialization/moshi/StreamCoreMoshiProviderDateParsingTest.kt @@ -25,7 +25,6 @@ import java.util.Date import java.util.Locale import java.util.TimeZone import kotlin.test.assertEquals -import kotlin.test.assertNotNull import kotlin.test.assertTrue import org.junit.Test @@ -66,15 +65,17 @@ internal class StreamCoreMoshiProviderDateParsingTest { } @Test - fun `connection ok with epoch millis dates still parses`() { - // Given: the pre-existing wire format — dates as epoch millis. + fun `connection ok with epoch nanos dates parses`() { + // Given: a connection.ok frame as sent by the v2 gateway used by Feeds (captured from a + // real handshake) — all date fields are epoch nanoseconds. val adapter = moshi.adapter(StreamClientWsEvent::class.java) - val millis = 1_783_064_846_592L val raw = """ - {"type":"connection.ok","connection_id":"conn-1", - "me":{"id":"u1","language":"en","role":"user","teams":[], - "created_at":$millis,"updated_at":$millis}} + {"type":"connection.ok","created_at":1790947725196662581, + "connection_id":"6abb765b-0a82-0bb6-0300-000000179c31", + "me":{"id":"u1","name":"U1","image":"","custom":{},"language":"","role":"user", + "teams":[],"created_at":1755586996702859000,"updated_at":1765800663371302000, + "banned":false,"online":true,"last_active":1790947725188417871}} """ .trimIndent() @@ -83,8 +84,21 @@ internal class StreamCoreMoshiProviderDateParsingTest { // Then assertTrue(event is StreamClientConnectedEvent) - assertEquals(Date(millis), event.me.createdAt) - assertEquals(Date(millis), event.me.updatedAt) + assertEquals(utcDate("2025-08-19T07:03:16.702"), event.me.createdAt) + assertEquals(utcDate("2025-12-15T12:11:03.371"), event.me.updatedAt) + assertEquals(utcDate("2026-10-02T13:28:45.188"), event.me.lastActive) + } + + @Test + fun `zero epoch nanos parses as the epoch`() { + // Given: the v2 gateway writes zero and pre-1970 times as 0. + val adapter = moshi.adapter(Date::class.java) + + // When + val date = adapter.fromJson("0") + + // Then + assertEquals(Date(0), date) } @Test @@ -104,26 +118,11 @@ internal class StreamCoreMoshiProviderDateParsingTest { // When val json = adapter.toJson(user) - // Then: outbound wire format is unchanged. + // Then: outbound wire format is unchanged (millis, even though numeric reads are nanos). assertTrue(json.contains("\"created_at\":1000")) assertTrue(json.contains("\"updated_at\":2000")) } - @Test - fun `RFC3339 date round-trips through millis`() { - // Given - val adapter = moshi.adapter(Date::class.java) - val parsed = adapter.fromJson("\"2026-07-06T07:47:26.592Z\"") - - // When - val json = adapter.toJson(parsed) - val reparsed = adapter.fromJson(json) - - // Then - assertNotNull(parsed) - assertEquals(parsed, reparsed) - } - @Test(expected = JsonDataException::class) fun `unsupported date token throws`() { // Given