Skip to content

Commit 0d14fdc

Browse files
committed
Implement the timezone database for Linux in Kotlin
1 parent ebed2c3 commit 0d14fdc

18 files changed

+1556
-283
lines changed

core/build.gradle.kts

+59-66
Original file line numberDiff line numberDiff line change
@@ -37,42 +37,44 @@ kotlin {
3737
explicitApi()
3838

3939
infra {
40-
// Tiers are in accordance with <https://kotlinlang.org/docs/native-target-support.html>
41-
// Tier 1
42-
target("linuxX64")
43-
// Tier 2
44-
target("linuxArm64")
40+
common("nix") {
41+
// Tiers are in accordance with <https://kotlinlang.org/docs/native-target-support.html>
42+
common("linux") {
43+
// Tier 1
44+
target("linuxX64")
45+
// Tier 2
46+
target("linuxArm64")
47+
// Tier 4 (deprecated, but still in demand)
48+
target("linuxArm32Hfp")
49+
}
50+
// the following targets are not supported, as we don't have timezone database implementations for them:
51+
/*
52+
target("androidNativeArm32")
53+
target("androidNativeArm64")
54+
target("androidNativeX86")
55+
target("androidNativeX64")
56+
*/
57+
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")
74+
}
75+
}
4576
// Tier 3
4677
target("mingwX64")
47-
// the following targets are not supported by kotlinx.serialization:
48-
/*
49-
target("androidNativeArm32")
50-
target("androidNativeArm64")
51-
target("androidNativeX86")
52-
target("androidNativeX64")
53-
*/
54-
// Tier 4 (deprecated, but still in demand)
55-
target("linuxArm32Hfp")
56-
57-
// Darwin targets are listed separately
58-
common("darwin") {
59-
// Tier 1
60-
target("macosX64")
61-
target("macosArm64")
62-
target("iosSimulatorArm64")
63-
target("iosX64")
64-
// Tier 2
65-
target("watchosSimulatorArm64")
66-
target("watchosX64")
67-
target("watchosArm32")
68-
target("watchosArm64")
69-
target("tvosSimulatorArm64")
70-
target("tvosX64")
71-
target("tvosArm64")
72-
target("iosArm64")
73-
// Tier 3
74-
target("watchosDeviceArm64")
75-
}
7678
}
7779

7880
jvm {
@@ -138,46 +140,37 @@ kotlin {
138140
compilations["test"].kotlinOptions {
139141
freeCompilerArgs += listOf("-trw")
140142
}
141-
if (konanTarget.family.isAppleFamily) {
142-
return@withType
143-
}
144-
compilations["main"].cinterops {
145-
create("date") {
146-
val cinteropDir = "$projectDir/native/cinterop"
147-
val dateLibDir = "${project(":").projectDir}/thirdparty/date"
148-
headers("$cinteropDir/public/cdate.h")
149-
defFile("native/cinterop/date.def")
150-
extraOpts("-Xsource-compiler-option", "-I$cinteropDir/public")
151-
extraOpts("-Xsource-compiler-option", "-DONLY_C_LOCALE=1")
152-
when {
153-
konanTarget.family == org.jetbrains.kotlin.konan.target.Family.LINUX -> {
154-
// needed for the date library so that it does not try to download the timezone database
155-
extraOpts("-Xsource-compiler-option", "-DUSE_OS_TZDB=1")
156-
/* using a more modern C++ version causes the date library to use features that are not
157-
* present in the currently outdated GCC root shipped with Kotlin/Native for Linux. */
158-
extraOpts("-Xsource-compiler-option", "-std=c++11")
159-
// the date library and its headers
160-
extraOpts("-Xcompile-source", "$dateLibDir/src/tz.cpp")
161-
extraOpts("-Xsource-compiler-option", "-I$dateLibDir/include")
162-
// the main source for the platform bindings.
163-
extraOpts("-Xcompile-source", "$cinteropDir/cpp/cdate.cpp")
164-
}
165-
konanTarget.family == org.jetbrains.kotlin.konan.target.Family.MINGW -> {
143+
when {
144+
konanTarget.family == org.jetbrains.kotlin.konan.target.Family.MINGW -> {
145+
compilations["main"].cinterops {
146+
create("date") {
147+
val cinteropDir = "$projectDir/native/cinterop"
148+
val dateLibDir = "${project(":").projectDir}/thirdparty/date"
149+
headers("$cinteropDir/public/cdate.h")
150+
defFile("native/cinterop/date.def")
151+
extraOpts("-Xsource-compiler-option", "-I$cinteropDir/public")
152+
extraOpts("-Xsource-compiler-option", "-DONLY_C_LOCALE=1")
166153
// needed to be able to use std::shared_mutex to implement caching.
167154
extraOpts("-Xsource-compiler-option", "-std=c++17")
168155
// the date library headers, needed for some pure calculations.
169156
extraOpts("-Xsource-compiler-option", "-I$dateLibDir/include")
170157
// the main source for the platform bindings.
171158
extraOpts("-Xcompile-source", "$cinteropDir/cpp/windows.cpp")
172159
}
173-
else -> {
174-
throw IllegalArgumentException("Unknown native target ${this@withType}")
175-
}
160+
}
161+
compilations["main"].defaultSourceSet {
162+
kotlin.srcDir("native/cinterop_actuals")
176163
}
177164
}
178-
}
179-
compilations["main"].defaultSourceSet {
180-
kotlin.srcDir("native/cinterop_actuals")
165+
konanTarget.family == org.jetbrains.kotlin.konan.target.Family.LINUX -> {
166+
// do nothing special
167+
}
168+
konanTarget.family.isAppleFamily -> {
169+
// do nothing special
170+
}
171+
else -> {
172+
throw IllegalArgumentException("Unknown native target ${this@withType}")
173+
}
181174
}
182175
}
183176

@@ -427,4 +420,4 @@ tasks.configureEach {
427420
with(org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsRootPlugin.apply(rootProject)) {
428421
nodeVersion = "21.0.0-v8-canary202309167e82ab1fa2"
429422
nodeDownloadBaseUrl = "https://nodejs.org/download/v8-canary"
430-
}
423+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
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+
/**
9+
* A helper for reading binary data.
10+
*/
11+
internal class BinaryDataReader(private val bytes: ByteArray, private var position: Int = 0) {
12+
/**
13+
* Reads a byte.
14+
*/
15+
fun readByte(): Byte = bytes[position++]
16+
17+
/**
18+
* Reads an unsigned byte.
19+
*/
20+
fun readUnsignedByte(): UByte =
21+
readByte().toUByte()
22+
23+
/**
24+
* Reads a big-endian (network byte order) 32-bit integer.
25+
*/
26+
fun readInt(): Int =
27+
(bytes[position].toInt() and 0xFF shl 24) or
28+
(bytes[position + 1].toInt() and 0xFF shl 16) or
29+
(bytes[position + 2].toInt() and 0xFF shl 8) or
30+
(bytes[position + 3].toInt() and 0xFF).also { position += 4 }
31+
32+
/**
33+
* Reads a big-endian (network byte order) 64-bit integer.
34+
*/
35+
fun readLong(): Long =
36+
(bytes[position].toLong() and 0xFF shl 56) or
37+
(bytes[position + 1].toLong() and 0xFF shl 48) or
38+
(bytes[position + 2].toLong() and 0xFF shl 40) or
39+
(bytes[position + 3].toLong() and 0xFF shl 32) or
40+
(bytes[position + 4].toLong() and 0xFF shl 24) or
41+
(bytes[position + 5].toLong() and 0xFF shl 16) or
42+
(bytes[position + 6].toLong() and 0xFF shl 8) or
43+
(bytes[position + 7].toLong() and 0xFF).also { position += 8 }
44+
45+
fun readUtf8String(length: Int) =
46+
bytes.decodeToString(position, position + length).also { position += length }
47+
48+
fun readAsciiChar(): Char = readByte().toInt().toChar()
49+
50+
fun skip(length: Int) { position += length }
51+
}

core/common/test/TimeZoneTest.kt

+107-3
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,9 @@ class TimeZoneTest {
3535
@Test
3636
fun available() {
3737
val allTzIds = TimeZone.availableZoneIds
38-
println("Available TZs:")
39-
allTzIds.forEach(::println)
38+
assertContains(allTzIds, "Europe/Berlin")
39+
assertContains(allTzIds, "Europe/Moscow")
40+
assertContains(allTzIds, "America/New_York")
4041

4142
assertNotEquals(0, allTzIds.size)
4243
assertTrue(TimeZone.currentSystemDefault().id in allTzIds)
@@ -46,7 +47,13 @@ class TimeZoneTest {
4647
@Test
4748
fun availableZonesAreAvailable() {
4849
for (zoneName in TimeZone.availableZoneIds) {
49-
TimeZone.of(zoneName)
50+
val timezone = try {
51+
TimeZone.of(zoneName)
52+
} catch (e: Exception) {
53+
throw Exception("Zone $zoneName is not available", e)
54+
}
55+
Instant.DISTANT_FUTURE.toLocalDateTime(timezone).toInstant(timezone)
56+
Instant.DISTANT_PAST.toLocalDateTime(timezone).toInstant(timezone)
5057
}
5158
}
5259

@@ -198,6 +205,103 @@ class TimeZoneTest {
198205
check("-5", LocalDateTime(2008, 11, 2, 2, 0, 0, 0))
199206
}
200207

208+
@Test
209+
fun checkKnownTimezoneDatabaseRecords() {
210+
with(TimeZone.of("America/New_York")) {
211+
checkRegular(this, LocalDateTime(2019, 3, 8, 23, 0), UtcOffset(hours = -5))
212+
checkGap(this, LocalDateTime(2019, 3, 10, 2, 0))
213+
checkRegular(this, LocalDateTime(2019, 6, 2, 23, 0), UtcOffset(hours = -4))
214+
checkOverlap(this, LocalDateTime(2019, 11, 3, 2, 0))
215+
checkRegular(this, LocalDateTime(2019, 12, 5, 23, 0), UtcOffset(hours = -5))
216+
}
217+
with(TimeZone.of("Europe/Berlin")) {
218+
checkRegular(this, LocalDateTime(2019, 1, 31, 1, 0), UtcOffset(hours = 1))
219+
checkGap(this, LocalDateTime(2019, 3, 31, 2, 0))
220+
checkRegular(this, LocalDateTime(2019, 6, 27, 1, 0), UtcOffset(hours = 2))
221+
checkOverlap(this, LocalDateTime(2019, 10, 27, 3, 0))
222+
checkRegular(this, LocalDateTime(2019, 12, 5, 23, 0), UtcOffset(hours = 1))
223+
}
224+
with(TimeZone.of("Europe/Moscow")) {
225+
checkRegular(this, LocalDateTime(2019, 1, 31, 1, 0), UtcOffset(hours = 3))
226+
checkRegular(this, LocalDateTime(2011, 1, 31, 1, 0), UtcOffset(hours = 3))
227+
checkGap(this, LocalDateTime(2011, 3, 27, 2, 0))
228+
checkRegular(this, LocalDateTime(2011, 5, 3, 1, 0), UtcOffset(hours = 4))
229+
}
230+
with(TimeZone.of("Australia/Sydney")) {
231+
checkRegular(this, LocalDateTime(2019, 1, 31, 1, 0), UtcOffset(hours = 11))
232+
checkOverlap(this, LocalDateTime(2019, 4, 7, 3, 0))
233+
checkRegular(this, LocalDateTime(2019, 10, 6, 1, 0), UtcOffset(hours = 10))
234+
checkGap(this, LocalDateTime(2019, 10, 6, 2, 0))
235+
checkRegular(this, LocalDateTime(2019, 12, 5, 23, 0), UtcOffset(hours = 11))
236+
}
237+
}
238+
201239
private fun LocalDateTime(year: Int, monthNumber: Int, dayOfMonth: Int) = LocalDateTime(year, monthNumber, dayOfMonth, 0, 0)
202240

203241
}
242+
243+
/**
244+
* [gapStart] is the first non-existent moment.
245+
*/
246+
private fun checkGap(timeZone: TimeZone, gapStart: LocalDateTime) {
247+
val instant = gapStart.toInstant(timeZone)
248+
/** the first [LocalDateTime] after the gap */
249+
val adjusted = instant.toLocalDateTime(timeZone)
250+
try {
251+
// there is at least a one-second gap
252+
assertNotEquals(gapStart, adjusted)
253+
// the offsets before the gap are equal
254+
assertEquals(
255+
instant.offsetIn(timeZone),
256+
instant.plus(1, DateTimeUnit.SECOND).offsetIn(timeZone))
257+
// the offsets after the gap are equal
258+
assertEquals(
259+
instant.minus(1, DateTimeUnit.SECOND).offsetIn(timeZone),
260+
instant.minus(2, DateTimeUnit.SECOND).offsetIn(timeZone)
261+
)
262+
} catch (e: Throwable) {
263+
throw Exception("Didn't find a gap at $gapStart for $timeZone", e)
264+
}
265+
}
266+
267+
/**
268+
* [overlapStart] is the first non-ambiguous date-time.
269+
*/
270+
private fun checkOverlap(timeZone: TimeZone, overlapStart: LocalDateTime) {
271+
// the earlier occurrence of the overlap
272+
val instantStart = overlapStart.plusNominalSeconds(-1).toInstant(timeZone).plus(1, DateTimeUnit.SECOND)
273+
// the later occurrence of the overlap
274+
val instantEnd = overlapStart.plusNominalSeconds(1).toInstant(timeZone).minus(1, DateTimeUnit.SECOND)
275+
try {
276+
// there is at least a one-second overlap
277+
assertNotEquals(instantStart, instantEnd)
278+
// the offsets before the overlap are equal
279+
assertEquals(
280+
instantStart.minus(1, DateTimeUnit.SECOND).offsetIn(timeZone),
281+
instantStart.minus(2, DateTimeUnit.SECOND).offsetIn(timeZone)
282+
)
283+
// the offsets after the overlap are equal
284+
assertEquals(
285+
instantStart.offsetIn(timeZone),
286+
instantEnd.offsetIn(timeZone)
287+
)
288+
} catch (e: Throwable) {
289+
throw Exception("Didn't find an overlap at $overlapStart for $timeZone", e)
290+
}
291+
}
292+
293+
private fun checkRegular(timeZone: TimeZone, dateTime: LocalDateTime, offset: UtcOffset) {
294+
val instant = dateTime.toInstant(timeZone)
295+
assertEquals(offset, instant.offsetIn(timeZone))
296+
try {
297+
// not a gap:
298+
assertEquals(dateTime, instant.toLocalDateTime(timeZone))
299+
// not an overlap, or an overlap longer than one hour:
300+
assertTrue(dateTime.plusNominalSeconds(3600) <= instant.plus(1, DateTimeUnit.HOUR).toLocalDateTime(timeZone))
301+
} catch (e: Throwable) {
302+
throw Exception("The date-time at $dateTime for $timeZone was in a gap or overlap", e)
303+
}
304+
}
305+
306+
private fun LocalDateTime.plusNominalSeconds(seconds: Int): LocalDateTime =
307+
toInstant(UtcOffset.ZERO).plus(seconds, DateTimeUnit.SECOND).toLocalDateTime(UtcOffset.ZERO)

0 commit comments

Comments
 (0)