Skip to content

Commit 53f0224

Browse files
committed
Make UtcOffsets with the same offset value indistinguishable
On the contrary, two FixedOffsetTimeZones with the same offset can differ by id (this aspect is native-only for now).
1 parent 498bf5c commit 53f0224

File tree

4 files changed

+36
-26
lines changed

4 files changed

+36
-26
lines changed

core/common/test/TimeZoneTest.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,16 @@ class TimeZoneTest {
9999
}
100100
}
101101

102+
@Test
103+
fun utcOffsetNormalization() {
104+
val sameOffsetTZs = listOf("+04", "+04:00", "UTC+4", "UT+04", "GMT+04:00:00").map { TimeZone.of(it) }
105+
val instant = Instant.fromEpochSeconds(0)
106+
val offsets = sameOffsetTZs.map { it.offsetAt(instant) }
107+
108+
assertTrue(offsets.distinct().size == 1, "Expected all offsets to be equal: $offsets")
109+
assertTrue(offsets.map { it.toString() }.distinct().size == 1, "Expected all offsets to have the same string representation: $offsets")
110+
}
111+
102112
// from 310bp
103113
@Test
104114
fun newYorkOffset() {

core/native/src/TimeZone.kt

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,19 +33,23 @@ public actual open class TimeZone internal constructor(internal val value: TimeZ
3333
return UtcOffset.parse(zoneId).asTimeZone()
3434
}
3535
if (zoneId == "UTC" || zoneId == "GMT" || zoneId == "UT") {
36-
// TODO: Should we allow non-normalized zone ids in UtcOffset id?
37-
return UtcOffset(0, zoneId).asTimeZone()
36+
return FixedOffsetTimeZone(UtcOffset(0), zoneId)
3837
}
3938
if (zoneId.startsWith("UTC+") || zoneId.startsWith("GMT+") ||
4039
zoneId.startsWith("UTC-") || zoneId.startsWith("GMT-")) {
40+
val prefix = zoneId.take(3)
4141
val offset = UtcOffset.parse(zoneId.substring(3))
42-
return (if (offset.totalSeconds == 0) UtcOffset(0, zoneId.substring(0, 3))
43-
else UtcOffset(offset.totalSeconds, zoneId.substring(0, 3) + offset.id)).asTimeZone()
42+
return when (offset.totalSeconds) {
43+
0 -> FixedOffsetTimeZone(offset, prefix)
44+
else -> FixedOffsetTimeZone(offset, "$prefix$offset")
45+
}
4446
}
4547
if (zoneId.startsWith("UT+") || zoneId.startsWith("UT-")) {
4648
val offset = UtcOffset.parse(zoneId.substring(2))
47-
return (if (offset.totalSeconds == 0) UtcOffset(0, "UT")
48-
else UtcOffset(offset.totalSeconds, "UT" + offset.id)).asTimeZone()
49+
return when (offset.totalSeconds) {
50+
0 -> FixedOffsetTimeZone(offset, "UT")
51+
else -> FixedOffsetTimeZone(offset, "UT$offset")
52+
}
4953
}
5054
return TimeZone(PlatformTimeZoneImpl.of(zoneId))
5155
}
@@ -80,7 +84,9 @@ public actual open class TimeZone internal constructor(internal val value: TimeZ
8084

8185

8286
@Serializable(with = FixedOffsetTimeZoneSerializer::class)
83-
public actual class FixedOffsetTimeZone actual constructor(public actual val utcOffset: UtcOffset) : TimeZone(ZoneOffsetImpl(utcOffset, utcOffset.id)) {
87+
public actual class FixedOffsetTimeZone internal constructor(public actual val utcOffset: UtcOffset, id: String) : TimeZone(ZoneOffsetImpl(utcOffset, id)) {
88+
89+
public actual constructor(utcOffset: UtcOffset) : this(utcOffset, utcOffset.toString())
8490

8591
@Deprecated("Use utcOffset.totalSeconds", ReplaceWith("utcOffset.totalSeconds"))
8692
public actual val totalSeconds: Int get() = utcOffset.totalSeconds

core/native/src/TimeZoneImpl.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,5 +38,5 @@ internal class ZoneOffsetImpl(val utcOffset: UtcOffset, override val id: String)
3838

3939
// org.threeten.bp.ZoneOffset#equals
4040
override fun equals(other: Any?): Boolean =
41-
this === other || other is ZoneOffsetImpl && utcOffset == other.utcOffset
41+
this === other || other is ZoneOffsetImpl && id == other.id
4242
}

core/native/src/UtcOffset.kt

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,16 @@ package kotlinx.datetime
88
import kotlin.math.abs
99
import kotlin.native.concurrent.ThreadLocal
1010

11-
public actual class UtcOffset internal constructor(public actual val totalSeconds: Int, internal val id: String) {
11+
public actual class UtcOffset internal constructor(public actual val totalSeconds: Int) {
12+
private val id: String = zoneIdByOffset(totalSeconds)
1213

1314
override fun hashCode(): Int = totalSeconds
1415
override fun equals(other: Any?): Boolean = other is UtcOffset && this.totalSeconds == other.totalSeconds
1516
override fun toString(): String = id
1617

1718
public actual companion object {
1819

19-
internal val ZERO: UtcOffset = UtcOffset(0, "Z")
20+
internal val ZERO: UtcOffset = UtcOffset(0)
2021

2122
public actual fun parse(offsetString: String): UtcOffset {
2223
if (offsetString == "Z") {
@@ -59,8 +60,7 @@ public actual class UtcOffset internal constructor(public actual val totalSecond
5960
val first: Char = offsetString[0]
6061
if (first != '+' && first != '-') {
6162
throw IllegalTimeZoneException(
62-
"Invalid ID for UtcOffset, plus/minus not found when expected: $offsetString"
63-
)
63+
"Invalid ID for UtcOffset, plus/minus not found when expected: $offsetString")
6464
}
6565
return if (first == '-') {
6666
ofHoursMinutesSeconds(-hours, -minutes, -seconds)
@@ -72,10 +72,8 @@ public actual class UtcOffset internal constructor(public actual val totalSecond
7272
// org.threeten.bp.ZoneOffset#validate
7373
private fun validate(hours: Int, minutes: Int, seconds: Int) {
7474
if (hours < -18 || hours > 18) {
75-
throw IllegalTimeZoneException(
76-
"Zone offset hours not in valid range: value " + hours +
77-
" is not in the range -18 to 18"
78-
)
75+
throw IllegalTimeZoneException("Zone offset hours not in valid range: value " + hours +
76+
" is not in the range -18 to 18")
7977
}
8078
if (hours > 0) {
8179
if (minutes < 0 || seconds < 0) {
@@ -89,16 +87,12 @@ public actual class UtcOffset internal constructor(public actual val totalSecond
8987
throw IllegalTimeZoneException("Zone offset minutes and seconds must have the same sign")
9088
}
9189
if (abs(minutes) > 59) {
92-
throw IllegalTimeZoneException(
93-
"Zone offset minutes not in valid range: abs(value) " +
94-
abs(minutes) + " is not in the range 0 to 59"
95-
)
90+
throw IllegalTimeZoneException("Zone offset minutes not in valid range: abs(value) " +
91+
abs(minutes) + " is not in the range 0 to 59")
9692
}
9793
if (abs(seconds) > 59) {
98-
throw IllegalTimeZoneException(
99-
"Zone offset seconds not in valid range: abs(value) " +
100-
abs(seconds) + " is not in the range 0 to 59"
101-
)
94+
throw IllegalTimeZoneException("Zone offset seconds not in valid range: abs(value) " +
95+
abs(seconds) + " is not in the range 0 to 59")
10296
}
10397
if (abs(hours) == 18 && (abs(minutes) > 0 || abs(seconds) > 0)) {
10498
throw IllegalTimeZoneException("Zone offset not in valid range: -18:00 to +18:00")
@@ -115,9 +109,9 @@ public actual class UtcOffset internal constructor(public actual val totalSecond
115109
// org.threeten.bp.ZoneOffset#ofTotalSeconds
116110
internal fun ofSeconds(seconds: Int): UtcOffset =
117111
if (seconds % (15 * SECONDS_PER_MINUTE) == 0) {
118-
utcOffsetCache[seconds] ?: UtcOffset(seconds, zoneIdByOffset(seconds)).also { utcOffsetCache[seconds] = it }
112+
utcOffsetCache[seconds] ?: UtcOffset(seconds).also { utcOffsetCache[seconds] = it }
119113
} else {
120-
UtcOffset(seconds, zoneIdByOffset(seconds))
114+
UtcOffset(seconds)
121115
}
122116

123117
// org.threeten.bp.ZoneOffset#parseNumber

0 commit comments

Comments
 (0)