Skip to content

Commit 9533ba7

Browse files
committed
Implement the Darwin timezones using the *nix machinery
1 parent 04346e1 commit 9533ba7

File tree

7 files changed

+96
-115
lines changed

7 files changed

+96
-115
lines changed

core/build.gradle.kts

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -55,22 +55,28 @@ kotlin {
5555
target("androidNativeX64")
5656
*/
5757
common("darwin") {
58-
// Tier 1
59-
target("macosX64")
60-
target("macosArm64")
61-
target("iosSimulatorArm64")
62-
target("iosX64")
63-
// Tier 2
64-
target("watchosSimulatorArm64")
65-
target("watchosX64")
66-
target("watchosArm32")
67-
target("watchosArm64")
68-
target("tvosSimulatorArm64")
69-
target("tvosX64")
70-
target("tvosArm64")
71-
target("iosArm64")
72-
// Tier 3
73-
target("watchosDeviceArm64")
58+
common("darwinDevices") {
59+
// Tier 1
60+
target("macosX64")
61+
target("macosArm64")
62+
// Tier 2
63+
target("watchosX64")
64+
target("watchosArm32")
65+
target("watchosArm64")
66+
target("tvosX64")
67+
target("tvosArm64")
68+
target("iosArm64")
69+
// Tier 3
70+
target("watchosDeviceArm64")
71+
}
72+
common("darwinSimulator") {
73+
// Tier 1
74+
target("iosSimulatorArm64")
75+
target("iosX64")
76+
// Tier 2
77+
target("watchosSimulatorArm64")
78+
target("tvosSimulatorArm64")
79+
}
7480
}
7581
}
7682
// Tier 3

core/darwin/src/Converters.kt

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,16 +42,20 @@ public fun NSDate.toKotlinInstant(): Instant {
4242
*
4343
* If the time zone is represented as a fixed number of seconds from UTC+0 (for example, if it is the result of a call
4444
* to [TimeZone.offset]) and the offset is not given in even minutes but also includes seconds, this method throws
45-
* [DateTimeException] to denote that lossy conversion would happen, as Darwin internally rounds the offsets to the
46-
* nearest minute.
45+
* [IllegalArgumentException] to denote that lossy conversion would happen, as Darwin internally rounds the offsets
46+
* to the nearest minute.
47+
*
48+
* If the time zone is unknown to the Foundation framework, [IllegalArgumentException] will be thrown.
4749
*/
4850
public fun TimeZone.toNSTimeZone(): NSTimeZone = if (this is FixedOffsetTimeZone) {
49-
require (offset.totalSeconds % 60 == 0) {
51+
require(offset.totalSeconds % 60 == 0) {
5052
"NSTimeZone cannot represent fixed-offset time zones with offsets not expressed in whole minutes: $this"
5153
}
5254
NSTimeZone.timeZoneForSecondsFromGMT(offset.totalSeconds.convert())
5355
} else {
54-
NSTimeZone.timeZoneWithName(id) ?: NSTimeZone.timeZoneWithAbbreviation(id)!!
56+
NSTimeZone.timeZoneWithName(id)
57+
?: NSTimeZone.timeZoneWithAbbreviation(id)
58+
?: throw IllegalArgumentException("The Foundation framework does not support the timezone '$id'")
5559
}
5660

5761
/**

core/darwin/src/TimeZoneNative.kt

Lines changed: 36 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -3,39 +3,20 @@
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

6-
@file:OptIn(kotlinx.cinterop.UnsafeNumber::class)
6+
@file:OptIn(ExperimentalForeignApi::class)
77

88
package kotlinx.datetime
99

10+
import kotlinx.cinterop.*
11+
import kotlinx.datetime.internal.*
1012
import platform.Foundation.*
1113

12-
private fun dateWithTimeIntervalSince1970Saturating(epochSeconds: Long): NSDate {
13-
val date = NSDate.dateWithTimeIntervalSince1970(epochSeconds.toDouble())
14-
return when {
15-
date.timeIntervalSinceDate(NSDate.distantPast) < 0 -> NSDate.distantPast
16-
date.timeIntervalSinceDate(NSDate.distantFuture) > 0 -> NSDate.distantFuture
17-
else -> date
18-
}
19-
}
20-
21-
private fun systemDateByLocalDate(zone: NSTimeZone, localDate: NSDate): NSDate? {
22-
val iso8601 = NSCalendar.calendarWithIdentifier(NSCalendarIdentifierISO8601)!!
23-
val utc = NSTimeZone.timeZoneForSecondsFromGMT(0)
24-
/* Now, we say that the date that we initially meant is `date`, only with
25-
the context of being in a timezone `zone`. */
26-
val dateComponents = iso8601.componentsInTimeZone(utc, localDate)
27-
dateComponents.timeZone = zone
28-
return iso8601.dateFromComponents(dateComponents)
29-
}
30-
31-
internal actual class RegionTimeZone(private val value: NSTimeZone, actual override val id: String): TimeZone() {
14+
internal actual class RegionTimeZone(private val tzid: TimeZoneRules, actual override val id: String) : TimeZone() {
3215
actual companion object {
33-
actual fun of(zoneId: String): RegionTimeZone {
34-
val abbreviations = NSTimeZone.abbreviationDictionary
35-
val trueZoneId = abbreviations[zoneId] as String? ?: zoneId
36-
val zone = NSTimeZone.timeZoneWithName(trueZoneId)
37-
?: throw IllegalTimeZoneException("No timezone found with zone ID '$zoneId'")
38-
return RegionTimeZone(zone, zoneId)
16+
actual fun of(zoneId: String): RegionTimeZone = try {
17+
RegionTimeZone(tzdbOnFilesystem.rulesForId(zoneId), zoneId)
18+
} catch (e: Exception) {
19+
throw IllegalTimeZoneException("Invalid zone ID: $zoneId", e)
3920
}
4021

4122
actual fun currentSystemDefault(): RegionTimeZone {
@@ -88,83 +69,44 @@ internal actual class RegionTimeZone(private val value: NSTimeZone, actual overr
8869
*/
8970
NSTimeZone.resetSystemTimeZone()
9071
val zone = NSTimeZone.systemTimeZone
91-
return RegionTimeZone(zone, zone.name)
72+
val zoneId = zone.name
73+
return RegionTimeZone(tzdbOnFilesystem.rulesForId(zoneId), zoneId)
9274
}
9375

9476
actual val availableZoneIds: Set<String>
95-
get() {
96-
val set = mutableSetOf("UTC")
97-
val zones = NSTimeZone.knownTimeZoneNames
98-
for (zone in zones) {
99-
if (zone is NSString) {
100-
set.add(zone as String)
101-
} else throw RuntimeException("$zone is expected to be NSString")
102-
}
103-
val abbrevs = NSTimeZone.abbreviationDictionary
104-
for ((key, value) in abbrevs) {
105-
if (key is NSString && value is NSString) {
106-
if (set.contains(value as String)) {
107-
set.add(key as String)
108-
}
109-
} else throw RuntimeException("$key and $value are expected to be NSString")
110-
}
111-
return set
112-
}
77+
get() = tzdbOnFilesystem.availableTimeZoneIds()
11378
}
11479

115-
actual override fun atStartOfDay(date: LocalDate): Instant {
80+
actual override fun atStartOfDay(date: LocalDate): Instant = memScoped {
11681
val ldt = LocalDateTime(date, LocalTime.MIN)
117-
val epochSeconds = ldt.toEpochSecond(UtcOffset.ZERO)
118-
// timezone
119-
val nsDate = NSDate.dateWithTimeIntervalSince1970(epochSeconds.toDouble())
120-
val newDate = systemDateByLocalDate(value, nsDate)
121-
?: throw RuntimeException("Unable to acquire the time of start of day at $nsDate for zone $this")
122-
val offset = value.secondsFromGMTForDate(newDate).toInt()
123-
/* if `epoch_sec` is not in the range supported by Darwin, assume that it
124-
is the correct local time for the midnight and just convert it to
125-
the system time. */
126-
if (nsDate.timeIntervalSinceDate(NSDate.distantPast) < 0 ||
127-
nsDate.timeIntervalSinceDate(NSDate.distantFuture) > 0)
128-
return Instant(epochSeconds - offset, 0)
129-
// The ISO-8601 calendar.
130-
val iso8601 = NSCalendar.calendarWithIdentifier(NSCalendarIdentifierISO8601)!!
131-
iso8601.timeZone = value
132-
// start of the day denoted by `newDate`
133-
val midnight = iso8601.startOfDayForDate(newDate)
134-
return Instant(midnight.timeIntervalSince1970.toLong(), 0)
135-
}
136-
137-
actual override fun atZone(dateTime: LocalDateTime, preferred: UtcOffset?): ZonedDateTime {
138-
val epochSeconds = dateTime.toEpochSecond(UtcOffset.ZERO)
139-
var offset = preferred?.totalSeconds ?: Int.MAX_VALUE
140-
val transitionDuration = run {
141-
/* a date in an unspecified timezone, defined by the number of seconds since
142-
the start of the epoch in *that* unspecified timezone */
143-
val date = dateWithTimeIntervalSince1970Saturating(epochSeconds)
144-
val newDate = systemDateByLocalDate(value, date)
145-
?: throw RuntimeException("Unable to acquire the offset at $dateTime for zone ${this@RegionTimeZone}")
146-
// we now know the offset of that timezone at this time.
147-
offset = value.secondsFromGMTForDate(newDate).toInt()
148-
/* `dateFromComponents` automatically corrects the date to avoid gaps. We
149-
need to learn which adjustments it performed. */
150-
(newDate.timeIntervalSince1970.toLong() +
151-
offset.toLong() - date.timeIntervalSince1970.toLong()).toInt()
152-
}
153-
val correctedDateTime = try {
154-
dateTime.plusSeconds(transitionDuration)
155-
} catch (e: IllegalArgumentException) {
156-
throw DateTimeArithmeticException("Overflow whet correcting the date-time to not be in the transition gap", e)
157-
} catch (e: ArithmeticException) {
158-
throw RuntimeException("Anomalously long timezone transition gap reported", e)
82+
when (val info = tzid.infoAtDatetime(ldt)) {
83+
is OffsetInfo.Regular -> ldt.toInstant(info.offset)
84+
is OffsetInfo.Gap -> info.start
85+
is OffsetInfo.Overlap -> ldt.toInstant(info.offsetBefore)
15986
}
160-
return ZonedDateTime(correctedDateTime, this@RegionTimeZone, UtcOffset.ofSeconds(offset))
16187
}
16288

163-
actual override fun offsetAtImpl(instant: Instant): UtcOffset {
164-
val date = dateWithTimeIntervalSince1970Saturating(instant.epochSeconds)
165-
return UtcOffset.ofSeconds(value.secondsFromGMTForDate(date).toInt())
166-
}
89+
actual override fun atZone(dateTime: LocalDateTime, preferred: UtcOffset?): ZonedDateTime =
90+
when (val info = tzid.infoAtDatetime(dateTime)) {
91+
is OffsetInfo.Regular -> ZonedDateTime(dateTime, this, info.offset)
92+
is OffsetInfo.Gap -> {
93+
try {
94+
ZonedDateTime(dateTime.plusSeconds(info.transitionDurationSeconds), this, info.offsetAfter)
95+
} catch (e: IllegalArgumentException) {
96+
throw DateTimeArithmeticException(
97+
"Overflow whet correcting the date-time to not be in the transition gap",
98+
e
99+
)
100+
}
101+
}
102+
103+
is OffsetInfo.Overlap -> ZonedDateTime(dateTime, this,
104+
if (info.offsetAfter == preferred) info.offsetAfter else info.offsetBefore)
105+
}
167106

107+
actual override fun offsetAtImpl(instant: Instant): UtcOffset = tzid.infoAtInstant(instant)
168108
}
169109

170110
internal actual fun currentTime(): Instant = NSDate.date().toKotlinInstant()
111+
112+
private val tzdbOnFilesystem = TzdbOnFilesystem(Path.fromString(defaultTzdbPath()))
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/*
2+
* Copyright 2019-2024 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+
internal expect fun defaultTzdbPath(): String

core/darwin/test/ConvertersTest.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,12 @@ class ConvertersTest {
5151
if (timeZone is FixedOffsetTimeZone) {
5252
continue
5353
}
54-
val nsTimeZone = timeZone.toNSTimeZone()
54+
val nsTimeZone = try {
55+
timeZone.toNSTimeZone()
56+
} catch (e: IllegalArgumentException) {
57+
assertEquals("America/Ciudad_Juarez", id)
58+
continue
59+
}
5560
assertEquals(normalizedId, nsTimeZone.name)
5661
assertEquals(timeZone, nsTimeZone.toKotlinTimeZone())
5762
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/*
2+
* Copyright 2019-2023 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+
internal actual fun defaultTzdbPath(): String = "/var/db/timezone/zoneinfo"
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/*
2+
* Copyright 2019-2024 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+
internal actual fun defaultTzdbPath(): String = "/usr/share/zoneinfo.default"

0 commit comments

Comments
 (0)