diff --git a/core/common/src/Instant.kt b/core/common/src/Instant.kt index 62fda985a..ea0ec724a 100644 --- a/core/common/src/Instant.kt +++ b/core/common/src/Instant.kt @@ -483,4 +483,4 @@ internal const val DISTANT_FUTURE_SECONDS = 3093527980800 * * Be careful: this function may throw for some values of the [Instant]. */ -internal expect fun Instant.toStringWithOffset(offset: ZoneOffset): String \ No newline at end of file +internal expect fun Instant.toStringWithOffset(offset: UtcOffset): String \ No newline at end of file diff --git a/core/common/src/TimeZone.kt b/core/common/src/TimeZone.kt index a9e279fa9..8585c171c 100644 --- a/core/common/src/TimeZone.kt +++ b/core/common/src/TimeZone.kt @@ -8,8 +8,7 @@ package kotlinx.datetime -import kotlinx.datetime.serializers.TimeZoneSerializer -import kotlinx.datetime.serializers.ZoneOffsetSerializer +import kotlinx.datetime.serializers.* import kotlinx.serialization.Serializable @Serializable(with = TimeZoneSerializer::class) @@ -32,7 +31,7 @@ public expect open class TimeZone { /** * Returns the time zone with the fixed UTC+0 offset. */ - public val UTC: TimeZone + public val UTC: FixedOffsetTimeZone /** * Returns the time zone identified by the provided [zoneId]. @@ -85,18 +84,25 @@ public expect open class TimeZone { public fun LocalDateTime.toInstant(): Instant } -@Serializable(with = ZoneOffsetSerializer::class) -public expect class ZoneOffset : TimeZone { +@Serializable(with = FixedOffsetTimeZoneSerializer::class) +public expect class FixedOffsetTimeZone : TimeZone { + public constructor(offset: UtcOffset) + public val offset: UtcOffset + + @Deprecated("Use offset.totalSeconds", ReplaceWith("offset.totalSeconds")) public val totalSeconds: Int } +@Deprecated("Use FixedOffsetTimeZone of UtcOffset instead", ReplaceWith("FixedOffsetTimeZone")) +public typealias ZoneOffset = FixedOffsetTimeZone + /** * Finds the offset from UTC this time zone has at the specified [instant] of physical time. * * @see Instant.toLocalDateTime * @see TimeZone.offsetAt */ -public expect fun TimeZone.offsetAt(instant: Instant): ZoneOffset +public expect fun TimeZone.offsetAt(instant: Instant): UtcOffset /** * Return a civil date/time value that this instant has in the specified [timeZone]. @@ -110,13 +116,16 @@ public expect fun TimeZone.offsetAt(instant: Instant): ZoneOffset */ public expect fun Instant.toLocalDateTime(timeZone: TimeZone): LocalDateTime +internal expect fun Instant.toLocalDateTime(offset: UtcOffset): LocalDateTime + + /** * Finds the offset from UTC the specified [timeZone] has at this instant of physical time. * * @see Instant.toLocalDateTime * @see TimeZone.offsetAt */ -public fun Instant.offsetIn(timeZone: TimeZone): ZoneOffset = +public fun Instant.offsetIn(timeZone: TimeZone): UtcOffset = timeZone.offsetAt(this) /** @@ -135,6 +144,8 @@ public fun Instant.offsetIn(timeZone: TimeZone): ZoneOffset = */ public expect fun LocalDateTime.toInstant(timeZone: TimeZone): Instant +public expect fun LocalDateTime.toInstant(offset: UtcOffset): Instant + /** * Returns an instant that corresponds to the start of this date in the specified [timeZone]. * diff --git a/core/common/src/UtcOffset.kt b/core/common/src/UtcOffset.kt new file mode 100644 index 000000000..9dddc022f --- /dev/null +++ b/core/common/src/UtcOffset.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2019-2021 JetBrains s.r.o. + * 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 kotlinx.datetime.serializers.UtcOffsetSerializer +import kotlinx.serialization.Serializable + +@Serializable(with = UtcOffsetSerializer::class) +public expect class UtcOffset { + public val totalSeconds: Int + + public companion object { + public val ZERO: UtcOffset + public fun parse(offsetString: String): UtcOffset + } +} +public expect fun UtcOffset(hours: Int? = null, minutes: Int? = null, seconds: Int? = null): UtcOffset + +@Deprecated("Use UtcOffset.ZERO instead", ReplaceWith("UtcOffset.ZERO"), DeprecationLevel.ERROR) +public fun UtcOffset(): UtcOffset = UtcOffset.ZERO + +public fun UtcOffset.asTimeZone(): FixedOffsetTimeZone = FixedOffsetTimeZone(this) \ No newline at end of file diff --git a/core/common/src/serializers/TimeZoneSerializers.kt b/core/common/src/serializers/TimeZoneSerializers.kt index c0a4f49b1..b0a393802 100644 --- a/core/common/src/serializers/TimeZoneSerializers.kt +++ b/core/common/src/serializers/TimeZoneSerializers.kt @@ -5,8 +5,9 @@ package kotlinx.datetime.serializers +import kotlinx.datetime.FixedOffsetTimeZone import kotlinx.datetime.TimeZone -import kotlinx.datetime.ZoneOffset +import kotlinx.datetime.UtcOffset import kotlinx.serialization.* import kotlinx.serialization.descriptors.* import kotlinx.serialization.encoding.* @@ -23,21 +24,35 @@ public object TimeZoneSerializer: KSerializer { } -public object ZoneOffsetSerializer: KSerializer { +public object FixedOffsetTimeZoneSerializer: KSerializer { - override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ZoneOffset", PrimitiveKind.STRING) + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("FixedOffsetTimeZone", PrimitiveKind.STRING) - override fun deserialize(decoder: Decoder): ZoneOffset { + override fun deserialize(decoder: Decoder): FixedOffsetTimeZone { val zone = TimeZone.of(decoder.decodeString()) - if (zone is ZoneOffset) { + if (zone is FixedOffsetTimeZone) { return zone } else { throw SerializationException("Timezone identifier '$zone' does not correspond to a fixed-offset timezone") } } - override fun serialize(encoder: Encoder, value: ZoneOffset) { + override fun serialize(encoder: Encoder, value: FixedOffsetTimeZone) { encoder.encodeString(value.id) } } + +public object UtcOffsetSerializer: KSerializer { + + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("UtcOffset", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): UtcOffset { + return UtcOffset.parse(decoder.decodeString()) + } + + override fun serialize(encoder: Encoder, value: UtcOffset) { + encoder.encodeString(value.toString()) + } + +} diff --git a/core/common/test/InstantTest.kt b/core/common/test/InstantTest.kt index 33e066c2f..5bad14932 100644 --- a/core/common/test/InstantTest.kt +++ b/core/common/test/InstantTest.kt @@ -112,13 +112,13 @@ class InstantTest { Instant.fromEpochSeconds(0, 0)) val offsets = listOf( - TimeZone.of("Z") as ZoneOffset, - TimeZone.of("+03:12:14") as ZoneOffset, - TimeZone.of("-03:12:14") as ZoneOffset, - TimeZone.of("+02:35") as ZoneOffset, - TimeZone.of("-02:35") as ZoneOffset, - TimeZone.of("+04") as ZoneOffset, - TimeZone.of("-04") as ZoneOffset, + UtcOffset.parse("Z"), + UtcOffset.parse("+03:12:14"), + UtcOffset.parse("-03:12:14"), + UtcOffset.parse("+02:35"), + UtcOffset.parse("-02:35"), + UtcOffset.parse("+04"), + UtcOffset.parse("-04"), ) for (instant in instants) { @@ -611,22 +611,3 @@ class InstantRangeTest { } } - -@Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER") -@kotlin.internal.InlineOnly -inline fun assertArithmeticFails(message: String? = null, f: () -> T) { - assertFailsWith(message) { - val result = f() - fail(result.toString()) - } -} - -@Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER") -@kotlin.internal.InlineOnly -inline fun assertInvalidFormat(message: String? = null, f: () -> T) { - assertFailsWith(message) { - val result = f() - fail(result.toString()) - } -} - diff --git a/core/common/test/TimeZoneTest.kt b/core/common/test/TimeZoneTest.kt index d29f1427b..698405e19 100644 --- a/core/common/test/TimeZoneTest.kt +++ b/core/common/test/TimeZoneTest.kt @@ -14,8 +14,12 @@ class TimeZoneTest { @Test fun utc() { - println(TimeZone.UTC) - assertEquals("Z", TimeZone.UTC.id) + val utc: FixedOffsetTimeZone = TimeZone.UTC + println(utc) + assertEquals("Z", utc.id) + assertEquals(UtcOffset.ZERO, utc.offset) + assertEquals(0, utc.offset.totalSeconds) + assertEquals(utc.offset, utc.offsetAt(Clock.System.now())) } @Test @@ -55,7 +59,13 @@ class TimeZoneTest { assertFailsWith { TimeZone.of("Mars/Standard") } assertFailsWith { TimeZone.of("UTC+X") } + } + @Test + fun ofFailsOnInvalidOffset() { + for (v in UtcOffsetTest.invalidUtcOffsetStrings) { + assertFailsWith { TimeZone.of(v) } + } } // from 310bp @@ -93,73 +103,96 @@ class TimeZoneTest { } } + @Test + fun utcOffsetNormalization() { + val sameOffsetTZs = listOf("+04", "+04:00", "UTC+4", "UT+04", "GMT+04:00:00").map { TimeZone.of(it) } + val instant = Instant.fromEpochSeconds(0) + val offsets = sameOffsetTZs.map { it.offsetAt(instant) } + + assertTrue(offsets.distinct().size == 1, "Expected all offsets to be equal: $offsets") + assertTrue(offsets.map { it.toString() }.distinct().size == 1, "Expected all offsets to have the same string representation: $offsets") + } + // from 310bp @Test fun newYorkOffset() { val test = TimeZone.of("America/New_York") - val offset = TimeZone.of("-5") - assertEquals(TimeZone.of("-5"), createInstant(offset, 2008, 1, 1).offsetIn(test)) - assertEquals(TimeZone.of("-5"), createInstant(offset, 2008, 2, 1).offsetIn(test)) - assertEquals(TimeZone.of("-5"), createInstant(offset, 2008, 3, 1).offsetIn(test)) - assertEquals(TimeZone.of("-4"), createInstant(offset, 2008, 4, 1).offsetIn(test)) - assertEquals(TimeZone.of("-4"), createInstant(offset, 2008, 5, 1).offsetIn(test)) - assertEquals(TimeZone.of("-4"), createInstant(offset, 2008, 6, 1).offsetIn(test)) - assertEquals(TimeZone.of("-4"), createInstant(offset, 2008, 7, 1).offsetIn(test)) - assertEquals(TimeZone.of("-4"), createInstant(offset, 2008, 8, 1).offsetIn(test)) - assertEquals(TimeZone.of("-4"), createInstant(offset, 2008, 9, 1).offsetIn(test)) - assertEquals(TimeZone.of("-4"), createInstant(offset, 2008, 10, 1).offsetIn(test)) - assertEquals(TimeZone.of("-4"), createInstant(offset, 2008, 11, 1).offsetIn(test)) - assertEquals(TimeZone.of("-5"), createInstant(offset, 2008, 12, 1).offsetIn(test)) - assertEquals(TimeZone.of("-5"), createInstant(offset, 2008, 1, 28).offsetIn(test)) - assertEquals(TimeZone.of("-5"), createInstant(offset, 2008, 2, 28).offsetIn(test)) - assertEquals(TimeZone.of("-4"), createInstant(offset, 2008, 3, 28).offsetIn(test)) - assertEquals(TimeZone.of("-4"), createInstant(offset, 2008, 4, 28).offsetIn(test)) - assertEquals(TimeZone.of("-4"), createInstant(offset, 2008, 5, 28).offsetIn(test)) - assertEquals(TimeZone.of("-4"), createInstant(offset, 2008, 6, 28).offsetIn(test)) - assertEquals(TimeZone.of("-4"), createInstant(offset, 2008, 7, 28).offsetIn(test)) - assertEquals(TimeZone.of("-4"), createInstant(offset, 2008, 8, 28).offsetIn(test)) - assertEquals(TimeZone.of("-4"), createInstant(offset, 2008, 9, 28).offsetIn(test)) - assertEquals(TimeZone.of("-4"), createInstant(offset, 2008, 10, 28).offsetIn(test)) - assertEquals(TimeZone.of("-5"), createInstant(offset, 2008, 11, 28).offsetIn(test)) - assertEquals(TimeZone.of("-5"), createInstant(offset, 2008, 12, 28).offsetIn(test)) + val offset = UtcOffset.parse("-5") + + fun check(expectedOffset: String, dateTime: LocalDateTime) { + assertEquals(UtcOffset.parse(expectedOffset), dateTime.toInstant(offset).offsetIn(test)) + } + + check("-5", LocalDateTime(2008, 1, 1)) + check("-5", LocalDateTime(2008, 2, 1)) + check("-5", LocalDateTime(2008, 3, 1)) + check("-4", LocalDateTime(2008, 4, 1)) + check("-4", LocalDateTime(2008, 5, 1)) + check("-4", LocalDateTime(2008, 6, 1)) + check("-4", LocalDateTime(2008, 7, 1)) + check("-4", LocalDateTime(2008, 8, 1)) + check("-4", LocalDateTime(2008, 9, 1)) + check("-4", LocalDateTime(2008, 10, 1)) + check("-4", LocalDateTime(2008, 11, 1)) + check("-5", LocalDateTime(2008, 12, 1)) + check("-5", LocalDateTime(2008, 1, 28)) + check("-5", LocalDateTime(2008, 2, 28)) + check("-4", LocalDateTime(2008, 3, 28)) + check("-4", LocalDateTime(2008, 4, 28)) + check("-4", LocalDateTime(2008, 5, 28)) + check("-4", LocalDateTime(2008, 6, 28)) + check("-4", LocalDateTime(2008, 7, 28)) + check("-4", LocalDateTime(2008, 8, 28)) + check("-4", LocalDateTime(2008, 9, 28)) + check("-4", LocalDateTime(2008, 10, 28)) + check("-5", LocalDateTime(2008, 11, 28)) + check("-5", LocalDateTime(2008, 12, 28)) } // from 310bp @Test fun newYorkOffsetToDST() { val test = TimeZone.of("America/New_York") - val offset = TimeZone.of("-5") - assertEquals(TimeZone.of("-5"), createInstant(offset, 2008, 3, 8).offsetIn(test)) - assertEquals(TimeZone.of("-5"), createInstant(offset, 2008, 3, 9).offsetIn(test)) - assertEquals(TimeZone.of("-4"), createInstant(offset, 2008, 3, 10).offsetIn(test)) - assertEquals(TimeZone.of("-4"), createInstant(offset, 2008, 3, 11).offsetIn(test)) - assertEquals(TimeZone.of("-4"), createInstant(offset, 2008, 3, 12).offsetIn(test)) - assertEquals(TimeZone.of("-4"), createInstant(offset, 2008, 3, 13).offsetIn(test)) - assertEquals(TimeZone.of("-4"), createInstant(offset, 2008, 3, 14).offsetIn(test)) + val offset = UtcOffset.parse("-5") + + fun check(expectedOffset: String, dateTime: LocalDateTime) { + assertEquals(UtcOffset.parse(expectedOffset), dateTime.toInstant(offset).offsetIn(test)) + } + + check("-5", LocalDateTime(2008, 3, 8)) + check("-5", LocalDateTime(2008, 3, 9)) + check("-4", LocalDateTime(2008, 3, 10)) + check("-4", LocalDateTime(2008, 3, 11)) + check("-4", LocalDateTime(2008, 3, 12)) + check("-4", LocalDateTime(2008, 3, 13)) + check("-4", LocalDateTime(2008, 3, 14)) // cutover at 02:00 local - assertEquals(TimeZone.of("-5"), createInstant(offset, 2008, 3, 9, 1, 59, 59, 999999999).offsetIn(test)) - assertEquals(TimeZone.of("-4"), createInstant(offset, 2008, 3, 9, 2, 0, 0, 0).offsetIn(test)) + check("-5", LocalDateTime(2008, 3, 9, 1, 59, 59, 999999999)) + check("-4", LocalDateTime(2008, 3, 9, 2, 0, 0, 0)) } // from 310bp @Test fun newYorkOffsetFromDST() { val test = TimeZone.of("America/New_York") - val offset = TimeZone.of("-4") - assertEquals(TimeZone.of("-4"), createInstant(offset, 2008, 11, 1).offsetIn(test)) - assertEquals(TimeZone.of("-4"), createInstant(offset, 2008, 11, 2).offsetIn(test)) - assertEquals(TimeZone.of("-5"), createInstant(offset, 2008, 11, 3).offsetIn(test)) - assertEquals(TimeZone.of("-5"), createInstant(offset, 2008, 11, 4).offsetIn(test)) - assertEquals(TimeZone.of("-5"), createInstant(offset, 2008, 11, 5).offsetIn(test)) - assertEquals(TimeZone.of("-5"), createInstant(offset, 2008, 11, 6).offsetIn(test)) - assertEquals(TimeZone.of("-5"), createInstant(offset, 2008, 11, 7).offsetIn(test)) + val offset = UtcOffset.parse("-4") + + fun check(expectedOffset: String, dateTime: LocalDateTime) { + assertEquals(UtcOffset.parse(expectedOffset), dateTime.toInstant(offset).offsetIn(test)) + } + + check("-4", LocalDateTime(2008, 11, 1)) + check("-4", LocalDateTime(2008, 11, 2)) + check("-5", LocalDateTime(2008, 11, 3)) + check("-5", LocalDateTime(2008, 11, 4)) + check("-5", LocalDateTime(2008, 11, 5)) + check("-5", LocalDateTime(2008, 11, 6)) + check("-5", LocalDateTime(2008, 11, 7)) // cutover at 02:00 local - assertEquals(TimeZone.of("-4"), createInstant(offset, 2008, 11, 2, 1, 59, 59, 999999999).offsetIn(test)) - assertEquals(TimeZone.of("-5"), createInstant(offset, 2008, 11, 2, 2, 0, 0, 0).offsetIn(test)) + check("-4", LocalDateTime(2008, 11, 2, 1, 59, 59, 999999999)) + check("-5", LocalDateTime(2008, 11, 2, 2, 0, 0, 0)) } - // from 310bp - private fun createInstant(offset: TimeZone, year: Int, month: Int, day: Int, hour: Int = 0, min: Int = 0, - sec: Int = 0, nano: Int = 0): Instant = - LocalDateTime(year, month, day, hour, min, sec, nano).toInstant(offset) + private fun LocalDateTime(year: Int, monthNumber: Int, dayOfMonth: Int) = LocalDateTime(year, monthNumber, dayOfMonth, 0, 0) + } diff --git a/core/common/test/UtcOffsetTest.kt b/core/common/test/UtcOffsetTest.kt new file mode 100644 index 000000000..4f8efc305 --- /dev/null +++ b/core/common/test/UtcOffsetTest.kt @@ -0,0 +1,170 @@ +/* + * Copyright 2019-2021 JetBrains s.r.o. + * 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.test + +import kotlinx.datetime.* +import kotlin.math.abs +import kotlin.test.* + +class UtcOffsetTest { + + companion object { + val invalidUtcOffsetStrings = listOf( + "", *('A'..'Y').map { it.toString() }.toTypedArray(), "ZZ", + "0", "+0:00", "+00:0", "+0:0", + "+000", "+00000", + "+0:00:00", "+00:0:00", "+00:00:0", "+0:0:0", "+0:0:00", "+00:0:0", "+0:00:0", + "1", "+01_00", "+01;00", "+01@00", "+01:AA", + "+19", "+19:00", "+18:01", "+18:00:01", "+1801", "+180001", + "-0:00", "-00:0", "-0:0", + "-000", "-00000", + "-0:00:00", "-00:0:00", "-00:00:0", "-0:0:0", "-0:0:00", "-00:0:0", "-0:00:0", + "-19", "-19:00", "-18:01", "-18:00:01", "-1801", "-180001", + "-01_00", "-01;00", "-01@00", "-01:AA", + "@01:00") + + val fixedOffsetTimeZoneIds = listOf( + "UTC", "UTC+0", "GMT+01", "UT-01", "Etc/UTC" + ) + + val offsetSecondsRange = -18 * 60 * 60 .. +18 * 60 * 60 + } + + + @Test + fun construction() { + for (totalSeconds in offsetSecondsRange) { + val hours = totalSeconds / (60 * 60) + val totalMinutes = totalSeconds / 60 + val minutes = totalMinutes % 60 + val seconds = totalSeconds % 60 + val offset = UtcOffset(hours, minutes, seconds) + val offsetSeconds = UtcOffset(seconds = totalSeconds) + val offsetMinutes = UtcOffset(minutes = totalMinutes, seconds = seconds) + assertEquals(totalSeconds, offset.totalSeconds) + assertEquals(offset, offsetMinutes) + assertEquals(offset, offsetSeconds) + } + } + + @Test + fun constructionErrors() { + // total range + assertIllegalArgument { UtcOffset(hours = -19) } + assertIllegalArgument { UtcOffset(hours = +19) } + assertIllegalArgument { UtcOffset(hours = -18, minutes = -1) } + assertIllegalArgument { UtcOffset(hours = -18, seconds = -1) } + assertIllegalArgument { UtcOffset(hours = +18, seconds = +1) } + assertIllegalArgument { UtcOffset(hours = +18, seconds = +1) } + assertIllegalArgument { UtcOffset(seconds = offsetSecondsRange.first - 1) } + assertIllegalArgument { UtcOffset(seconds = offsetSecondsRange.last + 1) } + // component ranges + assertIllegalArgument { UtcOffset(hours = 0, minutes = 60) } + assertIllegalArgument { UtcOffset(hours = 0, seconds = -60) } + assertIllegalArgument { UtcOffset(minutes = 90, seconds = 90) } + assertIllegalArgument { UtcOffset(minutes = 0, seconds = 90) } + // component signs + assertIllegalArgument { UtcOffset(hours = +1, minutes = -1) } + assertIllegalArgument { UtcOffset(hours = +1, seconds = -1) } + assertIllegalArgument { UtcOffset(hours = -1, minutes = +1) } + assertIllegalArgument { UtcOffset(hours = -1, seconds = +1) } + assertIllegalArgument { UtcOffset(minutes = +1, seconds = -1) } + assertIllegalArgument { UtcOffset(minutes = -1, seconds = +1) } + } + + @Test + fun utcOffsetToString() { + assertEquals("+01:00", UtcOffset(hours = 1, minutes = 0, seconds = 0).toString()) + assertEquals("+01:02:03", UtcOffset(hours = 1, minutes = 2, seconds = 3).toString()) + assertEquals("-01:00:30", UtcOffset(hours = -1, minutes = 0, seconds = -30).toString()) + assertEquals("Z", UtcOffset.ZERO.toString()) + } + + @Test + fun invalidUtcOffsetStrings() { + for (v in invalidUtcOffsetStrings) { + assertFailsWith("Should fail: $v") { UtcOffset.parse(v) } + } + for (v in fixedOffsetTimeZoneIds) { + assertFailsWith("Time zone name should not be parsed as UtcOffset: $v") { UtcOffset.parse(v) } + } + } + + @Test + fun parseAllValidValues() { + fun Int.pad() = toString().padStart(2, '0') + fun check(offsetSeconds: Int, offsetString: String, canonical: Boolean = false) { + val offset = UtcOffset.parse(offsetString) + if (offsetSeconds != offset.totalSeconds) { + fail("Expected string $offsetString to be parsed as $offset and have $offsetSeconds offset, got ${offset.totalSeconds}") + } + + val actualOffsetString = offset.toString() + if (canonical) { + assertEquals(offsetString, actualOffsetString) + } else { + assertNotEquals(offsetString, actualOffsetString) + val offset2 = UtcOffset.parse(actualOffsetString) + assertEquals(offset, offset2) + } + } + + for (offsetSeconds in offsetSecondsRange) { + val sign = when { + offsetSeconds < 0 -> "-" + else -> "+" + } + val hours = abs(offsetSeconds / 60 / 60) + val minutes = abs(offsetSeconds / 60 % 60) + val seconds = abs(offsetSeconds % 60) + + + check(offsetSeconds, "$sign${hours.pad()}:${minutes.pad()}:${seconds.pad()}", canonical = seconds != 0) + check(offsetSeconds, "$sign${hours.pad()}${minutes.pad()}${seconds.pad()}") + if (seconds == 0) { + check(offsetSeconds, "$sign${hours.pad()}:${minutes.pad()}", canonical = offsetSeconds != 0) + check(offsetSeconds, "$sign${hours.pad()}${minutes.pad()}") + if (minutes == 0) { + check(offsetSeconds, "$sign${hours.pad()}") + check(offsetSeconds, "$sign$hours") + } + } + } + check(0, "+00:00") + check(0, "-00:00") + check(0, "+0") + check(0, "-0") + check(0, "Z", canonical = true) + } + + @Test + fun equality() { + val equalOffsets = listOf( + listOf("Z", "+0", "+00", "+0000", "+00:00:00", "-00:00:00"), + listOf("+4", "+04", "+04:00"), + listOf("-18", "-1800", "-18:00:00"), + ) + for (equalGroup in equalOffsets) { + val offsets = equalGroup.map { UtcOffset.parse(it) } + val message = "$offsets" + assertEquals(1, offsets.distinct().size, message) + assertEquals(1, offsets.map { it.toString() }.distinct().size, message) + assertEquals(1, offsets.map { it.hashCode() }.distinct().size, message) + } + for ((offset1, offset2) in equalOffsets.map { UtcOffset.parse(it.random()) }.shuffled().zipWithNext()) { + assertNotEquals(offset1, offset2) + assertNotEquals(offset1.toString(), offset2.toString()) + } + } + + @Test + fun asTimeZone() { + val offset = UtcOffset(hours = 1, minutes = 20, seconds = 30) + val timeZone = offset.asTimeZone() + assertIs(timeZone) + assertEquals(offset, timeZone.offset) + } +} \ No newline at end of file diff --git a/core/common/test/assertions.kt b/core/common/test/assertions.kt new file mode 100644 index 000000000..dcae8d5b1 --- /dev/null +++ b/core/common/test/assertions.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2019-2021 JetBrains s.r.o. + * 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.test + +import kotlinx.datetime.DateTimeArithmeticException +import kotlinx.datetime.DateTimeFormatException +import kotlin.test.assertFailsWith +import kotlin.test.fail + +@Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER") +@kotlin.internal.InlineOnly +inline fun assertArithmeticFails(message: String? = null, f: () -> T) { + assertFailsWith(message) { + val result = f() + fail(result.toString()) + } +} + +@Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER") +@kotlin.internal.InlineOnly +inline fun assertInvalidFormat(message: String? = null, f: () -> T) { + assertFailsWith(message) { + val result = f() + fail(result.toString()) + } +} + +@Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER") +@kotlin.internal.InlineOnly +inline fun assertIllegalArgument(message: String? = null, f: () -> T) { + assertFailsWith(message) { + val result = f() + fail(result.toString()) + } +} \ No newline at end of file diff --git a/core/darwin/src/Converters.kt b/core/darwin/src/Converters.kt index 8d23fcab0..958a1c6cc 100644 --- a/core/darwin/src/Converters.kt +++ b/core/darwin/src/Converters.kt @@ -42,11 +42,11 @@ public fun NSDate.toKotlinInstant(): Instant { * [DateTimeException] to denote that lossy conversion would happen, as Darwin internally rounds the offsets to the * nearest minute. */ -public fun TimeZone.toNSTimeZone(): NSTimeZone = if (this is ZoneOffset) { - require (totalSeconds % 60 == 0) { - "Lossy conversion: Darwin uses minute precision for fixed-offset time zones" +public fun TimeZone.toNSTimeZone(): NSTimeZone = if (this is FixedOffsetTimeZone) { + require (offset.totalSeconds % 60 == 0) { + "NSTimeZone cannot represent fixed-offset time zones with offsets not expressed in whole minutes: $this" } - NSTimeZone.timeZoneForSecondsFromGMT(totalSeconds.convert()) + NSTimeZone.timeZoneForSecondsFromGMT(offset.totalSeconds.convert()) } else { NSTimeZone.timeZoneWithName(id) ?: NSTimeZone.timeZoneWithAbbreviation(id)!! } diff --git a/core/darwin/src/TimeZoneNative.kt b/core/darwin/src/TimeZoneNative.kt index ca6d104bf..ca04c8d8d 100644 --- a/core/darwin/src/TimeZoneNative.kt +++ b/core/darwin/src/TimeZoneNative.kt @@ -112,7 +112,7 @@ internal actual class PlatformTimeZoneImpl(private val value: NSTimeZone, overri override fun atStartOfDay(date: LocalDate): Instant { val ldt = LocalDateTime(date, LocalTime.MIN) - val epochSeconds = ldt.toEpochSecond(ZoneOffsetImpl.UTC) + val epochSeconds = ldt.toEpochSecond(UtcOffset.ZERO) // timezone val nsDate = NSDate.dateWithTimeIntervalSince1970(epochSeconds.toDouble()) val newDate = systemDateByLocalDate(value, nsDate) @@ -132,15 +132,15 @@ internal actual class PlatformTimeZoneImpl(private val value: NSTimeZone, overri return Instant(midnight.timeIntervalSince1970.toLong(), 0) } - override fun LocalDateTime.atZone(preferred: ZoneOffsetImpl?): ZonedDateTime { - val epochSeconds = toEpochSecond(ZoneOffsetImpl.UTC) + override fun atZone(dateTime: LocalDateTime, preferred: UtcOffset?): ZonedDateTime { + val epochSeconds = dateTime.toEpochSecond(UtcOffset.ZERO) var offset = preferred?.totalSeconds ?: Int.MAX_VALUE val transitionDuration = run { /* a date in an unspecified timezone, defined by the number of seconds since the start of the epoch in *that* unspecified timezone */ val date = dateWithTimeIntervalSince1970Saturating(epochSeconds) val newDate = systemDateByLocalDate(value, date) - ?: throw RuntimeException("Unable to acquire the offset at ${this@atZone} for zone ${this@PlatformTimeZoneImpl}") + ?: throw RuntimeException("Unable to acquire the offset at $dateTime for zone ${this@PlatformTimeZoneImpl}") // we now know the offset of that timezone at this time. offset = value.secondsFromGMTForDate(newDate).toInt() /* `dateFromComponents` automatically corrects the date to avoid gaps. We @@ -148,19 +148,19 @@ internal actual class PlatformTimeZoneImpl(private val value: NSTimeZone, overri (newDate.timeIntervalSince1970.toLong() + offset.toLong() - date.timeIntervalSince1970.toLong()).toInt() } - val dateTime = try { - this@atZone.plusSeconds(transitionDuration) + val correctedDateTime = try { + dateTime.plusSeconds(transitionDuration) } catch (e: IllegalArgumentException) { throw DateTimeArithmeticException("Overflow whet correcting the date-time to not be in the transition gap", e) } catch (e: ArithmeticException) { throw RuntimeException("Anomalously long timezone transition gap reported", e) } - return ZonedDateTime(dateTime, TimeZone(this@PlatformTimeZoneImpl), ZoneOffset.ofSeconds(offset).offset) + return ZonedDateTime(correctedDateTime, TimeZone(this), UtcOffset.ofSeconds(offset)) } - override fun offsetAt(instant: Instant): ZoneOffsetImpl { + override fun offsetAt(instant: Instant): UtcOffset { val date = dateWithTimeIntervalSince1970Saturating(instant.epochSeconds) - return ZoneOffset.ofSeconds(value.secondsFromGMTForDate(date).toInt()).offset + return UtcOffset.ofSeconds(value.secondsFromGMTForDate(date).toInt()) } // org.threeten.bp.ZoneId#equals diff --git a/core/darwin/test/ConvertersTest.kt b/core/darwin/test/ConvertersTest.kt index 35a4d144b..76fba650c 100644 --- a/core/darwin/test/ConvertersTest.kt +++ b/core/darwin/test/ConvertersTest.kt @@ -67,7 +67,7 @@ class ConvertersTest { (abs(hours) + 100).toString().substring(1) + ":" + (abs(minutes) + 100).toString().substring(1) + ":" + "00" - val test = TimeZone.of(str) + val test = TimeZone.of(str) as FixedOffsetTimeZone zoneOffsetCheck(test, hours, minutes) } } @@ -93,9 +93,11 @@ class ConvertersTest { assertEquals(str + "Z", dateFormatter.stringFromDate(nsDate)) } - private fun zoneOffsetCheck(timeZone: TimeZone, hours: Int, minutes: Int) { + private fun zoneOffsetCheck(timeZone: FixedOffsetTimeZone, hours: Int, minutes: Int) { val nsTimeZone = timeZone.toNSTimeZone() + val kotlinTimeZone = nsTimeZone.toKotlinTimeZone() assertEquals(hours * 3600 + minutes * 60, nsTimeZone.secondsFromGMT.convert()) - assertEquals(timeZone, nsTimeZone.toKotlinTimeZone()) + assertIs(kotlinTimeZone) + assertEquals(timeZone.offset, kotlinTimeZone.offset) } } diff --git a/core/js/src/Instant.kt b/core/js/src/Instant.kt index 4e5406fcb..73d289b78 100644 --- a/core/js/src/Instant.kt +++ b/core/js/src/Instant.kt @@ -217,5 +217,5 @@ public actual fun Instant.until(other: Instant, unit: DateTimeUnit, timeZone: Ti if (e.isJodaDateTimeException()) throw DateTimeArithmeticException(e) else throw e } -internal actual fun Instant.toStringWithOffset(offset: ZoneOffset): String = - jtOffsetDateTime.ofInstant(this.value, offset.zoneId).toString() +internal actual fun Instant.toStringWithOffset(offset: UtcOffset): String = + jtOffsetDateTime.ofInstant(this.value, offset.zoneOffset).toString() diff --git a/core/js/src/TimeZone.kt b/core/js/src/TimeZone.kt index da9187fcd..d42a318a1 100644 --- a/core/js/src/TimeZone.kt +++ b/core/js/src/TimeZone.kt @@ -4,11 +4,10 @@ */ package kotlinx.datetime +import kotlinx.datetime.serializers.* import kotlinx.datetime.internal.JSJoda.ZoneId -import kotlinx.datetime.serializers.TimeZoneSerializer -import kotlinx.datetime.serializers.ZoneOffsetSerializer -import kotlinx.serialization.Serializable import kotlinx.datetime.internal.JSJoda.ZoneOffset as jtZoneOffset +import kotlinx.serialization.Serializable @Serializable(with = TimeZoneSerializer::class) public actual open class TimeZone internal constructor(internal val zoneId: ZoneId) { @@ -28,12 +27,12 @@ public actual open class TimeZone internal constructor(internal val zoneId: Zone public actual companion object { public actual fun currentSystemDefault(): TimeZone = ZoneId.systemDefault().let(::TimeZone) - public actual val UTC: TimeZone = jtZoneOffset.UTC.let(::TimeZone) + public actual val UTC: FixedOffsetTimeZone = UtcOffset(jtZoneOffset.UTC).asTimeZone() public actual fun of(zoneId: String): TimeZone = try { val zone = ZoneId.of(zoneId) if (zone is jtZoneOffset) { - ZoneOffset(zone) + FixedOffsetTimeZone(UtcOffset(zone)) } else { TimeZone(zone) } @@ -46,10 +45,11 @@ public actual open class TimeZone internal constructor(internal val zoneId: Zone } } -@Serializable(with = ZoneOffsetSerializer::class) -public actual class ZoneOffset internal constructor(zoneOffset: jtZoneOffset): TimeZone(zoneOffset) { +@Serializable(with = FixedOffsetTimeZoneSerializer::class) +public actual class FixedOffsetTimeZone actual constructor(public actual val offset: UtcOffset): TimeZone(offset.zoneOffset) { private val zoneOffset get() = zoneId as jtZoneOffset + @Deprecated("Use offset.totalSeconds", ReplaceWith("offset.totalSeconds")) public actual val totalSeconds: Int get() = zoneOffset.totalSeconds().toInt() } @@ -61,11 +61,22 @@ public actual fun Instant.toLocalDateTime(timeZone: TimeZone): LocalDateTime = t throw e } -public actual fun TimeZone.offsetAt(instant: Instant): ZoneOffset = - zoneId.rules().offsetOfInstant(instant.value).let(::ZoneOffset) +internal actual fun Instant.toLocalDateTime(offset: UtcOffset): LocalDateTime = try { + kotlinx.datetime.internal.JSJoda.LocalDateTime.ofInstant(this.value, offset.zoneOffset).let(::LocalDateTime) +} catch (e: Throwable) { + if (e.isJodaDateTimeException()) throw DateTimeArithmeticException(e) + throw e +} + + +public actual fun TimeZone.offsetAt(instant: Instant): UtcOffset = + zoneId.rules().offsetOfInstant(instant.value).let(::UtcOffset) public actual fun LocalDateTime.toInstant(timeZone: TimeZone): Instant = this.value.atZone(timeZone.zoneId).toInstant().let(::Instant) +public actual fun LocalDateTime.toInstant(offset: UtcOffset): Instant = + this.value.toInstant(offset.zoneOffset).let(::Instant) + public actual fun LocalDate.atStartOfDayIn(timeZone: TimeZone): Instant = this.value.atStartOfDay(timeZone.zoneId).toInstant().let(::Instant) diff --git a/core/js/src/UtcOffset.kt b/core/js/src/UtcOffset.kt new file mode 100644 index 000000000..31e36a53f --- /dev/null +++ b/core/js/src/UtcOffset.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2019-2021 JetBrains s.r.o. + * 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 kotlinx.datetime.internal.JSJoda.ZoneOffset +import kotlinx.datetime.serializers.UtcOffsetSerializer +import kotlinx.serialization.Serializable + +@Serializable(with = UtcOffsetSerializer::class) +public actual class UtcOffset(internal val zoneOffset: ZoneOffset) { + public actual val totalSeconds: Int get() = zoneOffset.totalSeconds().toInt() + + override fun hashCode(): Int = zoneOffset.hashCode().toInt() + override fun equals(other: Any?): Boolean = other is UtcOffset && this.zoneOffset == other.zoneOffset + override fun toString(): String = zoneOffset.toString() + + public actual companion object { + + public actual val ZERO: UtcOffset = UtcOffset(ZoneOffset.UTC) + + public actual fun parse(offsetString: String): UtcOffset = try { + ZoneOffset.of(offsetString).let(::UtcOffset) + } catch (e: Throwable) { + if (e.isJodaDateTimeException()) throw DateTimeFormatException(e) + throw e + } + } +} + +@Suppress("ACTUAL_FUNCTION_WITH_DEFAULT_ARGUMENTS") +public actual fun UtcOffset(hours: Int? = null, minutes: Int? = null, seconds: Int? = null): UtcOffset = + try { + when { + hours != null -> + UtcOffset(ZoneOffset.ofHoursMinutesSeconds(hours, minutes ?: 0, seconds ?: 0)) + minutes != null -> + UtcOffset(ZoneOffset.ofHoursMinutesSeconds(minutes / 60, minutes % 60, seconds ?: 0)) + else -> { + UtcOffset(ZoneOffset.ofTotalSeconds(seconds ?: 0)) + } + } + } catch (e: Throwable) { + if (e.isJodaDateTimeException()) throw IllegalArgumentException(e) else throw e + } diff --git a/core/jvm/src/Converters.kt b/core/jvm/src/Converters.kt index c20db0408..c804b7dfc 100644 --- a/core/jvm/src/Converters.kt +++ b/core/jvm/src/Converters.kt @@ -61,11 +61,25 @@ public fun java.time.ZoneId.toKotlinTimeZone(): TimeZone = TimeZone(this) /** - * Converts this [kotlinx.datetime.ZoneOffset][ZoneOffset] value to a [java.time.ZoneOffset][java.time.ZoneOffset] value. + * Converts this [kotlinx.datetime.FixedOffsetTimeZone][FixedOffsetTimeZone] value to a [java.time.ZoneOffset][java.time.ZoneOffset] value. */ -public fun ZoneOffset.toJavaZoneOffset(): java.time.ZoneOffset = this.zoneOffset +public fun FixedOffsetTimeZone.toJavaZoneOffset(): java.time.ZoneOffset = this.offset.zoneOffset /** - * Converts this [java.time.ZoneOffset][java.time.ZoneOffset] value to a [kotlinx.datetime.ZoneOffset][ZoneOffset] value. + * Converts this [java.time.ZoneOffset][java.time.ZoneOffset] value to a [kotlinx.datetime.FixedOffsetTimeZone][FixedOffsetTimeZone] value. */ -public fun java.time.ZoneOffset.toKotlinZoneOffset(): ZoneOffset = ZoneOffset(this) +public fun java.time.ZoneOffset.toKotlinFixedOffsetTimeZone(): FixedOffsetTimeZone = FixedOffsetTimeZone(UtcOffset(this)) + +@Deprecated("Use toKotlinFixedOffsetTimeZone() instead.", ReplaceWith("this.toKotlinFixedOffsetTimeZone()")) +public fun java.time.ZoneOffset.toKotlinZoneOffset(): FixedOffsetTimeZone = toKotlinFixedOffsetTimeZone() + +/** + * Converts this [kotlinx.datetime.UtcOffset][UtcOffset] value to a [java.time.ZoneOffset][java.time.ZoneOffset] value. + */ +public fun UtcOffset.toJavaZoneOffset(): java.time.ZoneOffset = this.zoneOffset + +/** + * Converts this [java.time.ZoneOffset][java.time.ZoneOffset] value to a [kotlinx.datetime.UtcOffset][UtcOffset] value. + */ +public fun java.time.ZoneOffset.toKotlinUtcOffset(): UtcOffset = UtcOffset(this) + diff --git a/core/jvm/src/Instant.kt b/core/jvm/src/Instant.kt index 5be997d05..fb50c2c76 100644 --- a/core/jvm/src/Instant.kt +++ b/core/jvm/src/Instant.kt @@ -182,5 +182,5 @@ public actual fun Instant.until(other: Instant, unit: DateTimeUnit, timeZone: Ti if (this.value < other.value) Long.MAX_VALUE else Long.MIN_VALUE } -internal actual fun Instant.toStringWithOffset(offset: ZoneOffset): String = - jtOffsetDateTime.ofInstant(this.value, offset.zoneId).toString() +internal actual fun Instant.toStringWithOffset(offset: UtcOffset): String = + jtOffsetDateTime.ofInstant(this.value, offset.zoneOffset).toString() diff --git a/core/jvm/src/TimeZoneJvm.kt b/core/jvm/src/TimeZoneJvm.kt index c4c2771fd..cf802dfd9 100644 --- a/core/jvm/src/TimeZoneJvm.kt +++ b/core/jvm/src/TimeZoneJvm.kt @@ -8,8 +8,7 @@ package kotlinx.datetime -import kotlinx.datetime.serializers.TimeZoneSerializer -import kotlinx.datetime.serializers.ZoneOffsetSerializer +import kotlinx.datetime.serializers.* import kotlinx.serialization.Serializable import java.time.DateTimeException import java.time.ZoneId @@ -33,12 +32,12 @@ public actual open class TimeZone internal constructor(internal val zoneId: Zone public actual companion object { public actual fun currentSystemDefault(): TimeZone = ZoneId.systemDefault().let(::TimeZone) - public actual val UTC: TimeZone = jtZoneOffset.UTC.let(::TimeZone) + public actual val UTC: FixedOffsetTimeZone = UtcOffset(jtZoneOffset.UTC).asTimeZone() public actual fun of(zoneId: String): TimeZone = try { val zone = ZoneId.of(zoneId) if (zone is jtZoneOffset) { - ZoneOffset(zone) + FixedOffsetTimeZone(UtcOffset(zone)) } else { TimeZone(zone) } @@ -51,15 +50,15 @@ public actual open class TimeZone internal constructor(internal val zoneId: Zone } } -@Serializable(with = ZoneOffsetSerializer::class) -public actual class ZoneOffset internal constructor(zoneOffset: jtZoneOffset): TimeZone(zoneOffset) { - internal val zoneOffset get() = zoneId as jtZoneOffset - - public actual val totalSeconds: Int get() = zoneOffset.totalSeconds +@Serializable(with = FixedOffsetTimeZoneSerializer::class) +public actual class FixedOffsetTimeZone +public actual constructor(public actual val offset: UtcOffset): TimeZone(offset.zoneOffset) { + @Deprecated("Use offset.totalSeconds", ReplaceWith("offset.totalSeconds")) + public actual val totalSeconds: Int get() = offset.totalSeconds } -public actual fun TimeZone.offsetAt(instant: Instant): ZoneOffset = - zoneId.rules.getOffset(instant.value).let(::ZoneOffset) +public actual fun TimeZone.offsetAt(instant: Instant): UtcOffset = + zoneId.rules.getOffset(instant.value).let(::UtcOffset) public actual fun Instant.toLocalDateTime(timeZone: TimeZone): LocalDateTime = try { java.time.LocalDateTime.ofInstant(this.value, timeZone.zoneId).let(::LocalDateTime) @@ -67,8 +66,18 @@ public actual fun Instant.toLocalDateTime(timeZone: TimeZone): LocalDateTime = t throw DateTimeArithmeticException(e) } +internal actual fun Instant.toLocalDateTime(offset: UtcOffset): LocalDateTime = try { + java.time.LocalDateTime.ofInstant(this.value, offset.zoneOffset).let(::LocalDateTime) +} catch (e: DateTimeException) { + throw DateTimeArithmeticException(e) +} + + public actual fun LocalDateTime.toInstant(timeZone: TimeZone): Instant = this.value.atZone(timeZone.zoneId).toInstant().let(::Instant) +public actual fun LocalDateTime.toInstant(offset: UtcOffset): Instant = + this.value.toInstant(offset.zoneOffset).let(::Instant) + public actual fun LocalDate.atStartOfDayIn(timeZone: TimeZone): Instant = this.value.atStartOfDay(timeZone.zoneId).toInstant().let(::Instant) diff --git a/core/jvm/src/UtcOffsetJvm.kt b/core/jvm/src/UtcOffsetJvm.kt new file mode 100644 index 000000000..ccde301a8 --- /dev/null +++ b/core/jvm/src/UtcOffsetJvm.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2019-2021 JetBrains s.r.o. + * 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 kotlinx.datetime.serializers.UtcOffsetSerializer +import kotlinx.serialization.Serializable +import java.time.DateTimeException +import java.time.ZoneOffset + +@Serializable(with = UtcOffsetSerializer::class) +public actual class UtcOffset(internal val zoneOffset: ZoneOffset) { + public actual val totalSeconds: Int get() = zoneOffset.totalSeconds + + override fun hashCode(): Int = zoneOffset.hashCode() + override fun equals(other: Any?): Boolean = other is UtcOffset && this.zoneOffset == other.zoneOffset + override fun toString(): String = zoneOffset.toString() + + public actual companion object { + + public actual val ZERO: UtcOffset = UtcOffset(ZoneOffset.UTC) + + public actual fun parse(offsetString: String): UtcOffset = try { + ZoneOffset.of(offsetString).let(::UtcOffset) + } catch (e: DateTimeException) { + throw DateTimeFormatException(e) + } + } +} + +@Suppress("ACTUAL_FUNCTION_WITH_DEFAULT_ARGUMENTS") +public actual fun UtcOffset(hours: Int? = null, minutes: Int? = null, seconds: Int? = null): UtcOffset = + try { + when { + hours != null -> + UtcOffset(ZoneOffset.ofHoursMinutesSeconds(hours, minutes ?: 0, seconds ?: 0)) + minutes != null -> + UtcOffset(ZoneOffset.ofHoursMinutesSeconds(minutes / 60, minutes % 60, seconds ?: 0)) + else -> { + UtcOffset(ZoneOffset.ofTotalSeconds(seconds ?: 0)) + } + } + } catch (e: DateTimeException) { + throw IllegalArgumentException(e) + } diff --git a/core/jvm/test/ConvertersTest.kt b/core/jvm/test/ConvertersTest.kt index b59890815..978803a23 100644 --- a/core/jvm/test/ConvertersTest.kt +++ b/core/jvm/test/ConvertersTest.kt @@ -129,13 +129,15 @@ class ConvertersTest { @Test fun zoneOffset() { fun test(offsetString: String) { - val ktZoneOffset = TimeZone.of(offsetString).offsetAt(Instant.fromEpochMilliseconds(0)) + val ktUtcOffset = TimeZone.of(offsetString).offsetAt(Instant.fromEpochMilliseconds(0)) + val ktZoneOffset = ktUtcOffset.asTimeZone() val jtZoneOffset = JTZoneOffset.of(offsetString) - assertEquals(ktZoneOffset, jtZoneOffset.toKotlinZoneOffset()) + assertEquals(ktZoneOffset, jtZoneOffset.toKotlinFixedOffsetTimeZone()) assertEquals(ktZoneOffset, jtZoneOffset.toKotlinTimeZone()) assertEquals(jtZoneOffset, ktZoneOffset.toJavaZoneOffset()) assertEquals(jtZoneOffset, ktZoneOffset.toJavaZoneId()) + assertEquals(jtZoneOffset, ktUtcOffset.toJavaZoneOffset()) } test("Z") diff --git a/core/native/cinterop_actuals/TimeZoneNative.kt b/core/native/cinterop_actuals/TimeZoneNative.kt index a958622f2..d6e6b3bbc 100644 --- a/core/native/cinterop_actuals/TimeZoneNative.kt +++ b/core/native/cinterop_actuals/TimeZoneNative.kt @@ -47,7 +47,7 @@ internal actual class PlatformTimeZoneImpl(private val tzid: TZID, override val override fun atStartOfDay(date: LocalDate): Instant = memScoped { val ldt = LocalDateTime(date, LocalTime.MIN) - val epochSeconds = ldt.toEpochSecond(ZoneOffsetImpl.UTC) + val epochSeconds = ldt.toEpochSecond(UtcOffset.ZERO) val midnightInstantSeconds = at_start_of_day(tzid, epochSeconds) if (midnightInstantSeconds == Long.MAX_VALUE) { throw RuntimeException("Unable to acquire the time of start of day at $date for zone $this") @@ -55,30 +55,30 @@ internal actual class PlatformTimeZoneImpl(private val tzid: TZID, override val Instant(midnightInstantSeconds, 0) } - override fun LocalDateTime.atZone(preferred: ZoneOffsetImpl?): ZonedDateTime = memScoped { - val epochSeconds = toEpochSecond(ZoneOffsetImpl.UTC) + override fun atZone(dateTime: LocalDateTime, preferred: UtcOffset?): ZonedDateTime = memScoped { + val epochSeconds = dateTime.toEpochSecond(UtcOffset.ZERO) val offset = alloc() offset.value = preferred?.totalSeconds ?: Int.MAX_VALUE val transitionDuration = offset_at_datetime(tzid, epochSeconds, offset.ptr) if (offset.value == Int.MAX_VALUE) { - throw RuntimeException("Unable to acquire the offset at ${this@atZone} for zone ${this@PlatformTimeZoneImpl}") + throw RuntimeException("Unable to acquire the offset at $dateTime for zone ${this@PlatformTimeZoneImpl}") } - val dateTime = try { - this@atZone.plusSeconds(transitionDuration) + val correctedDateTime = try { + dateTime.plusSeconds(transitionDuration) } catch (e: IllegalArgumentException) { throw DateTimeArithmeticException("Overflow whet correcting the date-time to not be in the transition gap", e) } catch (e: ArithmeticException) { throw RuntimeException("Anomalously long timezone transition gap reported", e) } - ZonedDateTime(dateTime, TimeZone(this@PlatformTimeZoneImpl), ZoneOffset.ofSeconds(offset.value).offset) + ZonedDateTime(correctedDateTime, TimeZone(this@PlatformTimeZoneImpl), UtcOffset.ofSeconds(offset.value)) } - override fun offsetAt(instant: Instant): ZoneOffsetImpl { + override fun offsetAt(instant: Instant): UtcOffset { val offset = offset_at_instant(tzid, instant.epochSeconds) if (offset == Int.MAX_VALUE) { throw RuntimeException("Unable to acquire the offset at instant $instant for zone $this") } - return ZoneOffset.ofSeconds(offset).offset + return UtcOffset.ofSeconds(offset) } // org.threeten.bp.ZoneId#equals diff --git a/core/native/src/Instant.kt b/core/native/src/Instant.kt index b0616e85a..58da603cc 100644 --- a/core/native/src/Instant.kt +++ b/core/native/src/Instant.kt @@ -27,8 +27,8 @@ public actual enum class DayOfWeek { * * We can't just reuse the parsing logic of [ZoneOffset.of], as that version is more lenient: here, strings like * "0330" are not considered valid zone offsets, whereas [ZoneOffset.of] sees treats the example above as "03:30". */ -private val zoneOffsetParser: Parser - get() = (concreteCharParser('z').or(concreteCharParser('Z')).map { ZoneOffset.UTC }) +private val zoneOffsetParser: Parser + get() = (concreteCharParser('z').or(concreteCharParser('Z')).map { UtcOffset.ZERO }) .or( concreteCharParser('+').or(concreteCharParser('-')) .chain(intParser(2, 2)) @@ -54,10 +54,10 @@ private val zoneOffsetParser: Parser } try { if (sign == '-') - ZoneOffset.ofHoursMinutesSeconds(-hours, -minutes, -seconds) + UtcOffset.ofHoursMinutesSeconds(-hours, -minutes, -seconds) else - ZoneOffset.ofHoursMinutesSeconds(hours, minutes, seconds) - } catch (e: IllegalTimeZoneException) { + UtcOffset.ofHoursMinutesSeconds(hours, minutes, seconds) + } catch (e: IllegalArgumentException) { throw DateTimeFormatException(e) } } @@ -185,7 +185,7 @@ public actual class Instant internal constructor(public actual val epochSeconds: (epochSeconds xor (epochSeconds ushr 32)).toInt() + 51 * nanosecondsOfSecond // org.threeten.bp.format.DateTimeFormatterBuilder.InstantPrinterParser#print - actual override fun toString(): String = toStringWithOffset(ZoneOffset.UTC) + actual override fun toString(): String = toStringWithOffset(UtcOffset.ZERO) public actual companion object { internal actual val MIN = Instant(MIN_SECOND, 0) @@ -234,22 +234,30 @@ public actual class Instant internal constructor(public actual val epochSeconds: } -private fun Instant.toZonedLocalDateTimeFailing(zone: TimeZone): ZonedDateTime = try { - toZonedLocalDateTime(zone) +private fun Instant.toZonedDateTimeFailing(zone: TimeZone): ZonedDateTime = try { + toZonedDateTime(zone) } catch (e: IllegalArgumentException) { throw DateTimeArithmeticException("Can not convert instant $this to LocalDateTime to perform computations", e) } +/** + * @throws IllegalArgumentException if the [Instant] exceeds the boundaries of [LocalDateTime] + */ +private fun Instant.toZonedDateTime(zone: TimeZone): ZonedDateTime { + val currentOffset = zone.offsetAt(this) + return ZonedDateTime(toLocalDateTimeImpl(currentOffset), zone, currentOffset) +} + /** Check that [Instant] fits in [ZonedDateTime]. * This is done on the results of computations for consistency with other platforms. */ private fun Instant.check(zone: TimeZone): Instant = this@check.also { - toZonedLocalDateTimeFailing(zone) + toZonedDateTimeFailing(zone) } public actual fun Instant.plus(period: DateTimePeriod, timeZone: TimeZone): Instant = try { with(period) { - val withDate = toZonedLocalDateTimeFailing(timeZone) + val withDate = toZonedDateTimeFailing(timeZone) .run { if (totalMonths != 0) plus(totalMonths, DateTimeUnit.MONTH) else this } .run { if (days != 0) plus(days, DateTimeUnit.DAY) else this } withDate.toInstant() @@ -272,7 +280,7 @@ public actual fun Instant.plus(value: Long, unit: DateTimeUnit, timeZone: TimeZo is DateTimeUnit.DateBased -> { if (value < Int.MIN_VALUE || value > Int.MAX_VALUE) throw ArithmeticException("Can't add a Long date-based value, as it would cause an overflow") - toZonedLocalDateTimeFailing(timeZone).plus(value.toInt(), unit).toInstant() + toZonedDateTimeFailing(timeZone).plus(value.toInt(), unit).toInstant() } is DateTimeUnit.TimeBased -> check(timeZone).plus(value, unit).check(timeZone) @@ -296,8 +304,8 @@ public actual fun Instant.plus(value: Long, unit: DateTimeUnit.TimeBased): Insta @OptIn(ExperimentalTime::class) public actual fun Instant.periodUntil(other: Instant, timeZone: TimeZone): DateTimePeriod { - var thisLdt = toZonedLocalDateTimeFailing(timeZone) - val otherLdt = other.toZonedLocalDateTimeFailing(timeZone) + var thisLdt = toZonedDateTimeFailing(timeZone) + val otherLdt = other.toZonedDateTimeFailing(timeZone) val months = thisLdt.until(otherLdt, DateTimeUnit.MONTH).toInt() // `until` on dates never fails thisLdt = thisLdt.plus(months, DateTimeUnit.MONTH) // won't throw: thisLdt + months <= otherLdt, which is known to be valid @@ -311,7 +319,7 @@ public actual fun Instant.periodUntil(other: Instant, timeZone: TimeZone): DateT public actual fun Instant.until(other: Instant, unit: DateTimeUnit, timeZone: TimeZone): Long = when (unit) { is DateTimeUnit.DateBased -> - toZonedLocalDateTimeFailing(timeZone).dateTime.until(other.toZonedLocalDateTimeFailing(timeZone).dateTime, unit) + toZonedDateTimeFailing(timeZone).dateTime.until(other.toZonedDateTimeFailing(timeZone).dateTime, unit) .toLong() is DateTimeUnit.TimeBased -> { check(timeZone); other.check(timeZone) @@ -319,7 +327,7 @@ public actual fun Instant.until(other: Instant, unit: DateTimeUnit, timeZone: Ti } } -internal actual fun Instant.toStringWithOffset(offset: ZoneOffset): String { +internal actual fun Instant.toStringWithOffset(offset: UtcOffset): String { val buf = StringBuilder() val inNano: Int = nanosecondsOfSecond val seconds = epochSeconds + offset.totalSeconds @@ -378,6 +386,6 @@ internal actual fun Instant.toStringWithOffset(offset: ZoneOffset): String { } } } - buf.append(offset.id) + buf.append(offset) return buf.toString() } diff --git a/core/native/src/LocalDateTime.kt b/core/native/src/LocalDateTime.kt index b04180348..5c4c8400b 100644 --- a/core/native/src/LocalDateTime.kt +++ b/core/native/src/LocalDateTime.kt @@ -70,7 +70,7 @@ public actual class LocalDateTime internal constructor( actual override fun toString(): String = date.toString() + 'T' + time.toString() // org.threeten.bp.chrono.ChronoLocalDateTime#toEpochSecond - internal fun toEpochSecond(offset: ZoneOffsetImpl): Long { + internal fun toEpochSecond(offset: UtcOffset): Long { val epochDay = date.toEpochDay().toLong() var secs: Long = epochDay * 86400 + time.toSecondOfDay() secs -= offset.totalSeconds diff --git a/core/native/src/TimeZone.kt b/core/native/src/TimeZone.kt index 1d3e6014c..06887e469 100644 --- a/core/native/src/TimeZone.kt +++ b/core/native/src/TimeZone.kt @@ -8,10 +8,7 @@ package kotlinx.datetime -import kotlinx.datetime.serializers.TimeZoneSerializer -import kotlinx.datetime.serializers.ZoneOffsetSerializer -import kotlin.math.abs -import kotlin.native.concurrent.* +import kotlinx.datetime.serializers.* import kotlinx.serialization.Serializable @Serializable(with = TimeZoneSerializer::class) @@ -21,7 +18,7 @@ public actual open class TimeZone internal constructor(internal val value: TimeZ public actual fun currentSystemDefault(): TimeZone = PlatformTimeZoneImpl.currentSystemDefault().let(::TimeZone) - public actual val UTC: TimeZone = ZoneOffset.UTC + public actual val UTC: FixedOffsetTimeZone = UtcOffset.ZERO.asTimeZone() // org.threeten.bp.ZoneId#of(java.lang.String) public actual fun of(zoneId: String): TimeZone { @@ -32,22 +29,32 @@ public actual open class TimeZone internal constructor(internal val value: TimeZ if (zoneId.length == 1) { throw IllegalTimeZoneException("Invalid zone ID: $zoneId") } - if (zoneId.startsWith("+") || zoneId.startsWith("-")) { - return ZoneOffset.of(zoneId) - } - if (zoneId == "UTC" || zoneId == "GMT" || zoneId == "UT") { - return TimeZone(ZoneOffsetImpl(0, zoneId)) - } - if (zoneId.startsWith("UTC+") || zoneId.startsWith("GMT+") || - zoneId.startsWith("UTC-") || zoneId.startsWith("GMT-")) { - val offset = ZoneOffset.of(zoneId.substring(3)) - return (if (offset.totalSeconds == 0) ZoneOffsetImpl(0, zoneId.substring(0, 3)) - else ZoneOffsetImpl(offset.totalSeconds, zoneId.substring(0, 3) + offset.id)).let(::TimeZone) - } - if (zoneId.startsWith("UT+") || zoneId.startsWith("UT-")) { - val offset = ZoneOffset.of(zoneId.substring(2)) - return (if (offset.totalSeconds == 0) ZoneOffsetImpl(0, "UT") - else ZoneOffsetImpl(offset.totalSeconds, "UT" + offset.id)).let(::TimeZone) + try { + if (zoneId.startsWith("+") || zoneId.startsWith("-")) { + return UtcOffset.parse(zoneId).asTimeZone() + } + if (zoneId == "UTC" || zoneId == "GMT" || zoneId == "UT") { + return FixedOffsetTimeZone(UtcOffset.ZERO, zoneId) + } + if (zoneId.startsWith("UTC+") || zoneId.startsWith("GMT+") || + zoneId.startsWith("UTC-") || zoneId.startsWith("GMT-") + ) { + val prefix = zoneId.take(3) + val offset = UtcOffset.parse(zoneId.substring(3)) + return when (offset.totalSeconds) { + 0 -> FixedOffsetTimeZone(offset, prefix) + else -> FixedOffsetTimeZone(offset, "$prefix$offset") + } + } + if (zoneId.startsWith("UT+") || zoneId.startsWith("UT-")) { + val offset = UtcOffset.parse(zoneId.substring(2)) + return when (offset.totalSeconds) { + 0 -> FixedOffsetTimeZone(offset, "UT") + else -> FixedOffsetTimeZone(offset, "UT$offset") + } + } + } catch (e: DateTimeFormatException) { + throw IllegalTimeZoneException(e) } return TimeZone(PlatformTimeZoneImpl.of(zoneId)) } @@ -59,18 +66,22 @@ public actual open class TimeZone internal constructor(internal val value: TimeZ public actual val id: String get() = value.id - public actual fun Instant.toLocalDateTime(): LocalDateTime = try { - toZonedLocalDateTime(this@TimeZone).dateTime + public actual fun Instant.toLocalDateTime(): LocalDateTime = instantToLocalDateTime(this) + public actual fun LocalDateTime.toInstant(): Instant = localDateTimeToInstant(this) + + internal open fun atStartOfDay(date: LocalDate): Instant = value.atStartOfDay(date) + + internal open fun instantToLocalDateTime(instant: Instant): LocalDateTime = try { + instant.toLocalDateTimeImpl(offsetAt(instant)) } catch (e: IllegalArgumentException) { - throw DateTimeArithmeticException("Instant ${this@toLocalDateTime} is not representable as LocalDateTime", e) + throw DateTimeArithmeticException("Instant $instant is not representable as LocalDateTime.", e) } - public actual fun LocalDateTime.toInstant(): Instant = atZone().toInstant() - - internal open fun atStartOfDay(date: LocalDate): Instant = value.atStartOfDay(date) + internal open fun localDateTimeToInstant(dateTime: LocalDateTime): Instant = + atZone(dateTime).toInstant() - internal open fun LocalDateTime.atZone(preferred: ZoneOffsetImpl? = null): ZonedDateTime = - with(value) { atZone(preferred) } + internal open fun atZone(dateTime: LocalDateTime, preferred: UtcOffset? = null): ZonedDateTime = + value.atZone(dateTime, preferred) override fun equals(other: Any?): Boolean = this === other || other is TimeZone && this.value == other.value @@ -80,137 +91,46 @@ public actual open class TimeZone internal constructor(internal val value: TimeZ override fun toString(): String = value.toString() } -@ThreadLocal -private var zoneOffsetCache: MutableMap = mutableMapOf() -@Serializable(with = ZoneOffsetSerializer::class) -public actual class ZoneOffset internal constructor(internal val offset: ZoneOffsetImpl) : TimeZone(offset) { +@Serializable(with = FixedOffsetTimeZoneSerializer::class) +public actual class FixedOffsetTimeZone internal constructor(public actual val offset: UtcOffset, id: String) : TimeZone(ZoneOffsetImpl(offset, id)) { - public actual val totalSeconds: Int get() = offset.totalSeconds - - public companion object { - internal val UTC: ZoneOffset = ZoneOffset(ZoneOffsetImpl.UTC) + public actual constructor(offset: UtcOffset) : this(offset, offset.toString()) - // org.threeten.bp.ZoneOffset#of - internal fun of(offsetId: String): ZoneOffset { - if (offsetId == "Z") { - return UTC - } + @Deprecated("Use offset.totalSeconds", ReplaceWith("offset.totalSeconds")) + public actual val totalSeconds: Int get() = offset.totalSeconds - // parse - +h, +hh, +hhmm, +hh:mm, +hhmmss, +hh:mm:ss - val hours: Int - val minutes: Int - val seconds: Int - when (offsetId.length) { - 2 -> return of(offsetId[0].toString() + "0" + offsetId[1]) - 3 -> { - hours = parseNumber(offsetId, 1, false) - minutes = 0 - seconds = 0 - } - 5 -> { - hours = parseNumber(offsetId, 1, false) - minutes = parseNumber(offsetId, 3, false) - seconds = 0 - } - 6 -> { - hours = parseNumber(offsetId, 1, false) - minutes = parseNumber(offsetId, 4, true) - seconds = 0 - } - 7 -> { - hours = parseNumber(offsetId, 1, false) - minutes = parseNumber(offsetId, 3, false) - seconds = parseNumber(offsetId, 5, false) - } - 9 -> { - hours = parseNumber(offsetId, 1, false) - minutes = parseNumber(offsetId, 4, true) - seconds = parseNumber(offsetId, 7, true) - } - else -> throw IllegalTimeZoneException("Invalid ID for ZoneOffset, invalid format: $offsetId") - } - val first: Char = offsetId[0] - if (first != '+' && first != '-') { - throw IllegalTimeZoneException( - "Invalid ID for ZoneOffset, plus/minus not found when expected: $offsetId") - } - return if (first == '-') { - ofHoursMinutesSeconds(-hours, -minutes, -seconds) - } else { - ofHoursMinutesSeconds(hours, minutes, seconds) - } - } + override fun instantToLocalDateTime(instant: Instant): LocalDateTime = instant.toLocalDateTime(offset) + override fun localDateTimeToInstant(dateTime: LocalDateTime): Instant = dateTime.toInstant(offset) +} - // org.threeten.bp.ZoneOffset#validate - private fun validate(hours: Int, minutes: Int, seconds: Int) { - if (hours < -18 || hours > 18) { - throw IllegalTimeZoneException("Zone offset hours not in valid range: value " + hours + - " is not in the range -18 to 18") - } - if (hours > 0) { - if (minutes < 0 || seconds < 0) { - throw IllegalTimeZoneException("Zone offset minutes and seconds must be positive because hours is positive") - } - } else if (hours < 0) { - if (minutes > 0 || seconds > 0) { - throw IllegalTimeZoneException("Zone offset minutes and seconds must be negative because hours is negative") - } - } else if (minutes > 0 && seconds < 0 || minutes < 0 && seconds > 0) { - throw IllegalTimeZoneException("Zone offset minutes and seconds must have the same sign") - } - if (abs(minutes) > 59) { - throw IllegalTimeZoneException("Zone offset minutes not in valid range: abs(value) " + - abs(minutes) + " is not in the range 0 to 59") - } - if (abs(seconds) > 59) { - throw IllegalTimeZoneException("Zone offset seconds not in valid range: abs(value) " + - abs(seconds) + " is not in the range 0 to 59") - } - if (abs(hours) == 18 && (abs(minutes) > 0 || abs(seconds) > 0)) { - throw IllegalTimeZoneException("Zone offset not in valid range: -18:00 to +18:00") - } - } - // org.threeten.bp.ZoneOffset#ofHoursMinutesSeconds - internal fun ofHoursMinutesSeconds(hours: Int, minutes: Int, seconds: Int): ZoneOffset { - validate(hours, minutes, seconds) - return if (hours == 0 && minutes == 0 && seconds == 0) UTC - else ofSeconds(hours * SECONDS_PER_HOUR + minutes * SECONDS_PER_MINUTE + seconds) - } +public actual fun TimeZone.offsetAt(instant: Instant): UtcOffset = + value.offsetAt(instant) - // org.threeten.bp.ZoneOffset#ofTotalSeconds - internal fun ofSeconds(seconds: Int): ZoneOffset = - if (seconds % (15 * SECONDS_PER_MINUTE) == 0) { - zoneOffsetCache[seconds] ?: - ZoneOffset(ZoneOffsetImpl(seconds, zoneIdByOffset(seconds))).also { zoneOffsetCache[seconds] = it } - } else { - ZoneOffset(ZoneOffsetImpl(seconds, zoneIdByOffset(seconds))) - } +public actual fun Instant.toLocalDateTime(timeZone: TimeZone): LocalDateTime = + timeZone.instantToLocalDateTime(this) - // org.threeten.bp.ZoneOffset#parseNumber - private fun parseNumber(offsetId: CharSequence, pos: Int, precededByColon: Boolean): Int { - if (precededByColon && offsetId[pos - 1] != ':') { - throw IllegalTimeZoneException("Invalid ID for ZoneOffset, colon not found when expected: $offsetId") - } - val ch1 = offsetId[pos] - val ch2 = offsetId[pos + 1] - if (ch1 < '0' || ch1 > '9' || ch2 < '0' || ch2 > '9') { - throw IllegalTimeZoneException("Invalid ID for ZoneOffset, non numeric characters found: $offsetId") - } - return (ch1 - '0') * 10 + (ch2 - '0') - } - } +internal actual fun Instant.toLocalDateTime(offset: UtcOffset): LocalDateTime = try { + toLocalDateTimeImpl(offset) +} catch (e: IllegalArgumentException) { + throw DateTimeArithmeticException("Instant ${this@toLocalDateTime} is not representable as LocalDateTime", e) } -public actual fun TimeZone.offsetAt(instant: Instant): ZoneOffset = - value.offsetAt(instant).let(::ZoneOffset) - -public actual fun Instant.toLocalDateTime(timeZone: TimeZone): LocalDateTime = - with(timeZone) { toLocalDateTime() } +internal fun Instant.toLocalDateTimeImpl(offset: UtcOffset): LocalDateTime { + val localSecond: Long = epochSeconds + offset.totalSeconds // overflow caught later + val localEpochDay = floorDiv(localSecond, SECONDS_PER_DAY.toLong()).toInt() + val secsOfDay = floorMod(localSecond, SECONDS_PER_DAY.toLong()).toInt() + val date: LocalDate = LocalDate.ofEpochDay(localEpochDay) // may throw + val time: LocalTime = LocalTime.ofSecondOfDay(secsOfDay, nanosecondsOfSecond) + return LocalDateTime(date, time) +} public actual fun LocalDateTime.toInstant(timeZone: TimeZone): Instant = - with(timeZone) { toInstant() } + timeZone.localDateTimeToInstant(this) + +public actual fun LocalDateTime.toInstant(offset: UtcOffset): Instant = + Instant(this.toEpochSecond(offset), this.nanosecond) public actual fun LocalDate.atStartOfDayIn(timeZone: TimeZone): Instant = - timeZone.atStartOfDay(this) + timeZone.atStartOfDay(this) diff --git a/core/native/src/TimeZoneImpl.kt b/core/native/src/TimeZoneImpl.kt index 66354ebff..203033866 100644 --- a/core/native/src/TimeZoneImpl.kt +++ b/core/native/src/TimeZoneImpl.kt @@ -7,8 +7,8 @@ package kotlinx.datetime internal interface TimeZoneImpl { val id: String fun atStartOfDay(date: LocalDate): Instant - fun LocalDateTime.atZone(preferred: ZoneOffsetImpl?): ZonedDateTime - fun offsetAt(instant: Instant): ZoneOffsetImpl + fun atZone(dateTime: LocalDateTime, preferred: UtcOffset?): ZonedDateTime + fun offsetAt(instant: Instant): UtcOffset } internal expect class PlatformTimeZoneImpl: TimeZoneImpl { @@ -19,29 +19,24 @@ internal expect class PlatformTimeZoneImpl: TimeZoneImpl { } } -internal class ZoneOffsetImpl(val totalSeconds: Int, override val id: String): TimeZoneImpl { - - companion object { - // org.threeten.bp.ZoneOffset#UTC - val UTC = ZoneOffsetImpl(0, "Z") - } +internal class ZoneOffsetImpl(val utcOffset: UtcOffset, override val id: String): TimeZoneImpl { override fun atStartOfDay(date: LocalDate): Instant = - LocalDateTime(date, LocalTime.MIN).atZone(null).toInstant() + LocalDateTime(date, LocalTime.MIN).toInstant(utcOffset) - override fun LocalDateTime.atZone(preferred: ZoneOffsetImpl?): ZonedDateTime { - return ZonedDateTime(this@atZone, ZoneOffset(this@ZoneOffsetImpl), this@ZoneOffsetImpl) + override fun atZone(dateTime: LocalDateTime, preferred: UtcOffset?): ZonedDateTime { + return ZonedDateTime(dateTime, utcOffset.asTimeZone(), utcOffset) } - override fun offsetAt(instant: Instant): ZoneOffsetImpl = this + override fun offsetAt(instant: Instant): UtcOffset = utcOffset // org.threeten.bp.ZoneOffset#toString override fun toString(): String = id // org.threeten.bp.ZoneOffset#hashCode - override fun hashCode(): Int = totalSeconds + override fun hashCode(): Int = utcOffset.hashCode() // org.threeten.bp.ZoneOffset#equals override fun equals(other: Any?): Boolean = - this === other || other is ZoneOffsetImpl && totalSeconds == other.totalSeconds + this === other || other is ZoneOffsetImpl && id == other.id } \ No newline at end of file diff --git a/core/native/src/UtcOffset.kt b/core/native/src/UtcOffset.kt new file mode 100644 index 000000000..19873dc99 --- /dev/null +++ b/core/native/src/UtcOffset.kt @@ -0,0 +1,161 @@ +/* + * Copyright 2019-2021 JetBrains s.r.o. + * 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 kotlinx.datetime.serializers.UtcOffsetSerializer +import kotlinx.serialization.Serializable +import kotlin.math.abs +import kotlin.native.concurrent.ThreadLocal + +@Serializable(with = UtcOffsetSerializer::class) +public actual class UtcOffset private constructor(public actual val totalSeconds: Int) { + private val id: String = zoneIdByOffset(totalSeconds) + + override fun hashCode(): Int = totalSeconds + override fun equals(other: Any?): Boolean = other is UtcOffset && this.totalSeconds == other.totalSeconds + override fun toString(): String = id + + public actual companion object { + + public actual val ZERO: UtcOffset = UtcOffset(totalSeconds = 0) + + public actual fun parse(offsetString: String): UtcOffset { + if (offsetString == "Z") { + return ZERO + } + + // parse - +h, +hh, +hhmm, +hh:mm, +hhmmss, +hh:mm:ss + val hours: Int + val minutes: Int + val seconds: Int + when (offsetString.length) { + 2 -> return parse(offsetString[0].toString() + "0" + offsetString[1]) + 3 -> { + hours = parseNumber(offsetString, 1, false) + minutes = 0 + seconds = 0 + } + 5 -> { + hours = parseNumber(offsetString, 1, false) + minutes = parseNumber(offsetString, 3, false) + seconds = 0 + } + 6 -> { + hours = parseNumber(offsetString, 1, false) + minutes = parseNumber(offsetString, 4, true) + seconds = 0 + } + 7 -> { + hours = parseNumber(offsetString, 1, false) + minutes = parseNumber(offsetString, 3, false) + seconds = parseNumber(offsetString, 5, false) + } + 9 -> { + hours = parseNumber(offsetString, 1, false) + minutes = parseNumber(offsetString, 4, true) + seconds = parseNumber(offsetString, 7, true) + } + else -> throw DateTimeFormatException("Invalid ID for UtcOffset, invalid format: $offsetString") + } + val first: Char = offsetString[0] + if (first != '+' && first != '-') { + throw DateTimeFormatException( + "Invalid ID for UtcOffset, plus/minus not found when expected: $offsetString") + } + try { + return if (first == '-') { + ofHoursMinutesSeconds(-hours, -minutes, -seconds) + } else { + ofHoursMinutesSeconds(hours, minutes, seconds) + } + } catch (e: IllegalArgumentException) { + throw DateTimeFormatException(e) + } + } + + private fun validateTotal(totalSeconds: Int) { + if (totalSeconds !in -18 * SECONDS_PER_HOUR .. 18 * SECONDS_PER_HOUR) { + throw IllegalArgumentException("Total seconds value is out of range: $totalSeconds") + } + } + + // org.threeten.bp.ZoneOffset#validate + private fun validate(hours: Int, minutes: Int, seconds: Int) { + if (hours < -18 || hours > 18) { + throw IllegalArgumentException("Zone offset hours not in valid range: value " + hours + + " is not in the range -18 to 18") + } + if (hours > 0) { + if (minutes < 0 || seconds < 0) { + throw IllegalArgumentException("Zone offset minutes and seconds must be positive because hours is positive") + } + } else if (hours < 0) { + if (minutes > 0 || seconds > 0) { + throw IllegalArgumentException("Zone offset minutes and seconds must be negative because hours is negative") + } + } else if (minutes > 0 && seconds < 0 || minutes < 0 && seconds > 0) { + throw IllegalArgumentException("Zone offset minutes and seconds must have the same sign") + } + if (abs(minutes) > 59) { + throw IllegalArgumentException("Zone offset minutes not in valid range: abs(value) " + + abs(minutes) + " is not in the range 0 to 59") + } + if (abs(seconds) > 59) { + throw IllegalArgumentException("Zone offset seconds not in valid range: abs(value) " + + abs(seconds) + " is not in the range 0 to 59") + } + if (abs(hours) == 18 && (abs(minutes) > 0 || abs(seconds) > 0)) { + throw IllegalArgumentException("Utc offset not in valid range: -18:00 to +18:00") + } + } + + // org.threeten.bp.ZoneOffset#ofHoursMinutesSeconds + internal fun ofHoursMinutesSeconds(hours: Int, minutes: Int, seconds: Int): UtcOffset { + validate(hours, minutes, seconds) + return if (hours == 0 && minutes == 0 && seconds == 0) ZERO + else ofSeconds(hours * SECONDS_PER_HOUR + minutes * SECONDS_PER_MINUTE + seconds) + } + + // org.threeten.bp.ZoneOffset#ofTotalSeconds + internal fun ofSeconds(seconds: Int): UtcOffset { + validateTotal(seconds) + return if (seconds % (15 * SECONDS_PER_MINUTE) == 0) { + utcOffsetCache[seconds] ?: UtcOffset(totalSeconds = seconds).also { utcOffsetCache[seconds] = it } + } else { + UtcOffset(totalSeconds = seconds) + } + } + + // org.threeten.bp.ZoneOffset#parseNumber + private fun parseNumber(offsetId: CharSequence, pos: Int, precededByColon: Boolean): Int { + if (precededByColon && offsetId[pos - 1] != ':') { + throw DateTimeFormatException("Invalid ID for UtcOffset, colon not found when expected: $offsetId") + } + val ch1 = offsetId[pos] + val ch2 = offsetId[pos + 1] + if (ch1 < '0' || ch1 > '9' || ch2 < '0' || ch2 > '9') { + throw DateTimeFormatException("Invalid ID for UtcOffset, non numeric characters found: $offsetId") + } + return (ch1 - '0') * 10 + (ch2 - '0') + } + } +} + +@ThreadLocal +private var utcOffsetCache: MutableMap = mutableMapOf() + +@Suppress("ACTUAL_FUNCTION_WITH_DEFAULT_ARGUMENTS") +public actual fun UtcOffset(hours: Int? = null, minutes: Int? = null, seconds: Int? = null): UtcOffset = + when { + hours != null -> + UtcOffset.ofHoursMinutesSeconds(hours, minutes ?: 0, seconds ?: 0) + minutes != null -> + UtcOffset.ofHoursMinutesSeconds(minutes / MINUTES_PER_HOUR, minutes % MINUTES_PER_HOUR, seconds ?: 0) + else -> { + UtcOffset.ofSeconds(seconds ?: 0) + } + } + diff --git a/core/native/src/ZonedDateTime.kt b/core/native/src/ZonedDateTime.kt index 86b40de6a..f19ac49ad 100644 --- a/core/native/src/ZonedDateTime.kt +++ b/core/native/src/ZonedDateTime.kt @@ -8,7 +8,7 @@ package kotlinx.datetime -internal class ZonedDateTime(val dateTime: LocalDateTime, private val zone: TimeZone, val offset: ZoneOffsetImpl) { +internal class ZonedDateTime(val dateTime: LocalDateTime, private val zone: TimeZone, val offset: UtcOffset) { /** * @throws IllegalArgumentException if the result exceeds the boundaries * @throws ArithmeticException if arithmetic overflow occurs @@ -18,13 +18,13 @@ internal class ZonedDateTime(val dateTime: LocalDateTime, private val zone: Time // Never throws in practice private fun LocalDateTime.resolve(): ZonedDateTime = // workaround for https://github.com/Kotlin/kotlinx-datetime/issues/51 - if (with(offset) { atZone(null).toInstant() }.toLocalDateTime(zone) == this@resolve) { + if (this@resolve.toInstant(offset).toLocalDateTime(zone) == this@resolve) { // this LocalDateTime is valid in these timezone and offset. ZonedDateTime(this, zone, offset) } else { // this LDT does need proper resolving, as the instant that it would map to given the preferred offset // is is mapped to another LDT. - with(zone) { atZone(offset) } + zone.atZone(this, offset) } override fun equals(other: Any?): Boolean = @@ -38,7 +38,7 @@ internal class ZonedDateTime(val dateTime: LocalDateTime, private val zone: Time override fun toString(): String { var str = dateTime.toString() + offset.toString() - if (offset !== zone.value) { + if (zone !is FixedOffsetTimeZone || offset !== zone.offset) { str += "[$zone]" } return str @@ -48,19 +48,6 @@ internal class ZonedDateTime(val dateTime: LocalDateTime, private val zone: Time internal fun ZonedDateTime.toInstant(): Instant = Instant(dateTime.toEpochSecond(offset), dateTime.nanosecond) -// org.threeten.bp.LocalDateTime#ofEpochSecond + org.threeten.bp.ZonedDateTime#create -/** - * @throws IllegalArgumentException if the [Instant] exceeds the boundaries of [LocalDateTime] - */ -internal fun Instant.toZonedLocalDateTime(zone: TimeZone): ZonedDateTime { - val currentOffset = zone.value.offsetAt(this) - val localSecond: Long = epochSeconds + currentOffset.totalSeconds // overflow caught later - val localEpochDay = floorDiv(localSecond, SECONDS_PER_DAY.toLong()).toInt() - val secsOfDay = floorMod(localSecond, SECONDS_PER_DAY.toLong()).toInt() - val date: LocalDate = LocalDate.ofEpochDay(localEpochDay) // may throw - val time: LocalTime = LocalTime.ofSecondOfDay(secsOfDay, nanosecondsOfSecond) - return ZonedDateTime(LocalDateTime(date, time), zone, currentOffset) -} // org.threeten.bp.ZonedDateTime#until // This version is simplified and to be used ONLY in case you know the timezones are equal! diff --git a/core/native/test/ThreeTenBpLocalDateTimeTest.kt b/core/native/test/ThreeTenBpLocalDateTimeTest.kt index 8c423c7ba..d1b7e0971 100644 --- a/core/native/test/ThreeTenBpLocalDateTimeTest.kt +++ b/core/native/test/ThreeTenBpLocalDateTimeTest.kt @@ -15,10 +15,10 @@ class ThreeTenBpLocalDateTimeTest { fun toSecondsAfterEpoch() { for (i in -5..4) { val iHours = i * 3600 - val offset = ZoneOffset.ofSeconds(iHours) + val offset = UtcOffset(seconds = iHours) for (j in 0..99999) { val a = LocalDateTime(1970, 1, 1, 0, 0, 0, 0).plusSeconds(j) - assertEquals((j - iHours).toLong(), a.toEpochSecond(offset.offset)) + assertEquals((j - iHours).toLong(), a.toEpochSecond(offset)) } } } @@ -27,7 +27,7 @@ class ThreeTenBpLocalDateTimeTest { fun toSecondsBeforeEpoch() { for (i in 0..99999) { val a = LocalDateTime(1970, 1, 1, 0, 0, 0, 0).plusSeconds(-i) - assertEquals(-i.toLong(), a.toEpochSecond(ZoneOffsetImpl.UTC)) + assertEquals(-i.toLong(), a.toEpochSecond(UtcOffset.ZERO)) } } diff --git a/core/native/test/ThreeTenBpTimeZoneTest.kt b/core/native/test/ThreeTenBpTimeZoneTest.kt index a6cf02056..ae2eb10e7 100644 --- a/core/native/test/ThreeTenBpTimeZoneTest.kt +++ b/core/native/test/ThreeTenBpTimeZoneTest.kt @@ -18,16 +18,6 @@ import kotlin.test.* */ class ThreeTenBpTimeZoneTest { - @Test - fun zoneOffsetToString() { - var offset: ZoneOffset = ZoneOffset.ofHoursMinutesSeconds(1, 0, 0) - assertEquals("+01:00", offset.toString()) - offset = ZoneOffset.ofHoursMinutesSeconds(1, 2, 3) - assertEquals("+01:02:03", offset.toString()) - offset = ZoneOffset.UTC - assertEquals("Z", offset.toString()) - } - @Test fun utcIsCached() { val values = arrayOf( @@ -35,90 +25,9 @@ class ThreeTenBpTimeZoneTest { "+00", "+0000", "+00:00", "+000000", "+00:00:00", "-00", "-0000", "-00:00", "-000000", "-00:00:00") for (v in values) { - val test = ZoneOffset.of(v) - assertSame(test, ZoneOffset.UTC) - } - } - - @Test - fun invalidZoneOffsetNames() { - val values = arrayOf( - "", "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", - "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "ZZ", - "0", "+0:00", "+00:0", "+0:0", - "+000", "+00000", - "+0:00:00", "+00:0:00", "+00:00:0", "+0:0:0", "+0:0:00", "+00:0:0", "+0:00:0", - "1", "+01_00", "+01;00", "+01@00", "+01:AA", - "+19", "+19:00", "+18:01", "+18:00:01", "+1801", "+180001", - "-0:00", "-00:0", "-0:0", - "-000", "-00000", - "-0:00:00", "-00:0:00", "-00:00:0", "-0:0:0", "-0:0:00", "-00:0:0", "-0:00:0", - "-19", "-19:00", "-18:01", "-18:00:01", "-1801", "-180001", - "-01_00", "-01;00", "-01@00", "-01:AA", - "@01:00") - for (v in values) { - assertFailsWith(IllegalTimeZoneException::class, "should fail: $v") { ZoneOffset.of(v) } - } - } - - private fun zoneOffsetCheck(offset: ZoneOffset, hours: Int, minutes: Int, seconds: Int) { - assertEquals(offset.totalSeconds, hours * 60 * 60 + minutes * 60 + seconds) - val id: String - if (hours == 0 && minutes == 0 && seconds == 0) { - id = "Z" - } else { - var str = if (hours < 0 || minutes < 0 || seconds < 0) "-" else "+" - str += (abs(hours) + 100).toString().substring(1) - str += ":" - str += (abs(minutes) + 100).toString().substring(1) - if (seconds != 0) { - str += ":" - str += (abs(seconds) + 100).toString().substring(1) - } - id = str - } - assertEquals(id, offset.id) - assertEquals(ZoneOffset.ofHoursMinutesSeconds(hours, minutes, seconds), offset) - assertEquals(offset, ZoneOffset.of(id)) - assertEquals(id, offset.toString()) - } - - @Test - fun zoneOffsetEquals() { - val offset1 = ZoneOffset.ofHoursMinutesSeconds(1, 2, 3) - val offset2 = ZoneOffset.ofHoursMinutesSeconds(2, 3, 4) - val offset2b = ZoneOffset.ofHoursMinutesSeconds(2, 3, 4) - assertEquals(false, offset1 == offset2) - assertEquals(false, offset2 == offset1) - assertEquals(true, offset1 == offset1) - assertEquals(true, offset2 == offset2) - assertEquals(true, offset2 == offset2b) - assertEquals(offset1.hashCode(), offset1.hashCode()) - assertEquals(offset2.hashCode(), offset2.hashCode()) - assertEquals(offset2.hashCode(), offset2b.hashCode()) - } - - @Test - fun zoneOffsetParsingFullForm() { - for (i in -17..17) { - for (j in -59..59) { - for (k in -59..59) { - if (i < 0 && j <= 0 && k <= 0 || i > 0 && j >= 0 && k >= 0 || - i == 0 && (j < 0 && k <= 0 || j > 0 && k >= 0 || j == 0)) { - val str = (if (i < 0 || j < 0 || k < 0) "-" else "+") + - (abs(i) + 100).toString().substring(1) + ":" + - (abs(j) + 100).toString().substring(1) + ":" + - (abs(k) + 100).toString().substring(1) - val test = ZoneOffset.of(str) - zoneOffsetCheck(test, i, j, k) - } - } - } + val test = UtcOffset.parse(v) + assertSame(test, UtcOffset.ZERO) } - val test1 = ZoneOffset.of("-18:00:00") - zoneOffsetCheck(test1, -18, 0, 0) - val test2 = ZoneOffset.of("+18:00:00") - zoneOffsetCheck(test2, 18, 0, 0) } @Test @@ -126,7 +35,7 @@ class ThreeTenBpTimeZoneTest { val t1 = LocalDateTime(2020, 3, 29, 2, 14, 17, 201) val t2 = LocalDateTime(2020, 3, 29, 3, 14, 17, 201) val tz = TimeZone.of("Europe/Berlin") - assertEquals(with(tz) { t1.atZone() }, with(tz) { t2.atZone() }) + assertEquals(tz.atZone(t1), tz.atZone(t2)) } @Test @@ -134,7 +43,7 @@ class ThreeTenBpTimeZoneTest { val t = LocalDateTime(2007, 10, 28, 2, 30, 0, 0) val zone = TimeZone.of("Europe/Paris") assertEquals(ZonedDateTime(LocalDateTime(2007, 10, 28, 2, 30, 0, 0), - zone, ZoneOffset.ofSeconds(2 * 3600).offset), with(zone) { t.atZone() }) + zone, UtcOffset(seconds = 2 * 3600)), zone.atZone(t)) } } diff --git a/serialization/common/test/TimeZoneSerializationTest.kt b/serialization/common/test/TimeZoneSerializationTest.kt index 9cb0f5058..f4062b0b9 100644 --- a/serialization/common/test/TimeZoneSerializationTest.kt +++ b/serialization/common/test/TimeZoneSerializationTest.kt @@ -14,8 +14,8 @@ import kotlin.test.* class TimeZoneSerializationTest { - private fun zoneOffsetSerialization(serializer: KSerializer) { - val offset2h = TimeZone.of("+02:00") as ZoneOffset + private fun zoneOffsetSerialization(serializer: KSerializer) { + val offset2h = TimeZone.of("+02:00") as FixedOffsetTimeZone assertEquals("\"+02:00\"", Json.encodeToString(serializer, offset2h)) assertEquals(offset2h, Json.decodeFromString(serializer, "\"+02:00\"")) assertEquals(offset2h, Json.decodeFromString(serializer, "\"+02\"")) @@ -36,7 +36,7 @@ class TimeZoneSerializationTest { @Test fun testZoneOffsetSerialization() { - zoneOffsetSerialization(ZoneOffsetSerializer) + zoneOffsetSerialization(FixedOffsetTimeZoneSerializer) } @Test diff --git a/serialization/common/test/UtcOffsetSerializationTest.kt b/serialization/common/test/UtcOffsetSerializationTest.kt new file mode 100644 index 000000000..93d474f3b --- /dev/null +++ b/serialization/common/test/UtcOffsetSerializationTest.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2019-2021 JetBrains s.r.o. + * 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.serialization.test + +import kotlinx.datetime.* +import kotlinx.datetime.serializers.* +import kotlinx.serialization.KSerializer +import kotlinx.serialization.json.* +import kotlinx.serialization.serializer +import kotlin.test.* + +class UtcOffsetSerializationTest { + + private fun testSerializationAsPrimitive(serializer: KSerializer) { + val offset2h = UtcOffset.parse("+2") + assertEquals("\"+02:00\"", Json.encodeToString(serializer, offset2h)) + assertEquals(offset2h, Json.decodeFromString(serializer, "\"+02:00\"")) + assertEquals(offset2h, Json.decodeFromString(serializer, "\"+02\"")) + assertEquals(offset2h, Json.decodeFromString(serializer, "\"+2\"")) + + assertFailsWith { + Json.decodeFromString(serializer, "\"UTC+02:00\"") // not an offset + } + } + + @Test + fun defaultSerializer() { + testSerializationAsPrimitive(Json.serializersModule.serializer()) + } + + @Test + fun stringPrimitiveSerializer() { + testSerializationAsPrimitive(UtcOffsetSerializer) + testSerializationAsPrimitive(UtcOffset.serializer()) + } +} \ No newline at end of file