diff --git a/core/jvm/src/Instant.kt b/core/jvm/src/Instant.kt index ab3474647..ef64a8bae 100644 --- a/core/jvm/src/Instant.kt +++ b/core/jvm/src/Instant.kt @@ -20,7 +20,9 @@ import java.time.Instant as jtInstant import java.time.Clock as jtClock @Serializable(with = InstantIso8601Serializer::class) -public actual class Instant internal constructor(internal val value: jtInstant) : Comparable { +public actual class Instant internal constructor( + internal val value: jtInstant +) : Comparable, java.io.Serializable { public actual val epochSeconds: Long get() = value.epochSecond @@ -98,6 +100,8 @@ public actual class Instant internal constructor(internal val value: jtInstant) internal actual val MIN: Instant = Instant(jtInstant.MIN) internal actual val MAX: Instant = Instant(jtInstant.MAX) } + + private fun writeReplace(): Any = Ser(Ser.INSTANT_TAG, this) } private fun Instant.atZone(zone: TimeZone): java.time.ZonedDateTime = try { diff --git a/core/jvm/src/LocalDate.kt b/core/jvm/src/LocalDate.kt index fe3b9ae1a..64e14831a 100644 --- a/core/jvm/src/LocalDate.kt +++ b/core/jvm/src/LocalDate.kt @@ -17,7 +17,9 @@ import java.time.temporal.ChronoUnit import java.time.LocalDate as jtLocalDate @Serializable(with = LocalDateIso8601Serializer::class) -public actual class LocalDate internal constructor(internal val value: jtLocalDate) : Comparable { +public actual class LocalDate internal constructor( + internal val value: jtLocalDate +) : Comparable, java.io.Serializable { public actual companion object { public actual fun parse(input: CharSequence, format: DateTimeFormat): LocalDate = if (format === Formats.ISO) { @@ -76,6 +78,8 @@ public actual class LocalDate internal constructor(internal val value: jtLocalDa actual override fun compareTo(other: LocalDate): Int = this.value.compareTo(other.value) public actual fun toEpochDays(): Int = value.toEpochDay().clampToInt() + + private fun writeReplace(): Any = Ser(Ser.DATE_TAG, this) } @Deprecated("Use the plus overload with an explicit number of units", ReplaceWith("this.plus(1, unit)")) diff --git a/core/jvm/src/LocalDateTime.kt b/core/jvm/src/LocalDateTime.kt index 7dc28cdb1..9fc90492b 100644 --- a/core/jvm/src/LocalDateTime.kt +++ b/core/jvm/src/LocalDateTime.kt @@ -16,7 +16,9 @@ public actual typealias Month = java.time.Month public actual typealias DayOfWeek = java.time.DayOfWeek @Serializable(with = LocalDateTimeIso8601Serializer::class) -public actual class LocalDateTime internal constructor(internal val value: jtLocalDateTime) : Comparable { +public actual class LocalDateTime internal constructor( + internal val value: jtLocalDateTime +) : Comparable, java.io.Serializable { public actual constructor(year: Int, monthNumber: Int, dayOfMonth: Int, hour: Int, minute: Int, second: Int, nanosecond: Int) : this(try { @@ -83,5 +85,5 @@ public actual class LocalDateTime internal constructor(internal val value: jtLoc public actual val ISO: DateTimeFormat = ISO_DATETIME } + private fun writeReplace(): Any = Ser(Ser.DATE_TIME_TAG, this) } - diff --git a/core/jvm/src/LocalTime.kt b/core/jvm/src/LocalTime.kt index 71052570f..9fed45861 100644 --- a/core/jvm/src/LocalTime.kt +++ b/core/jvm/src/LocalTime.kt @@ -15,8 +15,9 @@ import java.time.format.DateTimeParseException import java.time.LocalTime as jtLocalTime @Serializable(with = LocalTimeIso8601Serializer::class) -public actual class LocalTime internal constructor(internal val value: jtLocalTime) : - Comparable { +public actual class LocalTime internal constructor( + internal val value: jtLocalTime +) : Comparable, java.io.Serializable { public actual constructor(hour: Int, minute: Int, second: Int, nanosecond: Int) : this( @@ -89,4 +90,6 @@ public actual class LocalTime internal constructor(internal val value: jtLocalTi public actual val ISO: DateTimeFormat get() = ISO_TIME } + + private fun writeReplace(): Any = Ser(Ser.TIME_TAG, this) } diff --git a/core/jvm/src/UtcOffsetJvm.kt b/core/jvm/src/UtcOffsetJvm.kt index 129857d74..7f9ed7031 100644 --- a/core/jvm/src/UtcOffsetJvm.kt +++ b/core/jvm/src/UtcOffsetJvm.kt @@ -14,7 +14,9 @@ import java.time.format.DateTimeFormatterBuilder import java.time.format.* @Serializable(with = UtcOffsetSerializer::class) -public actual class UtcOffset(internal val zoneOffset: ZoneOffset) { +public actual class UtcOffset( + internal val zoneOffset: ZoneOffset +): java.io.Serializable { public actual val totalSeconds: Int get() = zoneOffset.totalSeconds override fun hashCode(): Int = zoneOffset.hashCode() @@ -44,6 +46,8 @@ public actual class UtcOffset(internal val zoneOffset: ZoneOffset) { public actual val ISO_BASIC: DateTimeFormat get() = ISO_OFFSET_BASIC public actual val FOUR_DIGITS: DateTimeFormat get() = FOUR_DIGIT_OFFSET } + + private fun writeReplace(): Any = Ser(Ser.UTC_OFFSET_TAG, this) } @Suppress("ACTUAL_FUNCTION_WITH_DEFAULT_ARGUMENTS") diff --git a/core/jvm/src/internal/Ser.kt b/core/jvm/src/internal/Ser.kt new file mode 100644 index 000000000..0515a5e61 --- /dev/null +++ b/core/jvm/src/internal/Ser.kt @@ -0,0 +1,75 @@ +/* + * Copyright 2019-2024 JetBrains s.r.o. and contributors. + * Use of this source code is governed by the Apache 2.0 License that can be found in the LICENSE.txt file. + */ + +@file:Suppress("PackageDirectoryMismatch") +package kotlinx.datetime + +import java.io.* + +@PublishedApi // changing the class name would result in serialization incompatibility +internal class Ser(private var typeTag: Int, private var value: Any?) : Externalizable { + constructor() : this(0, null) + + override fun writeExternal(out: ObjectOutput) { + out.writeByte(typeTag) + val value = this.value + when (typeTag) { + INSTANT_TAG -> { + value as Instant + out.writeLong(value.epochSeconds) + out.writeInt(value.nanosecondsOfSecond) + } + DATE_TAG -> { + value as LocalDate + out.writeLong(value.value.toEpochDay()) + } + TIME_TAG -> { + value as LocalTime + out.writeLong(value.toNanosecondOfDay()) + } + DATE_TIME_TAG -> { + value as LocalDateTime + out.writeLong(value.date.value.toEpochDay()) + out.writeLong(value.time.toNanosecondOfDay()) + } + UTC_OFFSET_TAG -> { + value as UtcOffset + out.writeInt(value.totalSeconds) + } + else -> throw IllegalStateException("Unknown type tag: $typeTag for value: $value") + } + } + + override fun readExternal(`in`: ObjectInput) { + typeTag = `in`.readByte().toInt() + value = when (typeTag) { + INSTANT_TAG -> + Instant.fromEpochSeconds(`in`.readLong(), `in`.readInt()) + DATE_TAG -> + LocalDate(java.time.LocalDate.ofEpochDay(`in`.readLong())) + TIME_TAG -> + LocalTime.fromNanosecondOfDay(`in`.readLong()) + DATE_TIME_TAG -> + LocalDateTime( + LocalDate(java.time.LocalDate.ofEpochDay(`in`.readLong())), + LocalTime.fromNanosecondOfDay(`in`.readLong()) + ) + UTC_OFFSET_TAG -> + UtcOffset(seconds = `in`.readInt()) + else -> throw IOException("Unknown type tag: $typeTag") + } + } + + private fun readResolve(): Any = value!! + + companion object { + private const val serialVersionUID: Long = 0L + const val INSTANT_TAG = 1 + const val DATE_TAG = 2 + const val TIME_TAG = 3 + const val DATE_TIME_TAG = 4 + const val UTC_OFFSET_TAG = 10 + } +} \ No newline at end of file diff --git a/core/jvm/test/JvmSerializationTest.kt b/core/jvm/test/JvmSerializationTest.kt new file mode 100644 index 000000000..4c7bdad86 --- /dev/null +++ b/core/jvm/test/JvmSerializationTest.kt @@ -0,0 +1,95 @@ +/* + * Copyright 2019-2024 JetBrains s.r.o. and contributors. + * Use of this source code is governed by the Apache 2.0 License that can be found in the LICENSE.txt file. + */ + +package kotlinx.datetime + +import java.io.* +import kotlin.test.* + +class JvmSerializationTest { + + @Test + fun serializeInstant() { + roundTripSerialization(Instant.fromEpochSeconds(1234567890, 123456789)) + roundTripSerialization(Instant.MIN) + roundTripSerialization(Instant.MAX) + expectedDeserialization(Instant.parse("+150000-04-30T12:30:25.555998Z"), "0d010000043fa44d82612123db30") + } + + @Test + fun serializeLocalTime() { + roundTripSerialization(LocalTime(12, 34, 56, 789)) + roundTripSerialization(LocalTime.MIN) + roundTripSerialization(LocalTime.MAX) + expectedDeserialization(LocalTime(23, 59, 15, 995_003_220), "090300004e8a52680954") + } + + @Test + fun serializeLocalDate() { + roundTripSerialization(LocalDate(2022, 1, 23)) + roundTripSerialization(LocalDate.MIN) + roundTripSerialization(LocalDate.MAX) + expectedDeserialization(LocalDate(2024, 8, 12), "09020000000000004deb") + } + + @Test + fun serializeLocalDateTime() { + roundTripSerialization(LocalDateTime(2022, 1, 23, 21, 35, 53, 125_123_612)) + roundTripSerialization(LocalDateTime.MIN) + roundTripSerialization(LocalDateTime.MAX) + expectedDeserialization(LocalDateTime(2024, 8, 12, 10, 15, 0, 997_665_331), "11040000000000004deb0000218faedb9233") + } + + @Test + fun serializeUtcOffset() { + roundTripSerialization(UtcOffset(hours = 3, minutes = 30, seconds = 15)) + roundTripSerialization(UtcOffset(java.time.ZoneOffset.MIN)) + roundTripSerialization(UtcOffset(java.time.ZoneOffset.MAX)) + expectedDeserialization(UtcOffset.parse("-04:15:30"), "050affffc41e") + } + + @Test + fun serializeTimeZone() { + assertFailsWith { + roundTripSerialization(TimeZone.of("Europe/Moscow")) + } + } + + private fun serialize(value: Any?): ByteArray { + val bos = ByteArrayOutputStream() + val oos = ObjectOutputStream(bos) + oos.writeObject(value) + return bos.toByteArray() + } + + private fun deserialize(serialized: ByteArray): Any? { + val bis = ByteArrayInputStream(serialized) + ObjectInputStream(bis).use { ois -> + return ois.readObject() + } + } + + private fun roundTripSerialization(value: T) { + val serialized = serialize(value) + val deserialized = deserialize(serialized) + assertEquals(value, deserialized) + } + + @OptIn(ExperimentalStdlibApi::class) + private fun expectedDeserialization(expected: Any, blockData: String) { + val serialized = "aced0005737200146b6f746c696e782e6461746574696d652e53657200000000000000000c0000787077${blockData}78" + val hexFormat = HexFormat { bytes.byteSeparator = "" } + + try { + val deserialized = deserialize(serialized.hexToByteArray(hexFormat)) + if (expected != deserialized) { + assertEquals(expected, deserialized, "Golden serial form: $serialized\nActual serial form: ${serialize(expected).toHexString(hexFormat)}") + } + } catch (e: Throwable) { + fail("Failed to deserialize $serialized\nActual serial form: ${serialize(expected).toHexString(hexFormat)}", e) + } + } + +}