Skip to content
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

Implementation of timezone databases for Darwin in Kotlin #327

Merged
merged 8 commits into from
Feb 26, 2024
42 changes: 24 additions & 18 deletions core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -55,22 +55,28 @@ kotlin {
target("androidNativeX64")
*/
common("darwin") {
// Tier 1
target("macosX64")
target("macosArm64")
target("iosSimulatorArm64")
target("iosX64")
// Tier 2
target("watchosSimulatorArm64")
target("watchosX64")
target("watchosArm32")
target("watchosArm64")
target("tvosSimulatorArm64")
target("tvosX64")
target("tvosArm64")
target("iosArm64")
// Tier 3
target("watchosDeviceArm64")
common("darwinDevices") {
// Tier 1
target("macosX64")
target("macosArm64")
// Tier 2
target("watchosX64")
target("watchosArm32")
target("watchosArm64")
target("tvosX64")
target("tvosArm64")
target("iosArm64")
// Tier 3
target("watchosDeviceArm64")
}
common("darwinSimulator") {
// Tier 1
target("iosSimulatorArm64")
target("iosX64")
// Tier 2
target("watchosSimulatorArm64")
target("tvosSimulatorArm64")
}
}
}
// Tier 3
Expand Down Expand Up @@ -324,7 +330,7 @@ tasks {

val downloadWindowsZonesMapping by tasks.registering {
description = "Updates the mapping between Windows-specific and usual names for timezones"
val output = "$projectDir/windows/src/WindowsZoneNames.kt"
val output = "$projectDir/windows/src/internal/WindowsZoneNames.kt"
outputs.file(output)
doLast {
val initialFileContents = try { File(output).readBytes() } catch(e: Throwable) { ByteArray(0) }
Expand Down Expand Up @@ -358,7 +364,7 @@ val downloadWindowsZonesMapping by tasks.registering {
val bos = ByteArrayOutputStream()
PrintWriter(bos).use { out ->
out.println("""// generated with gradle task `$name`""")
out.println("""package kotlinx.datetime""")
out.println("""package kotlinx.datetime.internal""")
out.println("""internal val standardToWindows: Map<String, String> = mutableMapOf(""")
for ((usualName, windowsName) in sortedMapping) {
out.println(" \"$usualName\" to \"$windowsName\",")
Expand Down
11 changes: 10 additions & 1 deletion core/common/test/TimeZoneTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,24 @@ class TimeZoneTest {

@Test
fun availableZonesAreAvailable() {
val availableZones = mutableListOf<String>()
val nonAvailableZones = mutableListOf<Exception>()
for (zoneName in TimeZone.availableZoneIds) {
val timezone = try {
TimeZone.of(zoneName)
} catch (e: Exception) {
throw Exception("Zone $zoneName is not available", e)
nonAvailableZones.add(e)
continue
}
availableZones.add(zoneName)
Instant.DISTANT_FUTURE.toLocalDateTime(timezone).toInstant(timezone)
Instant.DISTANT_PAST.toLocalDateTime(timezone).toInstant(timezone)
}
if (nonAvailableZones.isNotEmpty()) {
println("Available zones: $availableZones")
println("Non-available zones: $nonAvailableZones")
throw nonAvailableZones[0]
}
}

@Test
Expand Down
12 changes: 8 additions & 4 deletions core/darwin/src/Converters.kt
Original file line number Diff line number Diff line change
Expand Up @@ -42,16 +42,20 @@ public fun NSDate.toKotlinInstant(): Instant {
*
* 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
* to [TimeZone.offset]) and the offset is not given in even minutes but also includes seconds, this method throws
* [DateTimeException] to denote that lossy conversion would happen, as Darwin internally rounds the offsets to the
* nearest minute.
* [IllegalArgumentException] to denote that lossy conversion would happen, as Darwin internally rounds the offsets
* to the nearest minute.
*
* If the time zone is unknown to the Foundation framework, [IllegalArgumentException] will be thrown.
*/
public fun TimeZone.toNSTimeZone(): NSTimeZone = if (this is FixedOffsetTimeZone) {
require (offset.totalSeconds % 60 == 0) {
require(offset.totalSeconds % 60 == 0) {
"NSTimeZone cannot represent fixed-offset time zones with offsets not expressed in whole minutes: $this"
}
NSTimeZone.timeZoneForSecondsFromGMT(offset.totalSeconds.convert())
} else {
NSTimeZone.timeZoneWithName(id) ?: NSTimeZone.timeZoneWithAbbreviation(id)!!
NSTimeZone.timeZoneWithName(id)
?: NSTimeZone.timeZoneWithAbbreviation(id)
?: throw IllegalArgumentException("The Foundation framework does not support the timezone '$id'")
}

/**
Expand Down
170 changes: 0 additions & 170 deletions core/darwin/src/TimeZoneNative.kt

This file was deleted.

69 changes: 69 additions & 0 deletions core/darwin/src/internal/TimeZoneNative.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/*
* Copyright 2019-2020 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.internal.*
import platform.Foundation.*

internal actual val systemTzdb: TimeZoneDatabase get() = tzdb.getOrThrow()

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

internal expect fun defaultTzdbPath(): String

internal actual fun currentSystemDefaultZone(): Pair<String, TimeZoneRules?> {
/* The framework has its own cache of the system timezone. Calls to
[NSTimeZone systemTimeZone] do not reflect changes to the system timezone
and instead just return the cached value. Thus, to acquire the current
system timezone, first, the cache should be cleared.

This solution is not without flaws, however. In particular, resetting the
system timezone also resets the default timezone ([NSTimeZone default]) if
it's the same as the cached system timezone:

NSTimeZone.defaultTimeZone = [NSTimeZone
timeZoneWithName: [[NSTimeZone systemTimeZone] name]];
NSLog(@"%@", NSTimeZone.defaultTimeZone.name);
NSLog(@"Change the system time zone, then press Enter");
getchar();
[NSTimeZone resetSystemTimeZone];
NSLog(@"%@", NSTimeZone.defaultTimeZone.name); // will also change

This is a fairly marginal problem:
* It is only a problem when the developer deliberately sets the default
timezone to the region that just happens to be the one that the user
is in, and then the user moves to another region, and the app also
uses the system timezone.
* Since iOS 11, the significance of the default timezone has been
de-emphasized. In particular, it is not included in the API for
Swift: https://forums.swift.org/t/autoupdating-type-properties/4608/4

Another possible solution could involve using [NSTimeZone localTimeZone].
This is documented to reflect the current, uncached system timezone on
iOS 11 and later:
https://developer.apple.com/documentation/foundation/nstimezone/1387209-localtimezone
However:
* Before iOS 11, this was the same as the default timezone and did not
reflect the system timezone.
* Worse, on a Mac (10.15.5), I failed to get it to work as documented.
NSLog(@"%@", NSTimeZone.localTimeZone.name);
NSLog(@"Change the system time zone, then press Enter");
getchar();
// [NSTimeZone resetSystemTimeZone]; // uncomment to make it work
NSLog(@"%@", NSTimeZone.localTimeZone.name);
The printed strings are the same even if I wait for good 10 minutes
before pressing Enter, unless the line with "reset" is uncommented--
then the timezone is updated, as it should be. So, for some reason,
NSTimeZone.localTimeZone, too, is cached.
With no iOS device to test this on, it doesn't seem worth the effort
to avoid just resetting the system timezone due to one edge case
that's hard to avoid.
*/
NSTimeZone.resetSystemTimeZone()
return NSTimeZone.systemTimeZone.name to null
}
7 changes: 6 additions & 1 deletion core/darwin/test/ConvertersTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,12 @@ class ConvertersTest {
if (timeZone is FixedOffsetTimeZone) {
continue
}
val nsTimeZone = timeZone.toNSTimeZone()
val nsTimeZone = try {
timeZone.toNSTimeZone()
} catch (e: IllegalArgumentException) {
assertEquals("America/Ciudad_Juarez", id)
continue
}
assertEquals(normalizedId, nsTimeZone.name)
assertEquals(timeZone, nsTimeZone.toKotlinTimeZone())
}
Expand Down
8 changes: 8 additions & 0 deletions core/darwinDevices/src/internal/TimeZoneNative.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/*
* Copyright 2019-2023 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

internal actual fun defaultTzdbPath(): String = "/var/db/timezone/zoneinfo"
8 changes: 8 additions & 0 deletions core/darwinSimulator/src/internal/TimeZoneNative.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/*
* Copyright 2019-2024 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

internal actual fun defaultTzdbPath(): String = "/usr/share/zoneinfo.default"
Loading