From 78f5694a199d0980c4f798bdc4474905019e8b51 Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Fri, 22 Mar 2024 14:59:24 +0100 Subject: [PATCH] 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/LocalDateTime.kt | 23 +++++++++++- core/jvm/src/LocalTime.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 ab3474647..07de7345e 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 fe3b9ae1a..56fb1b554 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) { @@ -42,6 +44,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 { @@ -76,6 +81,22 @@ 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 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") + } } @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..d884089ec 100644 --- a/core/jvm/src/LocalDateTime.kt +++ b/core/jvm/src/LocalDateTime.kt @@ -16,7 +16,10 @@ 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( + // only a `var` to allow Java deserialization + internal var 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 { @@ -77,11 +80,29 @@ 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/LocalTime.kt b/core/jvm/src/LocalTime.kt index 71052570f..a410007bd 100644 --- a/core/jvm/src/LocalTime.kt +++ b/core/jvm/src/LocalTime.kt @@ -15,8 +15,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( @@ -83,10 +85,29 @@ 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") + } } diff --git a/core/jvm/src/UtcOffsetJvm.kt b/core/jvm/src/UtcOffsetJvm.kt index 129857d74..ed010afd5 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 000000000..6f577d5e2 --- /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()) + } + } +}