From 75a14a6601326516cc67eb9ea9895e869e10e35f Mon Sep 17 00:00:00 2001
From: Dmitry Khalanskiy <Dmitry.Khalanskiy@jetbrains.com>
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 24dfcbe91..bec03c5d3 100644
--- a/core/jvm/src/Instant.kt
+++ b/core/jvm/src/Instant.kt
@@ -22,7 +22,9 @@ import java.time.OffsetDateTime as jtOffsetDateTime
 import java.time.Clock as jtClock
 
 @Serializable(with = InstantIso8601Serializer::class)
-public actual class Instant internal constructor(internal val value: jtInstant) : Comparable<Instant> {
+public actual class Instant internal constructor(
+    internal val value: jtInstant
+) : Comparable<Instant>, java.io.Serializable {
 
     public actual val epochSeconds: Long
         get() = value.epochSecond
@@ -111,6 +113,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<LocalDate> {
+public actual class LocalDate internal constructor(
+    internal val value: jtLocalDate
+) : Comparable<LocalDate>, java.io.Serializable {
     public actual companion object {
         public actual fun parse(input: CharSequence, format: DateTimeFormat<LocalDate>): 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<LocalDate> =
             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<LocalDateTime> {
+public actual class LocalDateTime internal constructor(
+    // only a `var` to allow Java deserialization
+    internal var value: jtLocalDateTime
+) : Comparable<LocalDateTime>, 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<LocalDateTime> =
             LocalDateTimeFormat.build(builder)
+
+        @JvmStatic
+        private val serialVersionUID: Long = 1L
     }
 
     public actual object Formats {
         public actual val ISO: DateTimeFormat<LocalDateTime> = 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<LocalTime> {
+public actual class LocalTime internal constructor(
+    // only a `var` to allow Java deserialization
+    internal var value: jtLocalTime
+) : Comparable<LocalTime>, 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<LocalTime> =
             LocalTimeFormat.build(builder)
+
+        @JvmStatic
+        private val serialVersionUID: Long = 1L
     }
 
     public actual object Formats {
         public actual val ISO: DateTimeFormat<LocalTime> 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 6b0b29387..e4d904a23 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<UtcOffset> get() = ISO_OFFSET_BASIC
         public actual val FOUR_DIGITS: DateTimeFormat<UtcOffset> 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<NotSerializableException> {
+            roundTripSerialization(TimeZone.of("Europe/Moscow"))
+        }
+    }
+
+    private fun <T> 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())
+        }
+    }
+}