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

Conversation

DmitryNekrasov
Copy link
Contributor

@DmitryNekrasov DmitryNekrasov commented Jun 19, 2025

Summary

This PR addresses issue #485 by leveraging Apple’s native Foundation time zone data if tzdb is not available. The goal is to make time zone computations work correctly on Darwin devices if tzdb is not available for some reasons, while ensuring the behavior remains consistent with other platforms.

Solution: Abstract Time Zone Rules and Introduce Foundation Implementation

The core of the solution is to abstract time zone logic behind a new internal interface, TimeZoneRules. This interface (introduced in this PR) defines the essential operations needed for time zone conversions: retrieving the offset at a given Instant (infoAtInstant) and obtaining detailed offset transition info for a given LocalDateTime (infoAtDatetime). By extracting this interface, we can provide multiple implementations of time zone rules. The existing tzdb-based logic has been refactored into an implementation called TimeZoneRulesCommon (which reads from tzdb as before), and a new implementation called TimeZoneRulesFoundation has been added for Darwin platforms. The TimeZone API now uses these abstractions under the hood, selecting the appropriate rules implementation based on the platform. For example, on iOS or macOS, TimeZone.of(zoneId) will use the Foundation-backed rules (if tzdb is not available), whereas on other targets it continues to use tzdb data.

Implementation Details of TimeZoneRulesFoundation (Darwin)

The TimeZoneRulesFoundation class uses Apple’s Foundation framework (NSTimeZone, NSCalendar, NSDate) to obtain time zone information. Upon initialization, it looks up the NSTimeZone by the zone identifier. The infoAtInstant(instant) function is straightforward: it converts the Instant to an NSDate and calls NSTimeZone.secondsFromGMT(for: date) to get the raw UTC offset in seconds, returning it as a UtcOffset. The more complex logic resides in infoAtDatetime(localDateTime), which must handle situations like Daylight Saving Time transitions (gaps and overlaps in local time). This method uses an NSCalendar set to the specific time zone to convert a Kotlin LocalDateTime to an NSDate. If the local datetime does not exist (!components.isValidDateInCalendar(calendar), this happens during a spring-forward DST gap when clocks jump forward) - the code identifies it as a gap. In a gap scenario, TimeZoneRulesFoundation finds the next DST transition before the missing hour and constructs an OffsetInfo.Gap object, providing the transition instant (start of the gap), as well as the offsets immediately before and after the gap. On the other hand, if the components is valid date in calendar (meaning the local time exists), the code then checks for a potential fall-back DST overlap. It does so by comparing the UTC offset of that time with the offset 24 hours later. If the offset 24 hours later is smaller (indicating that clocks were turned back, creating an overlap of local times), the code further pinpoints the exact transition moment by adding the offset difference to the NSDate. If it confirms that the local time repeats (the offset after adding the difference is still less than the original), it returns an OffsetInfo.Overlap with the overlap’s start instant and the two different offsets before/after the overlap. If neither a gap nor overlap is detected, it returns a regular offset info (OffsetInfo.Regular). This careful calculation ensures that TimeZoneRulesFoundation.infoAtDatetime can correctly distinguish normal times from “missing” hours and “repeated” hours, paralleling the behavior of tzdb. All exceptions and edge cases are handled. In summary, on Darwin the library now uses the system’s time zone database via Foundation for all conversions, meaning it will recognize all valid zone IDs and compute offsets correctly without requiring the tzdb files. Additionally, the mechanism for retrieving available time zone IDs (TimeZone.availableZoneIds internally) was updated: on Darwin it falls back to using NSTimeZone.knownTimeZoneNames (through a new internal getAvailableZoneIdsFoundation()), ensuring the set of zone IDs is complete and up-to-date with the system’s zones.

Testing and Verification

This PR includes an extensive test suite (TimeZoneNativeTest) to verify that the Foundation-based implementation behaves identically to the tzdb-based implementation across a wide range of scenarios. The tests create pairs of TimeZone objects for the same zone ID - one using the regular tzdb rules (timeZoneById / TimeZoneRulesCommon) and one using the Foundation rules (timeZoneByIdFoundation / TimeZoneRulesFoundation) - and compare their results. Key aspects tested are:

  • DST Transitions: The tests check that offsets around DST boundaries match. For example, for America/New_York the suite verifies the spring-forward transition in 2025 (clocks jumping from 1:59:59 to 3:00:00 on Mar 9, 2025) and the fall-back transition (clocks repeating 1:00 - 1:59 on Nov 2, 2025). It asserts that before a DST change, both implementations give the same offset, and after the change they still agree, and that the offset actually changes as expected (ensuring both detect the transition). Similar checks are done for other zones like Europe/London (which has a gap at 1 AM when DST starts, and an overlap in fall) and Australia/Sydney (opposite hemisphere DST dates). The tests confirm that OffsetInfo.Gap and OffsetInfo.Overlap produced by TimeZoneRulesFoundation exactly match those from the tzdb rules in each case (including the start instant of the gap/overlap and the offsets before/after).
  • Stable Times (No Transition): The tests pick ordinary dates (e.g. a random summer day mid-year, or a week span with no DST changes) and verify that for multiple sample instants the offsets remain constant and equal between the two implementations. For instance, for New York in July 2025 (well outside any DST change), the offset is consistently UTC-04 for all samples, and Foundation and tzdb agree on every query. This ensures no divergence in normal scenarios.
  • Comprehensive Edge Cases: A variety of historical and edge-case time zone scenarios are covered to ensure robustness. The test suite includes zones that have permanent offset changes or skipped dates:
    • Asia/Pyongyang: North Korea’s time zone shifted from UTC+9 to UTC+8:30 on August 14, 2015, then back to UTC+9 on May 5, 2018. The tests verify that a local time that fell in the gap when the clocks moved (e.g. 23:30 on May 4, 2018, which did not exist due to the jump) is handled as a gap, and that times before and after yield the correct offsets (and that Foundation and tzdb agree on those offsets).
    • America/Caracas: Venezuela used UTC-4:30 for several years and switched to UTC-4 on May 1, 2016 (eliminating the half-hour offset). The tests ensure that the overlap/transition at that date is recognized consistently.
    • Pacific/Apia (Samoa) and Pacific/Kwajalein: These zones famously skipped entire days when they moved across the International Date Line (Samoa skipped December 30, 2011, and Kwajalein skipped August 21, 1993, jumping a whole day ahead). The Foundation-based implementation is tested to handle such extreme gaps correctly. For example, it verifies that 2011-12-30 simply doesn’t exist in Apia’s local time line and is reported as a gap, with the transition to 2011-12-31 identified properly, matching tzdb results.
    • Asia/Magadan: Changed from UTC+12 to UTC+10 on October 26, 2014. Creating a 2-hour overlap.
  • API Consistency: Beyond just offset calculations, the tests also verify higher-level API consistency. They compare the results of TimeZone.atZone(LocalDateTime) (producing a ZonedDateTime) and TimeZone.atStartOfDay(LocalDate) for Foundation vs tzdb implementations across many sample dates and zones. In every case, the resulting instants or zoned date-times are equal, showing that the overall TimeZone behavior is unchanged from the user’s perspective.

All these tests passed with the final implementation, confirming that TimeZoneRulesFoundation produces identical results to the standard tzdb-based approach. The few discrepancies initially found (for example, in handling overlaps like Venezuela’s offset change) were fixed during development - the overlap detection logic was adjusted and now aligns with tzdb’s data. The extensive coverage of edge cases gives confidence that the Foundation-based strategy is robust and accurate.

Outcome

With this PR, Darwin (macOS/iOS) platforms now can use the native system time zone data for all date-time conversions, solving the tzdb access issues #485. The TimeZoneRules abstraction cleanly separates platform-specific implementations. On Apple devices, the library will seamlessly fall back to Foundation’s NSTimeZone for zone offsets and transitions, while other platforms continue using the embedded tzdb as before - all behind the scenes. This change ensures that developers can rely on kotlinx-datetime on iOS/macOS without encountering missing zone IDs or crashes, and without any API changes. The solution is validated by comprehensive tests demonstrating that the new implementation’s behavior is indistinguishable from the previous one, even in tricky scenarios. In summary, this PR provides a robust fix for time zone handling on Darwin systems and maintains consistency of results across all platforms.

@DmitryNekrasov DmitryNekrasov self-assigned this Jun 19, 2025
@DmitryNekrasov DmitryNekrasov added bug Something isn't working timezone The model and API of timezones labels Jun 19, 2025
@@ -14,7 +14,7 @@ internal class TzdbInRegistry: TimeZoneDatabase {

// TODO: starting version 1703 of Windows 10, the ICU library is also bundled, with more accurate/ timezone information.
// When Kotlin/Native drops support for Windows 7, we should investigate moving to the ICU.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@DmitryNekrasov DmitryNekrasov force-pushed the dmitry.nekrasov/bugfix/485-v2 branch 3 times, most recently from 1662d5c to bb122bb Compare June 20, 2025 13:47
@DmitryNekrasov DmitryNekrasov marked this pull request as ready for review June 20, 2025 14:47
Copy link
Collaborator

@dkhalanskyjb dkhalanskyjb left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've only taken a cursory look so far and will add more comments later, but here are some things I've already noticed.

It does look like the implementation works well, but I'll need to dive deep.

@dkhalanskyjb dkhalanskyjb self-requested a review June 23, 2025 07:31
@dkhalanskyjb dkhalanskyjb self-requested a review June 24, 2025 13:43
Copy link
Collaborator

@dkhalanskyjb dkhalanskyjb left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It troubles me that this implementation strongly relies on a contract that I haven't found stated anywhere. Have I missed some note in the Apple docs?

@DmitryNekrasov
Copy link
Contributor Author

I've rewritten the TimeZoneRulesFoundation.infoAtDatetime(LocalDateTime) implementation so that it is now similar to RecurringZoneRules.infoAtLocalDateTime() and no longer speculates on how far apart the overlaps and gaps are in time.

Copy link
Collaborator

@dkhalanskyjb dkhalanskyjb left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The one significant problem here is that the fallback does not report "UTC" as an available timezone identifier. Other than that, all I have are small stylistic suggestions. Thanks!

…tcOffset creation in TimeZoneRulesFoundation
…ndant computations in TimeZoneRulesFoundation
…s for improved clarity in `TimeZoneNativeTest`.
@DmitryNekrasov DmitryNekrasov force-pushed the dmitry.nekrasov/bugfix/485-v2 branch from bfc1725 to 59234f5 Compare June 30, 2025 14:47
Copy link
Collaborator

@dkhalanskyjb dkhalanskyjb left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this looks good!

…etime` functions in `TimeZoneRules` and `TimeZoneRulesFoundation`.
@DmitryNekrasov DmitryNekrasov merged commit 5824a70 into master Jul 1, 2025
1 check was pending
@DmitryNekrasov DmitryNekrasov deleted the dmitry.nekrasov/bugfix/485-v2 branch July 1, 2025 11:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working timezone The model and API of timezones
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants