From 9ec1061f6095558aca526aab316992a89f713168 Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Fri, 22 Mar 2024 14:59:24 +0100 Subject: [PATCH 1/8] Implement java.io.Serializable for some of the classes Implement java.io.Serializable for * Instant * LocalDate * LocalTime * LocalDateTime * UtcOffset TimeZone is not `Serializable` because its behavior is system-dependent. We can make it `java.io.Serializable` later if there is demand. We are using string representations instead of relying on Java's entities being `java.io.Serializable` so that we have more freedom to change our implementation later. Fixes #143 --- core/jvm/src/Instant.kt | 23 +++++++++++- core/jvm/src/LocalDate.kt | 23 +++++++++++- core/jvm/src/LocalDateTimeJvm.kt | 23 +++++++++++- core/jvm/src/LocalTimeJvm.kt | 25 ++++++++++++-- core/jvm/src/UtcOffsetJvm.kt | 20 ++++++++++- core/jvm/test/JvmSerializationTest.kt | 50 +++++++++++++++++++++++++++ 6 files changed, 158 insertions(+), 6 deletions(-) create mode 100644 core/jvm/test/JvmSerializationTest.kt diff --git a/core/jvm/src/Instant.kt b/core/jvm/src/Instant.kt index 53c67044..0e8090ea 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 @@ -97,6 +99,25 @@ 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) + + @JvmStatic + private val serialVersionUID: Long = 1L + } + + private fun writeObject(oStream: java.io.ObjectOutputStream) { + oStream.defaultWriteObject() + oStream.writeObject(value.toString()) + } + + private fun readObject(iStream: java.io.ObjectInputStream) { + iStream.defaultReadObject() + val field = this::class.java.getDeclaredField(::value.name) + field.isAccessible = true + field.set(this, jtOffsetDateTime.parse(fixOffsetRepresentation(iStream.readObject() as String)).toInstant()) + } + + private fun readObjectNoData() { + throw java.io.InvalidObjectException("Stream data required") } } diff --git a/core/jvm/src/LocalDate.kt b/core/jvm/src/LocalDate.kt index 9dc8e801..d8ad79cc 100644 --- a/core/jvm/src/LocalDate.kt +++ b/core/jvm/src/LocalDate.kt @@ -18,7 +18,9 @@ import java.time.LocalDate as jtLocalDate import kotlin.internal.* @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) { @@ -50,6 +52,9 @@ public actual class LocalDate internal constructor(internal val value: jtLocalDa @Suppress("FunctionName") public actual fun Format(block: DateTimeFormatBuilder.WithDate.() -> Unit): DateTimeFormat = LocalDateFormat.build(block) + + @JvmStatic + private val serialVersionUID: Long = 1L } public actual object Formats { @@ -100,6 +105,22 @@ public actual class LocalDate internal constructor(internal val value: jtLocalDa @PublishedApi @JvmName("toEpochDays") internal fun toEpochDaysJvm(): Int = value.toEpochDay().clampToInt() + + private fun writeObject(oStream: java.io.ObjectOutputStream) { + oStream.defaultWriteObject() + oStream.writeObject(value.toString()) + } + + private fun readObject(iStream: java.io.ObjectInputStream) { + iStream.defaultReadObject() + val field = this::class.java.getDeclaredField(::value.name) + field.isAccessible = true + field.set(this, jtLocalDate.parse(iStream.readObject() as String)) + } + + private fun readObjectNoData() { + throw java.io.InvalidObjectException("Stream data required") + } } /** diff --git a/core/jvm/src/LocalDateTimeJvm.kt b/core/jvm/src/LocalDateTimeJvm.kt index 77f4d87b..48a686b6 100644 --- a/core/jvm/src/LocalDateTimeJvm.kt +++ b/core/jvm/src/LocalDateTimeJvm.kt @@ -15,7 +15,10 @@ import java.time.format.DateTimeParseException import java.time.LocalDateTime as jtLocalDateTime @Serializable(with = LocalDateTimeIso8601Serializer::class) -public actual class LocalDateTime internal constructor(internal val value: jtLocalDateTime) : Comparable { +public actual class LocalDateTime internal constructor( + // only a `var` to allow Java deserialization + internal var value: jtLocalDateTime +) : Comparable, java.io.Serializable { public actual constructor(year: Int, month: Int, day: Int, hour: Int, minute: Int, second: Int, nanosecond: Int) : this(try { @@ -104,12 +107,30 @@ public actual class LocalDateTime internal constructor(internal val value: jtLoc @Suppress("FunctionName") public actual fun Format(builder: DateTimeFormatBuilder.WithDateTime.() -> Unit): DateTimeFormat = LocalDateTimeFormat.build(builder) + + @JvmStatic + private val serialVersionUID: Long = 1L } public actual object Formats { public actual val ISO: DateTimeFormat = ISO_DATETIME } + private fun writeObject(oStream: java.io.ObjectOutputStream) { + oStream.defaultWriteObject() + oStream.writeObject(value.toString()) + } + + private fun readObject(iStream: java.io.ObjectInputStream) { + iStream.defaultReadObject() + val field = this::class.java.getDeclaredField(::value.name) + field.isAccessible = true + field.set(this, jtLocalDateTime.parse(iStream.readObject() as String)) + } + + private fun readObjectNoData() { + throw java.io.InvalidObjectException("Stream data required") + } } /** diff --git a/core/jvm/src/LocalTimeJvm.kt b/core/jvm/src/LocalTimeJvm.kt index 20a03301..39194638 100644 --- a/core/jvm/src/LocalTimeJvm.kt +++ b/core/jvm/src/LocalTimeJvm.kt @@ -16,8 +16,10 @@ 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( + // only a `var` to allow Java deserialization + internal var value: jtLocalTime +) : Comparable, java.io.Serializable { public actual constructor(hour: Int, minute: Int, second: Int, nanosecond: Int) : this( @@ -84,12 +86,31 @@ public actual class LocalTime internal constructor(internal val value: jtLocalTi @Suppress("FunctionName") public actual fun Format(builder: DateTimeFormatBuilder.WithTime.() -> Unit): DateTimeFormat = LocalTimeFormat.build(builder) + + @JvmStatic + private val serialVersionUID: Long = 1L } public actual object Formats { public actual val ISO: DateTimeFormat get() = ISO_TIME } + + private fun writeObject(oStream: java.io.ObjectOutputStream) { + oStream.defaultWriteObject() + oStream.writeObject(value.toString()) + } + + private fun readObject(iStream: java.io.ObjectInputStream) { + iStream.defaultReadObject() + val field = this::class.java.getDeclaredField(::value.name) + field.isAccessible = true + field.set(this, jtLocalTime.parse(iStream.readObject() as String)) + } + + private fun readObjectNoData() { + throw java.io.InvalidObjectException("Stream data required") + } } @Deprecated( diff --git a/core/jvm/src/UtcOffsetJvm.kt b/core/jvm/src/UtcOffsetJvm.kt index 129857d7..ed010afd 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,22 @@ 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 writeObject(oStream: java.io.ObjectOutputStream) { + oStream.defaultWriteObject() + oStream.writeObject(zoneOffset.toString()) + } + + private fun readObject(iStream: java.io.ObjectInputStream) { + iStream.defaultReadObject() + val field = this::class.java.getDeclaredField(::zoneOffset.name) + field.isAccessible = true + field.set(this, ZoneOffset.of(iStream.readObject() as String)) + } + + private fun readObjectNoData() { + throw java.io.InvalidObjectException("Stream data required") + } } @Suppress("ACTUAL_FUNCTION_WITH_DEFAULT_ARGUMENTS") diff --git a/core/jvm/test/JvmSerializationTest.kt b/core/jvm/test/JvmSerializationTest.kt new file mode 100644 index 00000000..6f577d5e --- /dev/null +++ b/core/jvm/test/JvmSerializationTest.kt @@ -0,0 +1,50 @@ +/* + * 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)) + } + + @Test + fun serializeLocalTime() { + roundTripSerialization(LocalTime(12, 34, 56, 789)) + } + + @Test + fun serializeLocalDateTime() { + roundTripSerialization(LocalDateTime(2022, 1, 23, 21, 35, 53, 125_123_612)) + } + + @Test + fun serializeUtcOffset() { + roundTripSerialization(UtcOffset(hours = 3, minutes = 30, seconds = 15)) + } + + @Test + fun serializeTimeZone() { + assertFailsWith { + roundTripSerialization(TimeZone.of("Europe/Moscow")) + } + } + + private fun roundTripSerialization(value: T) { + val bos = ByteArrayOutputStream() + val oos = ObjectOutputStream(bos) + oos.writeObject(value) + val serialized = bos.toByteArray() + val bis = ByteArrayInputStream(serialized) + ObjectInputStream(bis).use { ois -> + assertEquals(value, ois.readObject()) + } + } +} From 3b940590b5d82125f9d84cb785b3ba11269302e8 Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Mon, 13 May 2024 14:14:40 +0200 Subject: [PATCH 2/8] Fix compilation after a merge --- core/jvm/src/Instant.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/jvm/src/Instant.kt b/core/jvm/src/Instant.kt index 0e8090ea..249d8f64 100644 --- a/core/jvm/src/Instant.kt +++ b/core/jvm/src/Instant.kt @@ -113,7 +113,7 @@ public actual class Instant internal constructor( iStream.defaultReadObject() val field = this::class.java.getDeclaredField(::value.name) field.isAccessible = true - field.set(this, jtOffsetDateTime.parse(fixOffsetRepresentation(iStream.readObject() as String)).toInstant()) + field.set(this, parse(iStream.readObject() as String).value) } private fun readObjectNoData() { From a29c32adb60cbb956546a7d483e9f9040010ebe6 Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Mon, 13 May 2024 14:14:59 +0200 Subject: [PATCH 3/8] val serialVersionUID -> const val --- core/jvm/src/Instant.kt | 3 +-- core/jvm/src/LocalDate.kt | 3 +-- core/jvm/src/LocalDateTimeJvm.kt | 3 +-- core/jvm/src/LocalTimeJvm.kt | 3 +-- 4 files changed, 4 insertions(+), 8 deletions(-) diff --git a/core/jvm/src/Instant.kt b/core/jvm/src/Instant.kt index 249d8f64..9001323a 100644 --- a/core/jvm/src/Instant.kt +++ b/core/jvm/src/Instant.kt @@ -100,8 +100,7 @@ public actual class Instant internal constructor( internal actual val MIN: Instant = Instant(jtInstant.MIN) internal actual val MAX: Instant = Instant(jtInstant.MAX) - @JvmStatic - private val serialVersionUID: Long = 1L + private const val serialVersionUID: Long = 1L } private fun writeObject(oStream: java.io.ObjectOutputStream) { diff --git a/core/jvm/src/LocalDate.kt b/core/jvm/src/LocalDate.kt index d8ad79cc..23bc226d 100644 --- a/core/jvm/src/LocalDate.kt +++ b/core/jvm/src/LocalDate.kt @@ -53,8 +53,7 @@ public actual class LocalDate internal constructor( public actual fun Format(block: DateTimeFormatBuilder.WithDate.() -> Unit): DateTimeFormat = LocalDateFormat.build(block) - @JvmStatic - private val serialVersionUID: Long = 1L + private const val serialVersionUID: Long = 1L } public actual object Formats { diff --git a/core/jvm/src/LocalDateTimeJvm.kt b/core/jvm/src/LocalDateTimeJvm.kt index 48a686b6..77144b51 100644 --- a/core/jvm/src/LocalDateTimeJvm.kt +++ b/core/jvm/src/LocalDateTimeJvm.kt @@ -108,8 +108,7 @@ public actual class LocalDateTime internal constructor( public actual fun Format(builder: DateTimeFormatBuilder.WithDateTime.() -> Unit): DateTimeFormat = LocalDateTimeFormat.build(builder) - @JvmStatic - private val serialVersionUID: Long = 1L + private const val serialVersionUID: Long = 1L } public actual object Formats { diff --git a/core/jvm/src/LocalTimeJvm.kt b/core/jvm/src/LocalTimeJvm.kt index 39194638..4a9f60e3 100644 --- a/core/jvm/src/LocalTimeJvm.kt +++ b/core/jvm/src/LocalTimeJvm.kt @@ -87,8 +87,7 @@ public actual class LocalTime internal constructor( public actual fun Format(builder: DateTimeFormatBuilder.WithTime.() -> Unit): DateTimeFormat = LocalTimeFormat.build(builder) - @JvmStatic - private val serialVersionUID: Long = 1L + private const val serialVersionUID: Long = 1L } public actual object Formats { From 17bac4bec185200c384227fbf98138e26562a6a9 Mon Sep 17 00:00:00 2001 From: Ilya Gorbunov Date: Thu, 15 Aug 2024 17:00:11 +0200 Subject: [PATCH 4/8] Use externalizable replacement for serializable entities --- core/jvm/src/Instant.kt | 18 +----- core/jvm/src/LocalDate.kt | 18 +----- core/jvm/src/LocalDateTimeJvm.kt | 19 +----- core/jvm/src/LocalTimeJvm.kt | 18 +----- core/jvm/src/UtcOffsetJvm.kt | 17 +----- core/jvm/src/internal/SerializedValue.kt | 74 ++++++++++++++++++++++++ core/jvm/test/JvmSerializationTest.kt | 47 ++++++++++++++- 7 files changed, 125 insertions(+), 86 deletions(-) create mode 100644 core/jvm/src/internal/SerializedValue.kt diff --git a/core/jvm/src/Instant.kt b/core/jvm/src/Instant.kt index 9001323a..d489380e 100644 --- a/core/jvm/src/Instant.kt +++ b/core/jvm/src/Instant.kt @@ -99,25 +99,9 @@ public actual class Instant internal constructor( internal actual val MIN: Instant = Instant(jtInstant.MIN) internal actual val MAX: Instant = Instant(jtInstant.MAX) - - private const val serialVersionUID: Long = 1L - } - - private fun writeObject(oStream: java.io.ObjectOutputStream) { - oStream.defaultWriteObject() - oStream.writeObject(value.toString()) } - private fun readObject(iStream: java.io.ObjectInputStream) { - iStream.defaultReadObject() - val field = this::class.java.getDeclaredField(::value.name) - field.isAccessible = true - field.set(this, parse(iStream.readObject() as String).value) - } - - private fun readObjectNoData() { - throw java.io.InvalidObjectException("Stream data required") - } + private fun writeReplace(): Any = SerializedValue(SerializedValue.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 23bc226d..0fc11077 100644 --- a/core/jvm/src/LocalDate.kt +++ b/core/jvm/src/LocalDate.kt @@ -52,8 +52,6 @@ public actual class LocalDate internal constructor( @Suppress("FunctionName") public actual fun Format(block: DateTimeFormatBuilder.WithDate.() -> Unit): DateTimeFormat = LocalDateFormat.build(block) - - private const val serialVersionUID: Long = 1L } public actual object Formats { @@ -105,21 +103,7 @@ public actual class LocalDate internal constructor( @JvmName("toEpochDays") internal fun toEpochDaysJvm(): Int = value.toEpochDay().clampToInt() - private fun writeObject(oStream: java.io.ObjectOutputStream) { - oStream.defaultWriteObject() - oStream.writeObject(value.toString()) - } - - private fun readObject(iStream: java.io.ObjectInputStream) { - iStream.defaultReadObject() - val field = this::class.java.getDeclaredField(::value.name) - field.isAccessible = true - field.set(this, jtLocalDate.parse(iStream.readObject() as String)) - } - - private fun readObjectNoData() { - throw java.io.InvalidObjectException("Stream data required") - } + private fun writeReplace(): Any = SerializedValue(SerializedValue.DATE_TAG, this) } /** diff --git a/core/jvm/src/LocalDateTimeJvm.kt b/core/jvm/src/LocalDateTimeJvm.kt index 77144b51..6f3473e8 100644 --- a/core/jvm/src/LocalDateTimeJvm.kt +++ b/core/jvm/src/LocalDateTimeJvm.kt @@ -8,6 +8,7 @@ package kotlinx.datetime import kotlinx.datetime.format.* import kotlinx.datetime.internal.removeLeadingZerosFromLongYearFormLocalDateTime +import kotlinx.datetime.internal.SerializedValue import kotlinx.datetime.serializers.LocalDateTimeIso8601Serializer import kotlinx.serialization.Serializable import java.time.DateTimeException @@ -107,29 +108,13 @@ public actual class LocalDateTime internal constructor( @Suppress("FunctionName") public actual fun Format(builder: DateTimeFormatBuilder.WithDateTime.() -> Unit): DateTimeFormat = LocalDateTimeFormat.build(builder) - - private const val serialVersionUID: Long = 1L } public actual object Formats { public actual val ISO: DateTimeFormat = ISO_DATETIME } - private fun writeObject(oStream: java.io.ObjectOutputStream) { - oStream.defaultWriteObject() - oStream.writeObject(value.toString()) - } - - private fun readObject(iStream: java.io.ObjectInputStream) { - iStream.defaultReadObject() - val field = this::class.java.getDeclaredField(::value.name) - field.isAccessible = true - field.set(this, jtLocalDateTime.parse(iStream.readObject() as String)) - } - - private fun readObjectNoData() { - throw java.io.InvalidObjectException("Stream data required") - } + private fun writeReplace(): Any = SerializedValue(SerializedValue.DATE_TIME_TAG, this) } /** diff --git a/core/jvm/src/LocalTimeJvm.kt b/core/jvm/src/LocalTimeJvm.kt index 4a9f60e3..4050aec6 100644 --- a/core/jvm/src/LocalTimeJvm.kt +++ b/core/jvm/src/LocalTimeJvm.kt @@ -86,8 +86,6 @@ public actual class LocalTime internal constructor( @Suppress("FunctionName") public actual fun Format(builder: DateTimeFormatBuilder.WithTime.() -> Unit): DateTimeFormat = LocalTimeFormat.build(builder) - - private const val serialVersionUID: Long = 1L } public actual object Formats { @@ -95,21 +93,7 @@ public actual class LocalTime internal constructor( } - private fun writeObject(oStream: java.io.ObjectOutputStream) { - oStream.defaultWriteObject() - oStream.writeObject(value.toString()) - } - - private fun readObject(iStream: java.io.ObjectInputStream) { - iStream.defaultReadObject() - val field = this::class.java.getDeclaredField(::value.name) - field.isAccessible = true - field.set(this, jtLocalTime.parse(iStream.readObject() as String)) - } - - private fun readObjectNoData() { - throw java.io.InvalidObjectException("Stream data required") - } + private fun writeReplace(): Any = SerializedValue(SerializedValue.TIME_TAG, this) } @Deprecated( diff --git a/core/jvm/src/UtcOffsetJvm.kt b/core/jvm/src/UtcOffsetJvm.kt index ed010afd..daef86be 100644 --- a/core/jvm/src/UtcOffsetJvm.kt +++ b/core/jvm/src/UtcOffsetJvm.kt @@ -6,6 +6,7 @@ package kotlinx.datetime import kotlinx.datetime.format.* +import kotlinx.datetime.internal.SerializedValue import kotlinx.datetime.serializers.UtcOffsetSerializer import kotlinx.serialization.Serializable import java.time.DateTimeException @@ -47,21 +48,7 @@ public actual class UtcOffset( public actual val FOUR_DIGITS: DateTimeFormat get() = FOUR_DIGIT_OFFSET } - private fun writeObject(oStream: java.io.ObjectOutputStream) { - oStream.defaultWriteObject() - oStream.writeObject(zoneOffset.toString()) - } - - private fun readObject(iStream: java.io.ObjectInputStream) { - iStream.defaultReadObject() - val field = this::class.java.getDeclaredField(::zoneOffset.name) - field.isAccessible = true - field.set(this, ZoneOffset.of(iStream.readObject() as String)) - } - - private fun readObjectNoData() { - throw java.io.InvalidObjectException("Stream data required") - } + private fun writeReplace(): Any = SerializedValue(SerializedValue.UTC_OFFSET_TAG, this) } @Suppress("ACTUAL_FUNCTION_WITH_DEFAULT_ARGUMENTS") diff --git a/core/jvm/src/internal/SerializedValue.kt b/core/jvm/src/internal/SerializedValue.kt new file mode 100644 index 00000000..e86ed8e5 --- /dev/null +++ b/core/jvm/src/internal/SerializedValue.kt @@ -0,0 +1,74 @@ +/* + * 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.internal + +import kotlinx.datetime.* +import java.io.* + +internal class SerializedValue(var typeTag: Int, 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 index 6f577d5e..d35262fa 100644 --- a/core/jvm/test/JvmSerializationTest.kt +++ b/core/jvm/test/JvmSerializationTest.kt @@ -13,21 +13,41 @@ 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 @@ -37,14 +57,35 @@ class JvmSerializationTest { } } - private fun roundTripSerialization(value: T) { + private fun serialize(value: Any?): ByteArray { val bos = ByteArrayOutputStream() val oos = ObjectOutputStream(bos) oos.writeObject(value) - val serialized = bos.toByteArray() + return bos.toByteArray() + } + + private fun deserialize(serialized: ByteArray): Any? { val bis = ByteArrayInputStream(serialized) ObjectInputStream(bis).use { ois -> - assertEquals(value, ois.readObject()) + 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 = "aced0005737200296b6f746c696e782e6461746574696d652e696e7465726e616c2e53657269616c697a656456616c756500000000000000000c0000787077${blockData}78" + val hexFormat = HexFormat { bytes.byteSeparator = "" } + + val deserialized = deserialize(serialized.hexToByteArray(hexFormat)) + if (expected != deserialized) { + assertEquals(expected, deserialized, "Golden serial form: $serialized\nActual serial form: ${serialize(expected).toHexString(hexFormat)}") } } + } From ca7d2d14492e02c922f8558771e37cdc595300b7 Mon Sep 17 00:00:00 2001 From: Ilya Gorbunov Date: Mon, 19 Aug 2024 14:52:25 +0200 Subject: [PATCH 5/8] Rename serialization surrogate and annotate with PublishedAPI --- core/jvm/src/Instant.kt | 2 +- core/jvm/src/LocalDate.kt | 2 +- core/jvm/src/LocalDateTimeJvm.kt | 3 +-- core/jvm/src/LocalTimeJvm.kt | 2 +- core/jvm/src/UtcOffsetJvm.kt | 3 +-- core/jvm/src/internal/{SerializedValue.kt => Ser.kt} | 7 ++++--- core/jvm/test/JvmSerializationTest.kt | 12 ++++++++---- 7 files changed, 17 insertions(+), 14 deletions(-) rename core/jvm/src/internal/{SerializedValue.kt => Ser.kt} (90%) diff --git a/core/jvm/src/Instant.kt b/core/jvm/src/Instant.kt index d489380e..9a0a837c 100644 --- a/core/jvm/src/Instant.kt +++ b/core/jvm/src/Instant.kt @@ -101,7 +101,7 @@ public actual class Instant internal constructor( internal actual val MAX: Instant = Instant(jtInstant.MAX) } - private fun writeReplace(): Any = SerializedValue(SerializedValue.INSTANT_TAG, this) + 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 0fc11077..7e7cda70 100644 --- a/core/jvm/src/LocalDate.kt +++ b/core/jvm/src/LocalDate.kt @@ -103,7 +103,7 @@ public actual class LocalDate internal constructor( @JvmName("toEpochDays") internal fun toEpochDaysJvm(): Int = value.toEpochDay().clampToInt() - private fun writeReplace(): Any = SerializedValue(SerializedValue.DATE_TAG, this) + private fun writeReplace(): Any = Ser(Ser.DATE_TAG, this) } /** diff --git a/core/jvm/src/LocalDateTimeJvm.kt b/core/jvm/src/LocalDateTimeJvm.kt index 6f3473e8..43fb4fce 100644 --- a/core/jvm/src/LocalDateTimeJvm.kt +++ b/core/jvm/src/LocalDateTimeJvm.kt @@ -8,7 +8,6 @@ package kotlinx.datetime import kotlinx.datetime.format.* import kotlinx.datetime.internal.removeLeadingZerosFromLongYearFormLocalDateTime -import kotlinx.datetime.internal.SerializedValue import kotlinx.datetime.serializers.LocalDateTimeIso8601Serializer import kotlinx.serialization.Serializable import java.time.DateTimeException @@ -114,7 +113,7 @@ public actual class LocalDateTime internal constructor( public actual val ISO: DateTimeFormat = ISO_DATETIME } - private fun writeReplace(): Any = SerializedValue(SerializedValue.DATE_TIME_TAG, this) + private fun writeReplace(): Any = Ser(Ser.DATE_TIME_TAG, this) } /** diff --git a/core/jvm/src/LocalTimeJvm.kt b/core/jvm/src/LocalTimeJvm.kt index 4050aec6..cabdfaf8 100644 --- a/core/jvm/src/LocalTimeJvm.kt +++ b/core/jvm/src/LocalTimeJvm.kt @@ -93,7 +93,7 @@ public actual class LocalTime internal constructor( } - private fun writeReplace(): Any = SerializedValue(SerializedValue.TIME_TAG, this) + private fun writeReplace(): Any = Ser(Ser.TIME_TAG, this) } @Deprecated( diff --git a/core/jvm/src/UtcOffsetJvm.kt b/core/jvm/src/UtcOffsetJvm.kt index daef86be..7f9ed703 100644 --- a/core/jvm/src/UtcOffsetJvm.kt +++ b/core/jvm/src/UtcOffsetJvm.kt @@ -6,7 +6,6 @@ package kotlinx.datetime import kotlinx.datetime.format.* -import kotlinx.datetime.internal.SerializedValue import kotlinx.datetime.serializers.UtcOffsetSerializer import kotlinx.serialization.Serializable import java.time.DateTimeException @@ -48,7 +47,7 @@ public actual class UtcOffset( public actual val FOUR_DIGITS: DateTimeFormat get() = FOUR_DIGIT_OFFSET } - private fun writeReplace(): Any = SerializedValue(SerializedValue.UTC_OFFSET_TAG, this) + private fun writeReplace(): Any = Ser(Ser.UTC_OFFSET_TAG, this) } @Suppress("ACTUAL_FUNCTION_WITH_DEFAULT_ARGUMENTS") diff --git a/core/jvm/src/internal/SerializedValue.kt b/core/jvm/src/internal/Ser.kt similarity index 90% rename from core/jvm/src/internal/SerializedValue.kt rename to core/jvm/src/internal/Ser.kt index e86ed8e5..0515a5e6 100644 --- a/core/jvm/src/internal/SerializedValue.kt +++ b/core/jvm/src/internal/Ser.kt @@ -3,12 +3,13 @@ * 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.internal +@file:Suppress("PackageDirectoryMismatch") +package kotlinx.datetime -import kotlinx.datetime.* import java.io.* -internal class SerializedValue(var typeTag: Int, var value: Any?) : Externalizable { +@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) { diff --git a/core/jvm/test/JvmSerializationTest.kt b/core/jvm/test/JvmSerializationTest.kt index d35262fa..4c7bdad8 100644 --- a/core/jvm/test/JvmSerializationTest.kt +++ b/core/jvm/test/JvmSerializationTest.kt @@ -79,12 +79,16 @@ class JvmSerializationTest { @OptIn(ExperimentalStdlibApi::class) private fun expectedDeserialization(expected: Any, blockData: String) { - val serialized = "aced0005737200296b6f746c696e782e6461746574696d652e696e7465726e616c2e53657269616c697a656456616c756500000000000000000c0000787077${blockData}78" + val serialized = "aced0005737200146b6f746c696e782e6461746574696d652e53657200000000000000000c0000787077${blockData}78" val hexFormat = HexFormat { bytes.byteSeparator = "" } - val deserialized = deserialize(serialized.hexToByteArray(hexFormat)) - if (expected != deserialized) { - assertEquals(expected, deserialized, "Golden serial form: $serialized\nActual serial form: ${serialize(expected).toHexString(hexFormat)}") + 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) } } From a67b4184e1c7713d3f9de0bf4db303cfdb9ffb5b Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Mon, 19 Aug 2024 15:44:56 +0200 Subject: [PATCH 6/8] Remove some leftovers --- core/jvm/src/LocalDateTimeJvm.kt | 3 +-- core/jvm/src/LocalTimeJvm.kt | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/core/jvm/src/LocalDateTimeJvm.kt b/core/jvm/src/LocalDateTimeJvm.kt index 43fb4fce..235be907 100644 --- a/core/jvm/src/LocalDateTimeJvm.kt +++ b/core/jvm/src/LocalDateTimeJvm.kt @@ -16,8 +16,7 @@ import java.time.LocalDateTime as jtLocalDateTime @Serializable(with = LocalDateTimeIso8601Serializer::class) public actual class LocalDateTime internal constructor( - // only a `var` to allow Java deserialization - internal var value: jtLocalDateTime + internal val value: jtLocalDateTime ) : Comparable, java.io.Serializable { public actual constructor(year: Int, month: Int, day: Int, hour: Int, minute: Int, second: Int, nanosecond: Int) : diff --git a/core/jvm/src/LocalTimeJvm.kt b/core/jvm/src/LocalTimeJvm.kt index cabdfaf8..98f42011 100644 --- a/core/jvm/src/LocalTimeJvm.kt +++ b/core/jvm/src/LocalTimeJvm.kt @@ -17,8 +17,7 @@ import java.time.LocalTime as jtLocalTime @Serializable(with = LocalTimeIso8601Serializer::class) public actual class LocalTime internal constructor( - // only a `var` to allow Java deserialization - internal var value: jtLocalTime + internal val value: jtLocalTime ) : Comparable, java.io.Serializable { public actual constructor(hour: Int, minute: Int, second: Int, nanosecond: Int) : From 8c767ead283b6537282dcc40865421460dc214bf Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Tue, 4 Mar 2025 11:28:33 +0100 Subject: [PATCH 7/8] Do not implement Instant: java.io.Serializable --- core/jvm/src/Instant.kt | 4 +--- core/jvm/src/internal/Ser.kt | 10 +--------- core/jvm/test/JvmSerializationTest.kt | 8 -------- 3 files changed, 2 insertions(+), 20 deletions(-) diff --git a/core/jvm/src/Instant.kt b/core/jvm/src/Instant.kt index 9a0a837c..78f82ed8 100644 --- a/core/jvm/src/Instant.kt +++ b/core/jvm/src/Instant.kt @@ -22,7 +22,7 @@ import java.time.Clock as jtClock @Serializable(with = InstantIso8601Serializer::class) public actual class Instant internal constructor( internal val value: jtInstant -) : Comparable, java.io.Serializable { +) : Comparable { public actual val epochSeconds: Long get() = value.epochSecond @@ -100,8 +100,6 @@ public actual class Instant internal constructor( 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/internal/Ser.kt b/core/jvm/src/internal/Ser.kt index 0515a5e6..61309ee6 100644 --- a/core/jvm/src/internal/Ser.kt +++ b/core/jvm/src/internal/Ser.kt @@ -16,11 +16,6 @@ internal class Ser(private var typeTag: Int, private var value: Any?) : External 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()) @@ -45,8 +40,6 @@ internal class Ser(private var typeTag: Int, private var value: Any?) : External 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 -> @@ -66,10 +59,9 @@ internal class Ser(private var typeTag: Int, private var value: Any?) : External 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 index 4c7bdad8..4a72e6d3 100644 --- a/core/jvm/test/JvmSerializationTest.kt +++ b/core/jvm/test/JvmSerializationTest.kt @@ -10,14 +10,6 @@ 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)) From 1776d8c79608b3f3608c8194429b25447831267b Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Mon, 17 Mar 2025 14:12:16 +0100 Subject: [PATCH 8/8] Add the API dump --- core/api/kotlinx-datetime.api | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/core/api/kotlinx-datetime.api b/core/api/kotlinx-datetime.api index e7873835..dc61840a 100644 --- a/core/api/kotlinx-datetime.api +++ b/core/api/kotlinx-datetime.api @@ -272,7 +272,7 @@ public final class kotlinx/datetime/InstantKt { public static final fun yearsUntil (Lkotlinx/datetime/Instant;Lkotlinx/datetime/Instant;Lkotlinx/datetime/TimeZone;)I } -public final class kotlinx/datetime/LocalDate : java/lang/Comparable { +public final class kotlinx/datetime/LocalDate : java/io/Serializable, java/lang/Comparable { public static final field Companion Lkotlinx/datetime/LocalDate$Companion; public fun (III)V public fun (ILjava/time/Month;I)V @@ -342,7 +342,7 @@ public final class kotlinx/datetime/LocalDateKt { public static final fun toLocalDate (Ljava/lang/String;)Lkotlinx/datetime/LocalDate; } -public final class kotlinx/datetime/LocalDateTime : java/lang/Comparable { +public final class kotlinx/datetime/LocalDateTime : java/io/Serializable, java/lang/Comparable { public static final field Companion Lkotlinx/datetime/LocalDateTime$Companion; public fun (IIIIIII)V public synthetic fun (IIIIIIIILkotlin/jvm/internal/DefaultConstructorMarker;)V @@ -397,7 +397,7 @@ public final class kotlinx/datetime/LocalDateTimeKt { public static final fun toLocalDateTime (Ljava/lang/String;)Lkotlinx/datetime/LocalDateTime; } -public final class kotlinx/datetime/LocalTime : java/lang/Comparable { +public final class kotlinx/datetime/LocalTime : java/io/Serializable, java/lang/Comparable { public static final field Companion Lkotlinx/datetime/LocalTime$Companion; public fun (IIII)V public synthetic fun (IIIIILkotlin/jvm/internal/DefaultConstructorMarker;)V @@ -474,6 +474,21 @@ public final class kotlinx/datetime/MonthKt { public static final fun getNumber (Lkotlinx/datetime/Month;)I } +public final class kotlinx/datetime/Ser : java/io/Externalizable { + public static final field Companion Lkotlinx/datetime/Ser$Companion; + public static final field DATE_TAG I + public static final field DATE_TIME_TAG I + public static final field TIME_TAG I + public static final field UTC_OFFSET_TAG I + public fun ()V + public fun (ILjava/lang/Object;)V + public fun readExternal (Ljava/io/ObjectInput;)V + public fun writeExternal (Ljava/io/ObjectOutput;)V +} + +public final class kotlinx/datetime/Ser$Companion { +} + public class kotlinx/datetime/TimeZone { public static final field Companion Lkotlinx/datetime/TimeZone$Companion; public fun equals (Ljava/lang/Object;)Z @@ -501,7 +516,7 @@ public final class kotlinx/datetime/TimeZoneKt { public static final fun toLocalDateTime (Lkotlinx/datetime/Instant;Lkotlinx/datetime/TimeZone;)Lkotlinx/datetime/LocalDateTime; } -public final class kotlinx/datetime/UtcOffset { +public final class kotlinx/datetime/UtcOffset : java/io/Serializable { public static final field Companion Lkotlinx/datetime/UtcOffset$Companion; public fun (Ljava/time/ZoneOffset;)V public fun equals (Ljava/lang/Object;)Z