Skip to content

Implement Foundation-based time zone support for Darwin systems with comprehensive DST handling #539

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 95 commits into from
Jul 1, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
95 commits
Select commit Hold shift + click to select a range
868f81f
#485: Add tests and fallback logic for retrieving available time zone…
DmitryNekrasov Jun 16, 2025
cef3d62
#485: Rename TimeZoneRules to TimeZoneRulesCommon across the codebase
DmitryNekrasov Jun 16, 2025
2c30c6e
#485: Implement TimeZoneRules interface and update TimeZoneRulesCommo…
DmitryNekrasov Jun 16, 2025
c4c4916
#485: Update RegionTimeZone to use TimeZoneRules instead of TimeZoneR…
DmitryNekrasov Jun 16, 2025
8f9ffba
#485: Add TimeZoneRulesFoundation implementation skeleton to core
DmitryNekrasov Jun 16, 2025
67d1e1f
#485: Add fallback to Foundation-based time zone retrieval on Darwin …
DmitryNekrasov Jun 16, 2025
c55680f
#485: Refactor timezone validation in tests to use a shared list of v…
DmitryNekrasov Jun 16, 2025
e545b06
#485: Add tests for Foundation-based time zone retrieval consistency.…
DmitryNekrasov Jun 16, 2025
9f8af79
#485: Remove debug println from TimeZoneNativeTest during validation …
DmitryNekrasov Jun 16, 2025
b392db9
#485: Pass zoneId to TimeZoneRulesFoundation constructor to ensure pr…
DmitryNekrasov Jun 17, 2025
00e135f
#485: Update TODO messages in TimeZoneRulesFoundation with method-spe…
DmitryNekrasov Jun 17, 2025
1b36cad
#485: Add test to validate offsetAt consistency between Foundation an…
DmitryNekrasov Jun 17, 2025
c252bc1
#485: Implement infoAtInstant in TimeZoneRulesFoundation and initiali…
DmitryNekrasov Jun 17, 2025
7856184
#485: Add test to verify DST transition handling consistency between …
DmitryNekrasov Jun 18, 2025
64128d9
#485: Add tests for DST transitions and stable periods to validate ti…
DmitryNekrasov Jun 18, 2025
687c8a6
#485: Add test to verify edge cases for DST gaps and overlaps in Time…
DmitryNekrasov Jun 18, 2025
8d75cc4
#485: Add comments for time zone offsets in DST gap and overlap tests
DmitryNekrasov Jun 18, 2025
37f33f6
#485: Add debug logging for infoAtDatetime in RegionTimeZone during z…
DmitryNekrasov Jun 18, 2025
56b05c6
#485: Add a debug helper for offsets in TimeZoneNativeTest and implem…
DmitryNekrasov Jun 18, 2025
0f1ba7a
#485: Add validation for LocalDateTime components and debug logging i…
DmitryNekrasov Jun 18, 2025
c096cb8
#485: Refactor infoAtInstant and infoAtDatetime in TimeZoneRulesFound…
DmitryNekrasov Jun 18, 2025
7d447e6
#485: Enhance infoAtDatetime to handle DST gaps and overlaps, improve…
DmitryNekrasov Jun 18, 2025
bfaa938
#485: Simplify infoAtDatetime logic, enhance null-safety, and refine …
DmitryNekrasov Jun 18, 2025
e1e9de3
#485: Remove unused @OptIn annotation in TimeZoneRulesFoundation
DmitryNekrasov Jun 18, 2025
e566643
#485: Add setup for time zone rules test cases and validate offset co…
DmitryNekrasov Jun 19, 2025
b0b9660
#485: Refactor TimeZoneNativeTest to reorganize assertions, reintrodu…
DmitryNekrasov Jun 19, 2025
3665682
#485: Remove debug logging from infoAtDatetime in RegionTimeZone
DmitryNekrasov Jun 19, 2025
fc2ba4b
#485: Remove unused TimeZone import in TimeZoneNativeTest
DmitryNekrasov Jun 19, 2025
6542d8f
#485: Add UTC test cases in TimeZoneNativeTest to validate no DST tra…
DmitryNekrasov Jun 19, 2025
b43e725
#485: Refactor DST gap/overlap handling in TimeZoneRulesFoundation an…
DmitryNekrasov Jun 19, 2025
96a2cb8
#485: Add detailed test cases for DST gap/overlap transitions in Time…
DmitryNekrasov Jun 19, 2025
e3e0710
#485: Consolidate and reorder UTC test cases in TimeZoneNativeTest
DmitryNekrasov Jun 19, 2025
317c4a2
#485: Simplify tzdb initialization and update rulesForId usage in Tim…
DmitryNekrasov Jun 19, 2025
a7db951
#485: Add Australia/Sydney test cases for DST gap/overlap transitions…
DmitryNekrasov Jun 19, 2025
d7ffdf6
#485: Fix incorrect overlap edge case times in TimeZoneNativeTest
DmitryNekrasov Jun 19, 2025
6ca929e
#485: Remove redundant blank lines in TimeZoneRulesFoundation infoAtD…
DmitryNekrasov Jun 19, 2025
16d1d1e
#485: Add Asia/Kolkata test cases to TimeZoneNativeTest for no DST va…
DmitryNekrasov Jun 19, 2025
3d65a79
#485: Add America/Sao_Paulo test cases to TimeZoneNativeTest for hist…
DmitryNekrasov Jun 19, 2025
6168ed6
#485: Add Pacific/Chatham test cases to TimeZoneNativeTest for unusua…
DmitryNekrasov Jun 19, 2025
c8fb77a
#485: Update copyright year range in TimeZoneRulesCommon header
DmitryNekrasov Jun 19, 2025
c780902
#485: Remove unused imports from TimeZoneNativeTest to clean up depen…
DmitryNekrasov Jun 19, 2025
5b235b9
#485: Rename variable in TimeZoneRulesFoundation for clarity and cons…
DmitryNekrasov Jun 19, 2025
3665fc8
#485: Replace forEach with for loop in TimeZoneNativeTest for improve…
DmitryNekrasov Jun 19, 2025
7c247af
#485: Add Asia/Pyongyang test cases to TimeZoneNativeTest for histori…
DmitryNekrasov Jun 19, 2025
855a591
#485: Add Pacific/Kwajalein test cases to TimeZoneNativeTest for skip…
DmitryNekrasov Jun 19, 2025
64ba33a
#485: Add Pacific/Apia test cases to TimeZoneNativeTest and adjust ga…
DmitryNekrasov Jun 19, 2025
a4d797f
#485: Add additional skipped date test case for Pacific/Kwajalein in …
DmitryNekrasov Jun 19, 2025
7e230ed
#485: Add America/Caracas test cases to TimeZoneNativeTest for UTC of…
DmitryNekrasov Jun 19, 2025
9aab509
#485: Extend separator line in TimeZoneNativeTest for improved log re…
DmitryNekrasov Jun 19, 2025
3d4fc91
#485: Remove redundant setup method and replace mutable test data wit…
DmitryNekrasov Jun 20, 2025
732c693
#485: Update return type of rulesForId in TimeZoneDatabase to TimeZon…
DmitryNekrasov Jun 20, 2025
7b4343c
#485: Update TimeZoneRulesCommon references to TimeZoneRules across i…
DmitryNekrasov Jun 20, 2025
31d87a3
#485: Cast TimeZoneRules to TimeZoneRulesCommon in TimeZoneRulesTest …
DmitryNekrasov Jun 20, 2025
b05fe2d
#485: Cast rulesForId result to TimeZoneRulesCommon in JsJodaTimezone…
DmitryNekrasov Jun 20, 2025
9cef46f
#485: Cast rulesForId result to TimeZoneRulesCommon in TimeZoneRulesC…
DmitryNekrasov Jun 20, 2025
78e09c6
#485: Add America/Caracas test cases to TimeZoneNativeTest for UTC of…
DmitryNekrasov Jun 20, 2025
9887af3
#485: Fix daylight saving overlap logic in TimeZoneRulesFoundation by…
DmitryNekrasov Jun 20, 2025
48236df
#485: Add Asia/Magadan test cases to TimeZoneNativeTest for UTC offse…
DmitryNekrasov Jun 20, 2025
278d070
#485: Add test case for one day before gap transition in TimeZoneNati…
DmitryNekrasov Jun 20, 2025
21513f6
#485: Replace static header with dynamic formatHeader method in TimeZ…
DmitryNekrasov Jun 20, 2025
b8f4f1b
#485: Iterate over multiple LocalDateTimes in testTimeZoneByIdFoundat…
DmitryNekrasov Jun 20, 2025
ffd5c16
#485: Rename test to reflect consistent ZonedDateTime production acro…
DmitryNekrasov Jun 20, 2025
f4c4411
#485: Add test for consistent atStartOfDay instance production betwee…
DmitryNekrasov Jun 20, 2025
7a8265e
#485: Add test for consistent UTC offset production between regular a…
DmitryNekrasov Jun 20, 2025
dacfdf7
#485: Replace kotlinx.datetime.Instant with kotlin.time.Instant in Ti…
DmitryNekrasov Jun 20, 2025
f155332
#485: Update Asia/Pyongyang test case in TimeZoneNativeTest to reflec…
DmitryNekrasov Jun 21, 2025
ad39080
#485: Extend TimeZoneNativeTest to classify LocalDateTimes with Offse…
DmitryNekrasov Jun 21, 2025
5f61ce4
#485: Refactor TimeZoneNativeTest for clarity by extracting OffsetInf…
DmitryNekrasov Jun 21, 2025
2ee058d
#485: Adjust LocalDateTime-OffsetInfo mappings in TimeZoneNativeTest …
DmitryNekrasov Jun 21, 2025
8036cee
#485: Refactor TimeZoneNativeTest for improved readability and consis…
DmitryNekrasov Jun 21, 2025
568d567
#485: Make OffsetInfoType enum private in TimeZoneNativeTest to restr…
DmitryNekrasov Jun 21, 2025
1ad7c4f
#485: Refactor consistent ZonedDateTime production test for improved …
DmitryNekrasov Jun 21, 2025
b3845ba
#485: Refactor consistent atStartOfDay instance production test for i…
DmitryNekrasov Jun 21, 2025
0c79ea5
#485: Move TimeZoneRulesCommon and RecurringZoneRules implementations…
DmitryNekrasov Jun 23, 2025
1dfec8e
#485: Replace specific import with wildcard in TimeZoneRules for math…
DmitryNekrasov Jun 23, 2025
21ca771
#485: Add ExperimentalForeignApi opt-in and use `convert` for safer U…
DmitryNekrasov Jun 23, 2025
16b2a1d
#485: Update TimeZoneRulesFoundation to use ISO8601 calendar
DmitryNekrasov Jun 23, 2025
052c98f
#485: Refactor TimeZoneRulesFoundation to improve null checks and enh…
DmitryNekrasov Jun 23, 2025
e4b423d
#485: Refactor infoAtDatetime based on nextDaylightSavingTimeTransiti…
DmitryNekrasov Jun 30, 2025
3f10cc0
#485: Remove unused `addTimeInterval` import from TimeZoneRulesFounda…
DmitryNekrasov Jun 30, 2025
4c43b3f
#485: Remove debug print statement from TimeZoneRulesFoundation
DmitryNekrasov Jun 30, 2025
2091ff2
#485: Remove unused SECS_PER_DAY constant from TimeZoneRulesFoundation
DmitryNekrasov Jun 30, 2025
20e5504
#485: Reuse `transitionDateTimeInstant` for clarity and to avoid redu…
DmitryNekrasov Jun 30, 2025
2bfeefb
#485: Update `rulesForId` and related methods to use `TimeZoneRulesCo…
DmitryNekrasov Jun 30, 2025
7323da4
#485: Replace `runCatching` with explicit `if` checks for improved cl…
DmitryNekrasov Jun 30, 2025
569c485
#485: Update `rulesForId` and `readRules` to return `TimeZoneRulesCom…
DmitryNekrasov Jun 30, 2025
b7fbc2c
#485: Remove unused `TimeZoneRulesCommon` import from `JsJodaTimezone…
DmitryNekrasov Jun 30, 2025
7624587
Refactor `assertOffsetInfoType` to directly compare `OffsetInfo` type…
DmitryNekrasov Jun 30, 2025
8fa63ae
Remove debug print statements from `TimeZoneNativeTest`.
DmitryNekrasov Jun 30, 2025
59234f5
Refine assertions in `TimeZoneNativeTest` to check for timezone prefi…
DmitryNekrasov Jun 30, 2025
4dd205f
Refactor `TimeZoneNativeTest` to use `DateTimePeriod` in offset calcu…
DmitryNekrasov Jul 1, 2025
1f9154d
Refactor `TimeZoneNativeTest` to rename `transitionTime` to `timeJust…
DmitryNekrasov Jul 1, 2025
400e246
Refactor `TimeZoneNativeTest` to parameterize `zoneId` in DST transit…
DmitryNekrasov Jul 1, 2025
a3ff28a
Refine `TimeZoneNativeTest` assertions to require both "UTC" and "GMT…
DmitryNekrasov Jul 1, 2025
6294845
Add synchronization comments for `infoAtLocalDateTime` and `infoAtDat…
DmitryNekrasov Jul 1, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions core/androidNative/src/internal/TzdbBionic.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@
package kotlinx.datetime.internal

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

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

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

Expand Down
8 changes: 4 additions & 4 deletions core/commonJs/src/internal/Platform.kt
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ private val tzdb: Result<TimeZoneDatabase?> = runCatching {
return (wholeMinutes * SECONDS_PER_MINUTE + seconds) * sign
}

val zones = mutableMapOf<String, TimeZoneRules>()
val zones = mutableMapOf<String, TimeZoneRulesCommon>()
val (zonesPacked, linksPacked) = readTzdb() ?: return@runCatching null
for (zone in zonesPacked) {
val components = zone.split('|')
Expand All @@ -70,7 +70,7 @@ private val tzdb: Result<TimeZoneDatabase?> = runCatching {
}
val indices = components[3].map { charCodeToInt(it) }
val lengthsOfPeriodsWithOffsets = components[4].split(' ').map(::base60MinutesInSeconds)
zones[components[0]] = TimeZoneRules(
zones[components[0]] = TimeZoneRulesCommon(
transitionEpochSeconds = lengthsOfPeriodsWithOffsets.runningReduce(Long::plus).let {
if (it.size == indices.size - 1) it else it.take<Long>(indices.size - 1)
},
Expand All @@ -85,7 +85,7 @@ private val tzdb: Result<TimeZoneDatabase?> = runCatching {
}
}
object : TimeZoneDatabase {
override fun rulesForId(id: String): TimeZoneRules =
override fun rulesForId(id: String): TimeZoneRulesCommon =
zones[id] ?: throw IllegalTimeZoneException("Unknown time zone: $id")

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

internal fun rulesForId(zoneId: String): TimeZoneRules? = tzdb.getOrThrow()?.rulesForId(zoneId)
internal fun rulesForId(zoneId: String): TimeZoneRulesCommon? = tzdb.getOrThrow()?.rulesForId(zoneId)

internal actual fun getAvailableZoneIds(): Set<String> =
tzdb.getOrThrow()?.availableTimeZoneIds() ?: setOf("UTC")
Expand Down
2 changes: 1 addition & 1 deletion core/commonKotlin/src/internal/TimeZoneDatabase.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@
package kotlinx.datetime.internal

internal interface TimeZoneDatabase {
fun rulesForId(id: String): TimeZoneRules
fun rulesForId(id: String): TimeZoneRulesCommon
fun availableTimeZoneIds(): Set<String>
}
25 changes: 19 additions & 6 deletions core/commonKotlin/src/internal/TimeZoneRules.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2019-2023 JetBrains s.r.o. and contributors.
* Copyright 2019-2025 JetBrains s.r.o. and contributors.
* Use of this source code is governed by the Apache 2.0 License that can be found in the LICENSE.txt file.
*/

Expand All @@ -11,7 +11,14 @@ import kotlinx.datetime.toLocalDateTime
import kotlin.math.*
import kotlin.time.Instant

internal class TimeZoneRules(
internal interface TimeZoneRules {

fun infoAtInstant(instant: Instant): UtcOffset

fun infoAtDatetime(localDateTime: LocalDateTime): OffsetInfo
}

internal class TimeZoneRulesCommon(
/**
* The list of [Instant.epochSeconds] parts of the instants when recorded transitions occur, in ascending order.
*/
Expand All @@ -31,15 +38,15 @@ internal class TimeZoneRules(
* [recurringZoneRules].
*/
val recurringZoneRules: RecurringZoneRules?,
) {
) : TimeZoneRules {
init {
require(offsets.size == transitionEpochSeconds.size + 1) {
"offsets.size must be one more than transitionEpochSeconds.size"
}
}

/**
* Constructs a [TimeZoneRules] without any historic data.
* Constructs a [TimeZoneRulesCommon] without any historic data.
*/
constructor(initialOffset: UtcOffset, rules: RecurringZoneRules) : this(
transitionEpochSeconds = emptyList(),
Expand Down Expand Up @@ -68,7 +75,7 @@ internal class TimeZoneRules(
}
}

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

fun infoAtDatetime(localDateTime: LocalDateTime): OffsetInfo {
override fun infoAtDatetime(localDateTime: LocalDateTime): OffsetInfo {
if (recurringZoneRules != null && transitionLocalDateTimes.lastOrNull()?.let { localDateTime > it } != false) {
return recurringZoneRules.infoAtLocalDateTime(localDateTime, offsets.last())
}
Expand Down Expand Up @@ -177,6 +184,12 @@ internal class RecurringZoneRules(
}
}

/**
* IMPORTANT: keep this implementation in sync with [TimeZoneRulesFoundation.infoAtDatetime].
* The algorithms and corner-case handling should stay identical so that Darwin (Foundation-based)
* and tzdb-based platforms compute the same results. When you change logic here, reflect the
* same change in [TimeZoneRulesFoundation.infoAtDatetime].
*/
fun infoAtLocalDateTime(localDateTime: LocalDateTime, offsetAtYearStart: UtcOffset): OffsetInfo {
val year = localDateTime.year
var offset = offsetAtYearStart
Expand Down
4 changes: 2 additions & 2 deletions core/commonKotlin/src/internal/Tzfile.kt
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,13 @@ internal class TzFileData(
}

internal class TzFile(val data: TzFileData, val rules: PosixTzString?) {
fun toTimeZoneRules(): TimeZoneRules {
fun toTimeZoneRules(): TimeZoneRulesCommon {
val tzOffsets = buildList {
add(data.states[0].offset)
data.transitions.forEach { add(data.states[it.stateIndex].offset) }
}
val offsets = tzOffsets.map { it.toUtcOffset() }
return TimeZoneRules(data.transitions.map { it.time }, offsets, rules?.toRecurringZoneRules())
return TimeZoneRulesCommon(data.transitions.map { it.time }, offsets, rules?.toRecurringZoneRules())
}
}

Expand Down
2 changes: 1 addition & 1 deletion core/commonKotlin/test/TimeZoneRulesTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ class TimeZoneRulesTest {
val ruleString = "AST4ADT,M3.2.0,M11.1.0\n" // Atlantic/Bermuda
val recurringRules =
PosixTzString.readIfPresent(BinaryDataReader(ruleString.encodeToByteArray()))!!.toRecurringZoneRules()!!
val rules = TimeZoneRules(UtcOffset(hours = -4), recurringRules)
val rules = TimeZoneRulesCommon(UtcOffset(hours = -4), recurringRules)
val dstStartTime = LocalDateTime(2020, 3, 8, 2, 1)
val infoAtDstStart = rules.infoAtDatetime(dstStartTime)
assertTrue(infoAtDstStart is OffsetInfo.Gap, "Expected Gap, got $infoAtDstStart")
Expand Down
20 changes: 16 additions & 4 deletions core/darwin/src/internal/TimeZoneNative.kt
Original file line number Diff line number Diff line change
@@ -1,21 +1,33 @@
/*
* Copyright 2019-2020 JetBrains s.r.o.
* Copyright 2019-2025 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.
*/

@file:OptIn(ExperimentalForeignApi::class)

package kotlinx.datetime.internal

import kotlinx.cinterop.*
import kotlinx.datetime.TimeZone
import kotlinx.datetime.internal.*
import platform.Foundation.*

internal actual fun timeZoneById(zoneId: String): TimeZone =
RegionTimeZone(tzdb.getOrThrow().rulesForId(zoneId), zoneId)
if (tzdb.isSuccess)
RegionTimeZone(tzdb.getOrThrow().rulesForId(zoneId), zoneId)
else
timeZoneByIdFoundation(zoneId)

internal fun timeZoneByIdFoundation(zoneId: String): TimeZone =
RegionTimeZone(TimeZoneRulesFoundation(zoneId), zoneId)

internal actual fun getAvailableZoneIds(): Set<String> =
tzdb.getOrThrow().availableTimeZoneIds()
if (tzdb.isSuccess)
tzdb.getOrThrow().availableTimeZoneIds()
else
getAvailableZoneIdsFoundation()

internal fun getAvailableZoneIdsFoundation(): Set<String> =
NSTimeZone.knownTimeZoneNames.mapTo(mutableSetOf("UTC")) { it.toString() }

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

Expand Down
82 changes: 82 additions & 0 deletions core/darwin/src/internal/TimeZoneRulesFoundation.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/*
* Copyright 2019-2025 JetBrains s.r.o. and contributors.
* Use of this source code is governed by the Apache 2.0 License that can be found in the LICENSE.txt file.
*/

package kotlinx.datetime.internal

import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.UnsafeNumber
import kotlinx.cinterop.convert
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.UtcOffset
import kotlinx.datetime.toKotlinInstant
import kotlinx.datetime.toLocalDateTime
import kotlinx.datetime.toNSDate
import kotlinx.datetime.toNSDateComponents
import platform.Foundation.NSCalendar
import platform.Foundation.NSCalendarIdentifierISO8601
import platform.Foundation.NSCalendarUnitYear
import platform.Foundation.NSDate
import platform.Foundation.NSTimeZone
import platform.Foundation.timeZoneWithName
import kotlin.time.Instant

internal class TimeZoneRulesFoundation(private val zoneId: String) : TimeZoneRules {
private val nsTimeZone: NSTimeZone = NSTimeZone.timeZoneWithName(zoneId)
?: throw IllegalArgumentException("Unknown timezone: $zoneId")

override fun infoAtInstant(instant: Instant): UtcOffset =
infoAtNsDate(instant.toNSDate())

/**
* IMPORTANT: mirrors the logic in [RecurringZoneRules.infoAtLocalDateTime].
* Any update to offset calculations, transition handling, or edge cases
* must be duplicated there to maintain consistent behavior across
* all platforms.
*/
@OptIn(UnsafeNumber::class, ExperimentalForeignApi::class)
override fun infoAtDatetime(localDateTime: LocalDateTime): OffsetInfo {
val calendar = NSCalendar.calendarWithIdentifier(NSCalendarIdentifierISO8601)
?.apply { timeZone = nsTimeZone }

val year = localDateTime.year
val startOfTheYear = calendar?.dateFromComponents(LocalDateTime(year, 1, 1, 0, 0).toNSDateComponents())
check(startOfTheYear != null) { "Failed to get the start of the year for $localDateTime, timezone: $zoneId" }

var currentDate: NSDate = startOfTheYear
var offset = infoAtNsDate(startOfTheYear)
do {
val transitionDateTime = nsTimeZone.nextDaylightSavingTimeTransitionAfterDate(currentDate)
if (transitionDateTime == null) break

val yearOfNextDate = calendar.component(NSCalendarUnitYear.convert(), fromDate = transitionDateTime)
val transitionDateTimeInstant = transitionDateTime.toKotlinInstant()

val offsetBefore = infoAtNsDate(currentDate)
val ldtBefore = transitionDateTimeInstant.toLocalDateTime(offsetBefore)
val offsetAfter = infoAtNsDate(transitionDateTime)
val ldtAfter = transitionDateTimeInstant.toLocalDateTime(offsetAfter)

return if (localDateTime < ldtBefore && localDateTime < ldtAfter) {
OffsetInfo.Regular(offsetBefore)
} else if (localDateTime >= ldtBefore && localDateTime >= ldtAfter) {
offset = offsetAfter
currentDate = transitionDateTime
continue
} else if (ldtAfter < ldtBefore) {
OffsetInfo.Overlap(transitionDateTimeInstant, offsetBefore, offsetAfter)
} else {
OffsetInfo.Gap(transitionDateTimeInstant, offsetBefore, offsetAfter)
}
} while (yearOfNextDate <= year)

return OffsetInfo.Regular(offset)
}

@OptIn(UnsafeNumber::class, ExperimentalForeignApi::class)
private fun infoAtNsDate(nsDate: NSDate): UtcOffset {
val offsetSeconds = nsTimeZone.secondsFromGMTForDate(nsDate)
return UtcOffset(seconds = offsetSeconds.convert())
}
}
Loading