diff --git a/core/api/kotlinx-datetime.api b/core/api/kotlinx-datetime.api index e7873835c..dc61840a7 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 diff --git a/core/jvm/src/Instant.kt b/core/jvm/src/Instant.kt index 53c670449..78f82ed8b 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 { public actual val epochSeconds: Long get() = value.epochSecond diff --git a/core/jvm/src/LocalDate.kt b/core/jvm/src/LocalDate.kt index 9dc8e8017..7e7cda702 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) { @@ -100,6 +102,8 @@ public actual class LocalDate internal constructor(internal val value: jtLocalDa @PublishedApi @JvmName("toEpochDays") internal fun toEpochDaysJvm(): Int = value.toEpochDay().clampToInt() + + private fun writeReplace(): Any = Ser(Ser.DATE_TAG, this) } /** diff --git a/core/jvm/src/LocalDateTimeJvm.kt b/core/jvm/src/LocalDateTimeJvm.kt index 77f4d87bd..235be9071 100644 --- a/core/jvm/src/LocalDateTimeJvm.kt +++ b/core/jvm/src/LocalDateTimeJvm.kt @@ -15,7 +15,9 @@ 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( + 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) : this(try { @@ -110,6 +112,7 @@ 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/LocalTimeJvm.kt b/core/jvm/src/LocalTimeJvm.kt index 20a033014..98f42011b 100644 --- a/core/jvm/src/LocalTimeJvm.kt +++ b/core/jvm/src/LocalTimeJvm.kt @@ -16,8 +16,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( @@ -90,6 +91,8 @@ 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) } @Deprecated( 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..61309ee6d --- /dev/null +++ b/core/jvm/src/internal/Ser.kt @@ -0,0 +1,67 @@ +/* + * 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) { + 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) { + 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 DATE_TAG = 2 + const val TIME_TAG = 3 + const val DATE_TIME_TAG = 4 + const val UTC_OFFSET_TAG = 10 + } +} diff --git a/core/jvm/test/JvmSerializationTest.kt b/core/jvm/test/JvmSerializationTest.kt new file mode 100644 index 000000000..4a72e6d3d --- /dev/null +++ b/core/jvm/test/JvmSerializationTest.kt @@ -0,0 +1,87 @@ +/* + * 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 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) + } + } + +}