Skip to content

Commit 5824a70

Browse files
Implement Foundation-based time zone support for Darwin systems with comprehensive DST handling (#539)
- Extract TimeZoneRules interface to abstract timezone operations - Refactor existing tzdb logic into TimeZoneRulesCommon - Add TimeZoneRulesFoundation using NSTimeZone for Darwin platforms - Handle DST transitions (gaps/overlaps) with Foundation APIs - Add comprehensive tests verifying Foundation/tzdb behavior parity Fixes #485
1 parent 5a2a6e1 commit 5824a70

File tree

11 files changed

+553
-25
lines changed

11 files changed

+553
-25
lines changed

core/androidNative/src/internal/TzdbBionic.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,13 @@
1010
package kotlinx.datetime.internal
1111

1212
private class TzdbBionic(private val rules: Map<String, Entry>) : TimeZoneDatabase {
13-
override fun rulesForId(id: String): TimeZoneRules =
13+
override fun rulesForId(id: String): TimeZoneRulesCommon =
1414
rules[id]?.readRules() ?: throw IllegalStateException("Unknown time zone $id")
1515

1616
override fun availableTimeZoneIds(): Set<String> = rules.keys
1717

1818
class Entry(val file: ByteArray, val offset: Int, val length: Int) {
19-
fun readRules(): TimeZoneRules = readTzFile(file.copyOfRange(offset, offset + length)).toTimeZoneRules()
19+
fun readRules(): TimeZoneRulesCommon = readTzFile(file.copyOfRange(offset, offset + length)).toTimeZoneRules()
2020
}
2121
}
2222

core/commonJs/src/internal/Platform.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ private val tzdb: Result<TimeZoneDatabase?> = runCatching {
6161
return (wholeMinutes * SECONDS_PER_MINUTE + seconds) * sign
6262
}
6363

64-
val zones = mutableMapOf<String, TimeZoneRules>()
64+
val zones = mutableMapOf<String, TimeZoneRulesCommon>()
6565
val (zonesPacked, linksPacked) = readTzdb() ?: return@runCatching null
6666
for (zone in zonesPacked) {
6767
val components = zone.split('|')
@@ -70,7 +70,7 @@ private val tzdb: Result<TimeZoneDatabase?> = runCatching {
7070
}
7171
val indices = components[3].map { charCodeToInt(it) }
7272
val lengthsOfPeriodsWithOffsets = components[4].split(' ').map(::base60MinutesInSeconds)
73-
zones[components[0]] = TimeZoneRules(
73+
zones[components[0]] = TimeZoneRulesCommon(
7474
transitionEpochSeconds = lengthsOfPeriodsWithOffsets.runningReduce(Long::plus).let {
7575
if (it.size == indices.size - 1) it else it.take<Long>(indices.size - 1)
7676
},
@@ -85,7 +85,7 @@ private val tzdb: Result<TimeZoneDatabase?> = runCatching {
8585
}
8686
}
8787
object : TimeZoneDatabase {
88-
override fun rulesForId(id: String): TimeZoneRules =
88+
override fun rulesForId(id: String): TimeZoneRulesCommon =
8989
zones[id] ?: throw IllegalTimeZoneException("Unknown time zone: $id")
9090

9191
override fun availableTimeZoneIds(): Set<String> = zones.keys
@@ -135,7 +135,7 @@ internal actual fun timeZoneById(zoneId: String): TimeZone {
135135
throw IllegalTimeZoneException("js-joda timezone database is not available")
136136
}
137137

138-
internal fun rulesForId(zoneId: String): TimeZoneRules? = tzdb.getOrThrow()?.rulesForId(zoneId)
138+
internal fun rulesForId(zoneId: String): TimeZoneRulesCommon? = tzdb.getOrThrow()?.rulesForId(zoneId)
139139

140140
internal actual fun getAvailableZoneIds(): Set<String> =
141141
tzdb.getOrThrow()?.availableTimeZoneIds() ?: setOf("UTC")

core/commonKotlin/src/internal/TimeZoneDatabase.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,6 @@
66
package kotlinx.datetime.internal
77

88
internal interface TimeZoneDatabase {
9-
fun rulesForId(id: String): TimeZoneRules
9+
fun rulesForId(id: String): TimeZoneRulesCommon
1010
fun availableTimeZoneIds(): Set<String>
1111
}

core/commonKotlin/src/internal/TimeZoneRules.kt

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2019-2023 JetBrains s.r.o. and contributors.
2+
* Copyright 2019-2025 JetBrains s.r.o. and contributors.
33
* Use of this source code is governed by the Apache 2.0 License that can be found in the LICENSE.txt file.
44
*/
55

@@ -11,7 +11,14 @@ import kotlinx.datetime.toLocalDateTime
1111
import kotlin.math.*
1212
import kotlin.time.Instant
1313

14-
internal class TimeZoneRules(
14+
internal interface TimeZoneRules {
15+
16+
fun infoAtInstant(instant: Instant): UtcOffset
17+
18+
fun infoAtDatetime(localDateTime: LocalDateTime): OffsetInfo
19+
}
20+
21+
internal class TimeZoneRulesCommon(
1522
/**
1623
* The list of [Instant.epochSeconds] parts of the instants when recorded transitions occur, in ascending order.
1724
*/
@@ -31,15 +38,15 @@ internal class TimeZoneRules(
3138
* [recurringZoneRules].
3239
*/
3340
val recurringZoneRules: RecurringZoneRules?,
34-
) {
41+
) : TimeZoneRules {
3542
init {
3643
require(offsets.size == transitionEpochSeconds.size + 1) {
3744
"offsets.size must be one more than transitionEpochSeconds.size"
3845
}
3946
}
4047

4148
/**
42-
* Constructs a [TimeZoneRules] without any historic data.
49+
* Constructs a [TimeZoneRulesCommon] without any historic data.
4350
*/
4451
constructor(initialOffset: UtcOffset, rules: RecurringZoneRules) : this(
4552
transitionEpochSeconds = emptyList(),
@@ -68,7 +75,7 @@ internal class TimeZoneRules(
6875
}
6976
}
7077

71-
fun infoAtInstant(instant: Instant): UtcOffset {
78+
override fun infoAtInstant(instant: Instant): UtcOffset {
7279
val epochSeconds = instant.epochSeconds
7380
// good: no transitions, or instant is after the last transition
7481
if (recurringZoneRules != null && transitionEpochSeconds.lastOrNull()?.let { epochSeconds >= it } != false) {
@@ -84,7 +91,7 @@ internal class TimeZoneRules(
8491
return offsets[index]
8592
}
8693

87-
fun infoAtDatetime(localDateTime: LocalDateTime): OffsetInfo {
94+
override fun infoAtDatetime(localDateTime: LocalDateTime): OffsetInfo {
8895
if (recurringZoneRules != null && transitionLocalDateTimes.lastOrNull()?.let { localDateTime > it } != false) {
8996
return recurringZoneRules.infoAtLocalDateTime(localDateTime, offsets.last())
9097
}
@@ -177,6 +184,12 @@ internal class RecurringZoneRules(
177184
}
178185
}
179186

187+
/**
188+
* IMPORTANT: keep this implementation in sync with [TimeZoneRulesFoundation.infoAtDatetime].
189+
* The algorithms and corner-case handling should stay identical so that Darwin (Foundation-based)
190+
* and tzdb-based platforms compute the same results. When you change logic here, reflect the
191+
* same change in [TimeZoneRulesFoundation.infoAtDatetime].
192+
*/
180193
fun infoAtLocalDateTime(localDateTime: LocalDateTime, offsetAtYearStart: UtcOffset): OffsetInfo {
181194
val year = localDateTime.year
182195
var offset = offsetAtYearStart

core/commonKotlin/src/internal/Tzfile.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,13 @@ internal class TzFileData(
4040
}
4141

4242
internal class TzFile(val data: TzFileData, val rules: PosixTzString?) {
43-
fun toTimeZoneRules(): TimeZoneRules {
43+
fun toTimeZoneRules(): TimeZoneRulesCommon {
4444
val tzOffsets = buildList {
4545
add(data.states[0].offset)
4646
data.transitions.forEach { add(data.states[it.stateIndex].offset) }
4747
}
4848
val offsets = tzOffsets.map { it.toUtcOffset() }
49-
return TimeZoneRules(data.transitions.map { it.time }, offsets, rules?.toRecurringZoneRules())
49+
return TimeZoneRulesCommon(data.transitions.map { it.time }, offsets, rules?.toRecurringZoneRules())
5050
}
5151
}
5252

core/commonKotlin/test/TimeZoneRulesTest.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ class TimeZoneRulesTest {
3737
val ruleString = "AST4ADT,M3.2.0,M11.1.0\n" // Atlantic/Bermuda
3838
val recurringRules =
3939
PosixTzString.readIfPresent(BinaryDataReader(ruleString.encodeToByteArray()))!!.toRecurringZoneRules()!!
40-
val rules = TimeZoneRules(UtcOffset(hours = -4), recurringRules)
40+
val rules = TimeZoneRulesCommon(UtcOffset(hours = -4), recurringRules)
4141
val dstStartTime = LocalDateTime(2020, 3, 8, 2, 1)
4242
val infoAtDstStart = rules.infoAtDatetime(dstStartTime)
4343
assertTrue(infoAtDstStart is OffsetInfo.Gap, "Expected Gap, got $infoAtDstStart")

core/darwin/src/internal/TimeZoneNative.kt

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,33 @@
11
/*
2-
* Copyright 2019-2020 JetBrains s.r.o.
2+
* Copyright 2019-2025 JetBrains s.r.o.
33
* Use of this source code is governed by the Apache 2.0 License that can be found in the LICENSE.txt file.
44
*/
55

66
@file:OptIn(ExperimentalForeignApi::class)
7+
78
package kotlinx.datetime.internal
89

910
import kotlinx.cinterop.*
1011
import kotlinx.datetime.TimeZone
11-
import kotlinx.datetime.internal.*
1212
import platform.Foundation.*
1313

1414
internal actual fun timeZoneById(zoneId: String): TimeZone =
15-
RegionTimeZone(tzdb.getOrThrow().rulesForId(zoneId), zoneId)
15+
if (tzdb.isSuccess)
16+
RegionTimeZone(tzdb.getOrThrow().rulesForId(zoneId), zoneId)
17+
else
18+
timeZoneByIdFoundation(zoneId)
19+
20+
internal fun timeZoneByIdFoundation(zoneId: String): TimeZone =
21+
RegionTimeZone(TimeZoneRulesFoundation(zoneId), zoneId)
1622

1723
internal actual fun getAvailableZoneIds(): Set<String> =
18-
tzdb.getOrThrow().availableTimeZoneIds()
24+
if (tzdb.isSuccess)
25+
tzdb.getOrThrow().availableTimeZoneIds()
26+
else
27+
getAvailableZoneIdsFoundation()
28+
29+
internal fun getAvailableZoneIdsFoundation(): Set<String> =
30+
NSTimeZone.knownTimeZoneNames.mapTo(mutableSetOf("UTC")) { it.toString() }
1931

2032
private val tzdb = runCatching { TzdbOnFilesystem(Path.fromString(defaultTzdbPath())) }
2133

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/*
2+
* Copyright 2019-2025 JetBrains s.r.o. and contributors.
3+
* Use of this source code is governed by the Apache 2.0 License that can be found in the LICENSE.txt file.
4+
*/
5+
6+
package kotlinx.datetime.internal
7+
8+
import kotlinx.cinterop.ExperimentalForeignApi
9+
import kotlinx.cinterop.UnsafeNumber
10+
import kotlinx.cinterop.convert
11+
import kotlinx.datetime.LocalDateTime
12+
import kotlinx.datetime.UtcOffset
13+
import kotlinx.datetime.toKotlinInstant
14+
import kotlinx.datetime.toLocalDateTime
15+
import kotlinx.datetime.toNSDate
16+
import kotlinx.datetime.toNSDateComponents
17+
import platform.Foundation.NSCalendar
18+
import platform.Foundation.NSCalendarIdentifierISO8601
19+
import platform.Foundation.NSCalendarUnitYear
20+
import platform.Foundation.NSDate
21+
import platform.Foundation.NSTimeZone
22+
import platform.Foundation.timeZoneWithName
23+
import kotlin.time.Instant
24+
25+
internal class TimeZoneRulesFoundation(private val zoneId: String) : TimeZoneRules {
26+
private val nsTimeZone: NSTimeZone = NSTimeZone.timeZoneWithName(zoneId)
27+
?: throw IllegalArgumentException("Unknown timezone: $zoneId")
28+
29+
override fun infoAtInstant(instant: Instant): UtcOffset =
30+
infoAtNsDate(instant.toNSDate())
31+
32+
/**
33+
* IMPORTANT: mirrors the logic in [RecurringZoneRules.infoAtLocalDateTime].
34+
* Any update to offset calculations, transition handling, or edge cases
35+
* must be duplicated there to maintain consistent behavior across
36+
* all platforms.
37+
*/
38+
@OptIn(UnsafeNumber::class, ExperimentalForeignApi::class)
39+
override fun infoAtDatetime(localDateTime: LocalDateTime): OffsetInfo {
40+
val calendar = NSCalendar.calendarWithIdentifier(NSCalendarIdentifierISO8601)
41+
?.apply { timeZone = nsTimeZone }
42+
43+
val year = localDateTime.year
44+
val startOfTheYear = calendar?.dateFromComponents(LocalDateTime(year, 1, 1, 0, 0).toNSDateComponents())
45+
check(startOfTheYear != null) { "Failed to get the start of the year for $localDateTime, timezone: $zoneId" }
46+
47+
var currentDate: NSDate = startOfTheYear
48+
var offset = infoAtNsDate(startOfTheYear)
49+
do {
50+
val transitionDateTime = nsTimeZone.nextDaylightSavingTimeTransitionAfterDate(currentDate)
51+
if (transitionDateTime == null) break
52+
53+
val yearOfNextDate = calendar.component(NSCalendarUnitYear.convert(), fromDate = transitionDateTime)
54+
val transitionDateTimeInstant = transitionDateTime.toKotlinInstant()
55+
56+
val offsetBefore = infoAtNsDate(currentDate)
57+
val ldtBefore = transitionDateTimeInstant.toLocalDateTime(offsetBefore)
58+
val offsetAfter = infoAtNsDate(transitionDateTime)
59+
val ldtAfter = transitionDateTimeInstant.toLocalDateTime(offsetAfter)
60+
61+
return if (localDateTime < ldtBefore && localDateTime < ldtAfter) {
62+
OffsetInfo.Regular(offsetBefore)
63+
} else if (localDateTime >= ldtBefore && localDateTime >= ldtAfter) {
64+
offset = offsetAfter
65+
currentDate = transitionDateTime
66+
continue
67+
} else if (ldtAfter < ldtBefore) {
68+
OffsetInfo.Overlap(transitionDateTimeInstant, offsetBefore, offsetAfter)
69+
} else {
70+
OffsetInfo.Gap(transitionDateTimeInstant, offsetBefore, offsetAfter)
71+
}
72+
} while (yearOfNextDate <= year)
73+
74+
return OffsetInfo.Regular(offset)
75+
}
76+
77+
@OptIn(UnsafeNumber::class, ExperimentalForeignApi::class)
78+
private fun infoAtNsDate(nsDate: NSDate): UtcOffset {
79+
val offsetSeconds = nsTimeZone.secondsFromGMTForDate(nsDate)
80+
return UtcOffset(seconds = offsetSeconds.convert())
81+
}
82+
}

0 commit comments

Comments
 (0)