Skip to content

Commit 978ae2f

Browse files
authored
Reimplement datetime for Darwin in pure Kotlin (#73)
The workarounds for building various platforms differently are removed in favor of the utilization of HMPP support, allowing us to replace the Objective-C++ code with Kotlin.
1 parent a372015 commit 978ae2f

15 files changed

+383
-459
lines changed

core/build.gradle.kts

+32-15
Original file line numberDiff line numberDiff line change
@@ -100,33 +100,50 @@ kotlin {
100100
}
101101

102102
targets.withType<org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget> {
103+
compilations["test"].kotlinOptions {
104+
freeCompilerArgs += listOf("-trw")
105+
}
106+
if (konanTarget.family.isAppleFamily) {
107+
return@withType
108+
}
103109
compilations["main"].cinterops {
104110
create("date") {
105111
val cinteropDir = "$projectDir/native/cinterop"
106112
val dateLibDir = "${project(":").projectDir}/thirdparty/date"
107113
headers("$cinteropDir/public/cdate.h")
108114
defFile("native/cinterop/date.def")
109-
// common options
110-
extraOpts("-Xsource-compiler-option", "-std=c++17")
111115
extraOpts("-Xsource-compiler-option", "-I$cinteropDir/public")
112-
extraOpts("-Xsource-compiler-option", "-include$cinteropDir/cpp/defines.hpp")
113-
// *nix support
114-
extraOpts("-Xcompile-source", "$dateLibDir/src/tz.cpp")
115-
extraOpts("-Xcompile-source", "$dateLibDir/src/ios.mm")
116-
extraOpts("-Xsource-compiler-option", "-I$dateLibDir/include")
117-
extraOpts("-Xcompile-source", "$cinteropDir/cpp/cdate.cpp")
118-
// iOS support
119-
extraOpts("-Xcompile-source", "$cinteropDir/cpp/apple.mm")
120-
// Windows support
121-
extraOpts("-Xcompile-source", "$cinteropDir/cpp/windows.cpp")
116+
extraOpts("-Xsource-compiler-option", "-DONLY_C_LOCALE=1")
117+
when {
118+
konanTarget.family == org.jetbrains.kotlin.konan.target.Family.LINUX -> {
119+
// needed for the date library so that it does not try to download the timezone database
120+
extraOpts("-Xsource-compiler-option", "-DUSE_OS_TZDB=1")
121+
/* using a more modern C++ version causes the date library to use features that are not
122+
* present in the currently outdated GCC root shipped with Kotlin/Native for Linux. */
123+
extraOpts("-Xsource-compiler-option", "-std=c++11")
124+
// the date library and its headers
125+
extraOpts("-Xcompile-source", "$dateLibDir/src/tz.cpp")
126+
extraOpts("-Xsource-compiler-option", "-I$dateLibDir/include")
127+
// the main source for the platform bindings.
128+
extraOpts("-Xcompile-source", "$cinteropDir/cpp/cdate.cpp")
129+
}
130+
konanTarget.family == org.jetbrains.kotlin.konan.target.Family.MINGW -> {
131+
// needed to be able to use std::shared_mutex to implement caching.
132+
extraOpts("-Xsource-compiler-option", "-std=c++17")
133+
// the date library headers, needed for some pure calculations.
134+
extraOpts("-Xsource-compiler-option", "-I$dateLibDir/include")
135+
// the main source for the platform bindings.
136+
extraOpts("-Xcompile-source", "$cinteropDir/cpp/windows.cpp")
137+
}
138+
else -> {
139+
throw IllegalArgumentException("Unknown native target ${this@withType}")
140+
}
141+
}
122142
}
123143
}
124144
compilations["main"].defaultSourceSet {
125145
kotlin.srcDir("native/cinterop_actuals")
126146
}
127-
compilations["test"].kotlinOptions {
128-
freeCompilerArgs += listOf("-trw")
129-
}
130147
}
131148

132149

core/darwin/src/TimeZoneNative.kt

+177
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
/*
2+
* Copyright 2019-2020 JetBrains s.r.o.
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
7+
8+
import platform.Foundation.*
9+
10+
private fun dateWithTimeIntervalSince1970Saturating(epochSeconds: Long): NSDate {
11+
val date = NSDate.dateWithTimeIntervalSince1970(epochSeconds.toDouble())
12+
return when {
13+
date.timeIntervalSinceDate(NSDate.distantPast) < 0 -> NSDate.distantPast
14+
date.timeIntervalSinceDate(NSDate.distantFuture) > 0 -> NSDate.distantFuture
15+
else -> date
16+
}
17+
}
18+
19+
private fun systemDateByLocalDate(zone: NSTimeZone, localDate: NSDate): NSDate? {
20+
val iso8601 = NSCalendar.calendarWithIdentifier(NSCalendarIdentifierISO8601)!!
21+
val utc = NSTimeZone.timeZoneForSecondsFromGMT(0)
22+
/* Now, we say that the date that we initially meant is `date`, only with
23+
the context of being in a timezone `zone`. */
24+
val dateComponents = iso8601.componentsInTimeZone(utc, localDate)
25+
dateComponents.timeZone = zone
26+
return iso8601.dateFromComponents(dateComponents)
27+
}
28+
29+
internal actual class PlatformTimeZoneImpl(private val value: NSTimeZone, override val id: String): TimeZoneImpl {
30+
actual companion object {
31+
actual fun of(zoneId: String): PlatformTimeZoneImpl {
32+
val abbreviations = NSTimeZone.abbreviationDictionary
33+
val trueZoneId = abbreviations[zoneId] as String? ?: zoneId
34+
val zone = NSTimeZone.timeZoneWithName(trueZoneId)
35+
?: throw IllegalTimeZoneException("No timezone found with zone ID '$zoneId'")
36+
return PlatformTimeZoneImpl(zone, zoneId)
37+
}
38+
39+
actual fun currentSystemDefault(): PlatformTimeZoneImpl {
40+
/* The framework has its own cache of the system timezone. Calls to
41+
[NSTimeZone systemTimeZone] do not reflect changes to the system timezone
42+
and instead just return the cached value. Thus, to acquire the current
43+
system timezone, first, the cache should be cleared.
44+
45+
This solution is not without flaws, however. In particular, resetting the
46+
system timezone also resets the default timezone ([NSTimeZone default]) if
47+
it's the same as the cached system timezone:
48+
49+
NSTimeZone.defaultTimeZone = [NSTimeZone
50+
timeZoneWithName: [[NSTimeZone systemTimeZone] name]];
51+
NSLog(@"%@", NSTimeZone.defaultTimeZone.name);
52+
NSLog(@"Change the system time zone, then press Enter");
53+
getchar();
54+
[NSTimeZone resetSystemTimeZone];
55+
NSLog(@"%@", NSTimeZone.defaultTimeZone.name); // will also change
56+
57+
This is a fairly marginal problem:
58+
* It is only a problem when the developer deliberately sets the default
59+
timezone to the region that just happens to be the one that the user
60+
is in, and then the user moves to another region, and the app also
61+
uses the system timezone.
62+
* Since iOS 11, the significance of the default timezone has been
63+
de-emphasized. In particular, it is not included in the API for
64+
Swift: https://forums.swift.org/t/autoupdating-type-properties/4608/4
65+
66+
Another possible solution could involve using [NSTimeZone localTimeZone].
67+
This is documented to reflect the current, uncached system timezone on
68+
iOS 11 and later:
69+
https://developer.apple.com/documentation/foundation/nstimezone/1387209-localtimezone
70+
However:
71+
* Before iOS 11, this was the same as the default timezone and did not
72+
reflect the system timezone.
73+
* Worse, on a Mac (10.15.5), I failed to get it to work as documented.
74+
NSLog(@"%@", NSTimeZone.localTimeZone.name);
75+
NSLog(@"Change the system time zone, then press Enter");
76+
getchar();
77+
// [NSTimeZone resetSystemTimeZone]; // uncomment to make it work
78+
NSLog(@"%@", NSTimeZone.localTimeZone.name);
79+
The printed strings are the same even if I wait for good 10 minutes
80+
before pressing Enter, unless the line with "reset" is uncommented--
81+
then the timezone is updated, as it should be. So, for some reason,
82+
NSTimeZone.localTimeZone, too, is cached.
83+
With no iOS device to test this on, it doesn't seem worth the effort
84+
to avoid just resetting the system timezone due to one edge case
85+
that's hard to avoid.
86+
*/
87+
NSTimeZone.resetSystemTimeZone()
88+
val zone = NSTimeZone.systemTimeZone
89+
return PlatformTimeZoneImpl(zone, zone.name)
90+
}
91+
92+
actual val availableZoneIds: Set<String>
93+
get() {
94+
val set = mutableSetOf("UTC")
95+
val zones = NSTimeZone.knownTimeZoneNames
96+
for (zone in zones) {
97+
if (zone is NSString) {
98+
set.add(zone as String)
99+
} else throw RuntimeException("$zone is expected to be NSString")
100+
}
101+
val abbrevs = NSTimeZone.abbreviationDictionary
102+
for ((key, value) in abbrevs) {
103+
if (key is NSString && value is NSString) {
104+
if (set.contains(value as String)) {
105+
set.add(key as String)
106+
}
107+
} else throw RuntimeException("$key and $value are expected to be NSString")
108+
}
109+
return set
110+
}
111+
}
112+
113+
override fun atStartOfDay(date: LocalDate): Instant {
114+
val ldt = LocalDateTime(date, LocalTime.MIN)
115+
val epochSeconds = ldt.toEpochSecond(ZoneOffsetImpl.UTC)
116+
// timezone
117+
val nsDate = NSDate.dateWithTimeIntervalSince1970(epochSeconds.toDouble())
118+
val newDate = systemDateByLocalDate(value, nsDate)
119+
?: throw RuntimeException("Unable to acquire the time of start of day at $nsDate for zone $this")
120+
val offset = value.secondsFromGMTForDate(newDate).toInt()
121+
/* if `epoch_sec` is not in the range supported by Darwin, assume that it
122+
is the correct local time for the midnight and just convert it to
123+
the system time. */
124+
if (nsDate.timeIntervalSinceDate(NSDate.distantPast) < 0 ||
125+
nsDate.timeIntervalSinceDate(NSDate.distantFuture) > 0)
126+
return Instant(epochSeconds - offset, 0)
127+
// The ISO-8601 calendar.
128+
val iso8601 = NSCalendar.calendarWithIdentifier(NSCalendarIdentifierISO8601)!!
129+
iso8601.timeZone = value
130+
// start of the day denoted by `newDate`
131+
val midnight = iso8601.startOfDayForDate(newDate)
132+
return Instant(midnight.timeIntervalSince1970.toLong(), 0)
133+
}
134+
135+
override fun LocalDateTime.atZone(preferred: ZoneOffsetImpl?): ZonedDateTime {
136+
val epochSeconds = toEpochSecond(ZoneOffsetImpl.UTC)
137+
var offset = preferred?.totalSeconds ?: Int.MAX_VALUE
138+
val transitionDuration = run {
139+
/* a date in an unspecified timezone, defined by the number of seconds since
140+
the start of the epoch in *that* unspecified timezone */
141+
val date = dateWithTimeIntervalSince1970Saturating(epochSeconds)
142+
val newDate = systemDateByLocalDate(value, date)
143+
?: throw RuntimeException("Unable to acquire the offset at ${this@atZone} for zone ${this@PlatformTimeZoneImpl}")
144+
// we now know the offset of that timezone at this time.
145+
offset = value.secondsFromGMTForDate(newDate).toInt()
146+
/* `dateFromComponents` automatically corrects the date to avoid gaps. We
147+
need to learn which adjustments it performed. */
148+
(newDate.timeIntervalSince1970.toLong() +
149+
offset.toLong() - date.timeIntervalSince1970.toLong()).toInt()
150+
}
151+
val dateTime = try {
152+
this@atZone.plusSeconds(transitionDuration)
153+
} catch (e: IllegalArgumentException) {
154+
throw DateTimeArithmeticException("Overflow whet correcting the date-time to not be in the transition gap", e)
155+
} catch (e: ArithmeticException) {
156+
throw RuntimeException("Anomalously long timezone transition gap reported", e)
157+
}
158+
return ZonedDateTime(dateTime, TimeZone(this@PlatformTimeZoneImpl), ZoneOffset.ofSeconds(offset).offset)
159+
}
160+
161+
override fun offsetAt(instant: Instant): ZoneOffsetImpl {
162+
val date = dateWithTimeIntervalSince1970Saturating(instant.epochSeconds)
163+
return ZoneOffset.ofSeconds(value.secondsFromGMTForDate(date).toInt()).offset
164+
}
165+
166+
// org.threeten.bp.ZoneId#equals
167+
override fun equals(other: Any?): Boolean =
168+
this === other || other is PlatformTimeZoneImpl && this.id == other.id
169+
170+
// org.threeten.bp.ZoneId#hashCode
171+
override fun hashCode(): Int = id.hashCode()
172+
173+
// org.threeten.bp.ZoneId#toString
174+
override fun toString(): String = id
175+
}
176+
177+
internal actual fun currentTime(): Instant = NSDate.date().toKotlinInstant()

core/darwin/test/ConvertersTest.kt

+1-1
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ class ConvertersTest {
4848
for (id in TimeZone.availableZoneIds) {
4949
val normalizedId = (NSTimeZone.abbreviationDictionary[id] ?: id) as String
5050
val timeZone = TimeZone.of(normalizedId)
51-
if (timeZone is ZoneOffset) {
51+
if (timeZone.value is ZoneOffsetImpl) {
5252
continue
5353
}
5454
val nsTimeZone = timeZone.toNSTimeZone()

0 commit comments

Comments
 (0)