Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -34,29 +34,34 @@ 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<Date>() {
private val rfc3339 = Rfc3339DateJsonAdapter()

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}"
)
}

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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()

Expand All @@ -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
Expand All @@ -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
Expand Down
Loading