From 0bf46109c8f1793687fd587ab7768b0fed2b5468 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:46:58 +0200 Subject: [PATCH] Read numeric WS dates as epoch nanoseconds The v2 gateway Feeds connects to (/api/v2/connect) encodes every time field as integer epoch nanoseconds, but LenientDateAdapter read numbers as epoch millis. StreamConnectedUser.createdAt, updatedAt and lastActive came out tens of millions of years in the future. No gateway sends numeric dates in any other unit (chat /connect and video /video/connect send RFC3339 strings), so a number is now always nanoseconds. Writes stay in millis so the health check payload sent back to the server is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../moshi/StreamCoreMoshiProvider.kt | 17 ++++--- .../serialization/moshi/MoshiProviderTest.kt | 17 +++---- .../StreamCoreMoshiProviderDateParsingTest.kt | 49 +++++++++---------- 3 files changed, 42 insertions(+), 41 deletions(-) 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