diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 8bdec4ba2..605f0432c 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -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 @@ -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) } @@ -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 = mutableMapOf(""") for ((usualName, windowsName) in sortedMapping) { out.println(" \"$usualName\" to \"$windowsName\",") diff --git a/core/common/test/TimeZoneTest.kt b/core/common/test/TimeZoneTest.kt index 21f4618b0..c0f660d57 100644 --- a/core/common/test/TimeZoneTest.kt +++ b/core/common/test/TimeZoneTest.kt @@ -46,15 +46,24 @@ class TimeZoneTest { @Test fun availableZonesAreAvailable() { + val availableZones = mutableListOf() + val nonAvailableZones = mutableListOf() 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 diff --git a/core/darwin/src/Converters.kt b/core/darwin/src/Converters.kt index bb929687a..3c1c3999b 100644 --- a/core/darwin/src/Converters.kt +++ b/core/darwin/src/Converters.kt @@ -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'") } /** diff --git a/core/darwin/src/TimeZoneNative.kt b/core/darwin/src/TimeZoneNative.kt deleted file mode 100644 index a43c54118..000000000 --- a/core/darwin/src/TimeZoneNative.kt +++ /dev/null @@ -1,170 +0,0 @@ -/* - * 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(kotlinx.cinterop.UnsafeNumber::class) - -package kotlinx.datetime - -import platform.Foundation.* - -private fun dateWithTimeIntervalSince1970Saturating(epochSeconds: Long): NSDate { - val date = NSDate.dateWithTimeIntervalSince1970(epochSeconds.toDouble()) - return when { - date.timeIntervalSinceDate(NSDate.distantPast) < 0 -> NSDate.distantPast - date.timeIntervalSinceDate(NSDate.distantFuture) > 0 -> NSDate.distantFuture - else -> date - } -} - -private fun systemDateByLocalDate(zone: NSTimeZone, localDate: NSDate): NSDate? { - val iso8601 = NSCalendar.calendarWithIdentifier(NSCalendarIdentifierISO8601)!! - val utc = NSTimeZone.timeZoneForSecondsFromGMT(0) - /* Now, we say that the date that we initially meant is `date`, only with - the context of being in a timezone `zone`. */ - val dateComponents = iso8601.componentsInTimeZone(utc, localDate) - dateComponents.timeZone = zone - return iso8601.dateFromComponents(dateComponents) -} - -internal actual class RegionTimeZone(private val value: NSTimeZone, actual override val id: String): TimeZone() { - actual companion object { - actual fun of(zoneId: String): RegionTimeZone { - val abbreviations = NSTimeZone.abbreviationDictionary - val trueZoneId = abbreviations[zoneId] as String? ?: zoneId - val zone = NSTimeZone.timeZoneWithName(trueZoneId) - ?: throw IllegalTimeZoneException("No timezone found with zone ID '$zoneId'") - return RegionTimeZone(zone, zoneId) - } - - actual fun currentSystemDefault(): RegionTimeZone { - /* 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() - val zone = NSTimeZone.systemTimeZone - return RegionTimeZone(zone, zone.name) - } - - actual val availableZoneIds: Set - get() { - val set = mutableSetOf("UTC") - val zones = NSTimeZone.knownTimeZoneNames - for (zone in zones) { - if (zone is NSString) { - set.add(zone as String) - } else throw RuntimeException("$zone is expected to be NSString") - } - val abbrevs = NSTimeZone.abbreviationDictionary - for ((key, value) in abbrevs) { - if (key is NSString && value is NSString) { - if (set.contains(value as String)) { - set.add(key as String) - } - } else throw RuntimeException("$key and $value are expected to be NSString") - } - return set - } - } - - actual override fun atStartOfDay(date: LocalDate): Instant { - val ldt = LocalDateTime(date, LocalTime.MIN) - val epochSeconds = ldt.toEpochSecond(UtcOffset.ZERO) - // timezone - val nsDate = NSDate.dateWithTimeIntervalSince1970(epochSeconds.toDouble()) - val newDate = systemDateByLocalDate(value, nsDate) - ?: throw RuntimeException("Unable to acquire the time of start of day at $nsDate for zone $this") - val offset = value.secondsFromGMTForDate(newDate).toInt() - /* if `epoch_sec` is not in the range supported by Darwin, assume that it - is the correct local time for the midnight and just convert it to - the system time. */ - if (nsDate.timeIntervalSinceDate(NSDate.distantPast) < 0 || - nsDate.timeIntervalSinceDate(NSDate.distantFuture) > 0) - return Instant(epochSeconds - offset, 0) - // The ISO-8601 calendar. - val iso8601 = NSCalendar.calendarWithIdentifier(NSCalendarIdentifierISO8601)!! - iso8601.timeZone = value - // start of the day denoted by `newDate` - val midnight = iso8601.startOfDayForDate(newDate) - return Instant(midnight.timeIntervalSince1970.toLong(), 0) - } - - actual override fun atZone(dateTime: LocalDateTime, preferred: UtcOffset?): ZonedDateTime { - val epochSeconds = dateTime.toEpochSecond(UtcOffset.ZERO) - var offset = preferred?.totalSeconds ?: Int.MAX_VALUE - val transitionDuration = run { - /* a date in an unspecified timezone, defined by the number of seconds since - the start of the epoch in *that* unspecified timezone */ - val date = dateWithTimeIntervalSince1970Saturating(epochSeconds) - val newDate = systemDateByLocalDate(value, date) - ?: throw RuntimeException("Unable to acquire the offset at $dateTime for zone ${this@RegionTimeZone}") - // we now know the offset of that timezone at this time. - offset = value.secondsFromGMTForDate(newDate).toInt() - /* `dateFromComponents` automatically corrects the date to avoid gaps. We - need to learn which adjustments it performed. */ - (newDate.timeIntervalSince1970.toLong() + - offset.toLong() - date.timeIntervalSince1970.toLong()).toInt() - } - val correctedDateTime = try { - dateTime.plusSeconds(transitionDuration) - } catch (e: IllegalArgumentException) { - throw DateTimeArithmeticException("Overflow whet correcting the date-time to not be in the transition gap", e) - } catch (e: ArithmeticException) { - throw RuntimeException("Anomalously long timezone transition gap reported", e) - } - return ZonedDateTime(correctedDateTime, this@RegionTimeZone, UtcOffset.ofSeconds(offset)) - } - - actual override fun offsetAtImpl(instant: Instant): UtcOffset { - val date = dateWithTimeIntervalSince1970Saturating(instant.epochSeconds) - return UtcOffset.ofSeconds(value.secondsFromGMTForDate(date).toInt()) - } - -} - -internal actual fun currentTime(): Instant = NSDate.date().toKotlinInstant() diff --git a/core/darwin/src/internal/TimeZoneNative.kt b/core/darwin/src/internal/TimeZoneNative.kt new file mode 100644 index 000000000..74134336e --- /dev/null +++ b/core/darwin/src/internal/TimeZoneNative.kt @@ -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 { + /* 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 +} diff --git a/core/darwin/test/ConvertersTest.kt b/core/darwin/test/ConvertersTest.kt index bbdc87399..7074ed6ec 100644 --- a/core/darwin/test/ConvertersTest.kt +++ b/core/darwin/test/ConvertersTest.kt @@ -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()) } diff --git a/core/darwinDevices/src/internal/TimeZoneNative.kt b/core/darwinDevices/src/internal/TimeZoneNative.kt new file mode 100644 index 000000000..e3b3f2b7d --- /dev/null +++ b/core/darwinDevices/src/internal/TimeZoneNative.kt @@ -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" diff --git a/core/darwinSimulator/src/internal/TimeZoneNative.kt b/core/darwinSimulator/src/internal/TimeZoneNative.kt new file mode 100644 index 000000000..7bad88f36 --- /dev/null +++ b/core/darwinSimulator/src/internal/TimeZoneNative.kt @@ -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" diff --git a/core/linux/src/TimeZoneNative.kt b/core/linux/src/TimeZoneNative.kt deleted file mode 100644 index 0e01a99e0..000000000 --- a/core/linux/src/TimeZoneNative.kt +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright 2019-2023 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 - -import kotlinx.cinterop.* -import kotlinx.datetime.internal.* -import platform.posix.* - -internal actual class RegionTimeZone(private val tzid: TimeZoneRules, actual override val id: String) : TimeZone() { - actual companion object { - actual fun of(zoneId: String): RegionTimeZone = try { - RegionTimeZone(tzdbOnFilesystem.rulesForId(zoneId), zoneId) - } catch (e: Exception) { - throw IllegalTimeZoneException("Invalid zone ID: $zoneId", e) - } - - actual fun currentSystemDefault(): RegionTimeZone { - val zoneId = tzdbOnFilesystem.currentSystemDefault()?.second - ?: throw IllegalStateException("Failed to get the system timezone") - return of(zoneId.toString()) - } - - actual val availableZoneIds: Set - get() = tzdbOnFilesystem.availableTimeZoneIds() - } - - actual override fun atStartOfDay(date: LocalDate): Instant = memScoped { - val ldt = LocalDateTime(date, LocalTime.MIN) - when (val info = tzid.infoAtDatetime(ldt)) { - is OffsetInfo.Regular -> ldt.toInstant(info.offset) - is OffsetInfo.Gap -> info.start - is OffsetInfo.Overlap -> ldt.toInstant(info.offsetBefore) - } - } - - actual override fun atZone(dateTime: LocalDateTime, preferred: UtcOffset?): ZonedDateTime = - when (val info = tzid.infoAtDatetime(dateTime)) { - is OffsetInfo.Regular -> ZonedDateTime(dateTime, this, info.offset) - is OffsetInfo.Gap -> { - try { - ZonedDateTime(dateTime.plusSeconds(info.transitionDurationSeconds), this, info.offsetAfter) - } catch (e: IllegalArgumentException) { - throw DateTimeArithmeticException( - "Overflow whet correcting the date-time to not be in the transition gap", - e - ) - } - } - - is OffsetInfo.Overlap -> ZonedDateTime(dateTime, this, - if (info.offsetAfter == preferred) info.offsetAfter else info.offsetBefore) - } - - actual override fun offsetAtImpl(instant: Instant): UtcOffset = tzid.infoAtInstant(instant) -} - -@OptIn(UnsafeNumber::class) -internal actual fun currentTime(): Instant = memScoped { - val tm = alloc() - val error = clock_gettime(CLOCK_REALTIME, tm.ptr) - if (error != 0) { - val errorStr: String = strerror(errno)?.toKString() ?: "Unknown error" - throw IllegalStateException("Could not obtain the current clock readings from the system: $errorStr") - } - val seconds: Long = tm.tv_sec.convert() - val nanoseconds: Int = tm.tv_nsec.convert() - try { - require(nanoseconds in 0 until NANOS_PER_ONE) - return Instant(seconds, nanoseconds) - } catch (e: IllegalArgumentException) { - throw IllegalStateException("The readings from the system clock are not representable as an Instant") - } -} - -private val tzdbOnFilesystem = TzdbOnFilesystem(Path.fromString("/usr/share/zoneinfo")) diff --git a/core/linux/src/internal/TimeZoneNative.kt b/core/linux/src/internal/TimeZoneNative.kt new file mode 100644 index 000000000..8efb2944c --- /dev/null +++ b/core/linux/src/internal/TimeZoneNative.kt @@ -0,0 +1,16 @@ +/* + * Copyright 2019-2023 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. + */ + +package kotlinx.datetime.internal + +internal actual val systemTzdb: TimeZoneDatabase get() = tzdb.getOrThrow() + +private val tzdb = runCatching { TzdbOnFilesystem() } + +internal actual fun currentSystemDefaultZone(): Pair { + val zoneId = pathToSystemDefault()?.second?.toString() + ?: throw IllegalStateException("Failed to get the system timezone") + return zoneId to null +} diff --git a/core/native/src/Instant.kt b/core/native/src/Instant.kt index 3b21d33e0..aa00381c1 100644 --- a/core/native/src/Instant.kt +++ b/core/native/src/Instant.kt @@ -127,8 +127,6 @@ private const val MAX_SECOND = 31494816403199L // +1000000-12-31T23:59:59 private fun isValidInstantSecond(second: Long) = second >= MIN_SECOND && second <= MAX_SECOND -internal expect fun currentTime(): Instant - @Serializable(with = InstantIso8601Serializer::class) public actual class Instant internal constructor(public actual val epochSeconds: Long, public actual val nanosecondsOfSecond: Int) : Comparable { diff --git a/core/native/src/TimeZone.kt b/core/native/src/TimeZone.kt index 681716d74..e2b4ff21d 100644 --- a/core/native/src/TimeZone.kt +++ b/core/native/src/TimeZone.kt @@ -17,9 +17,15 @@ public actual open class TimeZone internal constructor() { public actual companion object { - public actual fun currentSystemDefault(): TimeZone = + public actual fun currentSystemDefault(): TimeZone { // TODO: probably check if currentSystemDefault name is parseable as FixedOffsetTimeZone? - RegionTimeZone.currentSystemDefault() + val (name, rules) = currentSystemDefaultZone() + return if (rules == null) { + of(name) + } else { + RegionTimeZone(rules, name) + } + } public actual val UTC: FixedOffsetTimeZone = UtcOffset.ZERO.asTimeZone() @@ -59,11 +65,15 @@ public actual open class TimeZone internal constructor() { } catch (e: DateTimeFormatException) { throw IllegalTimeZoneException(e) } - return RegionTimeZone.of(zoneId) + return try { + RegionTimeZone(systemTzdb.rulesForId(zoneId), zoneId) + } catch (e: Exception) { + throw IllegalTimeZoneException("Invalid zone ID: $zoneId", e) + } } public actual val availableZoneIds: Set - get() = RegionTimeZone.availableZoneIds + get() = systemTzdb.availableTimeZoneIds() } public actual open val id: String @@ -95,20 +105,6 @@ public actual open class TimeZone internal constructor() { override fun toString(): String = id } -internal expect class RegionTimeZone : TimeZone { - override val id: String - override fun atStartOfDay(date: LocalDate): Instant - override fun offsetAtImpl(instant: Instant): UtcOffset - override fun atZone(dateTime: LocalDateTime, preferred: UtcOffset?): ZonedDateTime - - companion object { - fun of(zoneId: String): RegionTimeZone - fun currentSystemDefault(): RegionTimeZone - val availableZoneIds: Set - } -} - - @Serializable(with = FixedOffsetTimeZoneSerializer::class) public actual class FixedOffsetTimeZone internal constructor(public actual val offset: UtcOffset, override val id: String) : TimeZone() { diff --git a/core/native/src/internal/Platform.kt b/core/native/src/internal/Platform.kt new file mode 100644 index 000000000..1c324e7cd --- /dev/null +++ b/core/native/src/internal/Platform.kt @@ -0,0 +1,27 @@ +/* + * 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 + +import kotlinx.cinterop.* +import kotlinx.datetime.* +import platform.posix.* + +internal expect val systemTzdb: TimeZoneDatabase + +internal expect fun currentSystemDefaultZone(): Pair + +@OptIn(ExperimentalForeignApi::class, UnsafeNumber::class) +internal fun currentTime(): Instant = memScoped { + val tm = alloc() + val error = clock_gettime(CLOCK_REALTIME.convert(), tm.ptr) + check(error == 0) { "Error when reading the system clock: ${strerror(errno)?.toKString() ?: "Unknown error"}" } + try { + require(tm.tv_nsec in 0 until NANOS_PER_ONE) + Instant(tm.tv_sec.convert(), tm.tv_nsec.convert()) + } catch (e: IllegalArgumentException) { + throw IllegalStateException("The readings from the system clock (${tm.tv_sec} seconds, ${tm.tv_nsec} nanoseconds) are not representable as an Instant") + } +} diff --git a/core/native/src/internal/RegionTimeZone.kt b/core/native/src/internal/RegionTimeZone.kt new file mode 100644 index 000000000..d4e7bc6b6 --- /dev/null +++ b/core/native/src/internal/RegionTimeZone.kt @@ -0,0 +1,40 @@ +/* + * 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 + +import kotlinx.datetime.* + +internal class RegionTimeZone(private val tzid: TimeZoneRules, override val id: String) : TimeZone() { + + override fun atStartOfDay(date: LocalDate): Instant { + val ldt = LocalDateTime(date, LocalTime.MIN) + return when (val info = tzid.infoAtDatetime(ldt)) { + is OffsetInfo.Regular -> ldt.toInstant(info.offset) + is OffsetInfo.Gap -> info.start + is OffsetInfo.Overlap -> ldt.toInstant(info.offsetBefore) + } + } + + override fun atZone(dateTime: LocalDateTime, preferred: UtcOffset?): ZonedDateTime = + when (val info = tzid.infoAtDatetime(dateTime)) { + is OffsetInfo.Regular -> ZonedDateTime(dateTime, this, info.offset) + is OffsetInfo.Gap -> { + try { + ZonedDateTime(dateTime.plusSeconds(info.transitionDurationSeconds), this, info.offsetAfter) + } catch (e: IllegalArgumentException) { + throw DateTimeArithmeticException( + "Overflow whet correcting the date-time to not be in the transition gap", + e + ) + } + } + + is OffsetInfo.Overlap -> ZonedDateTime(dateTime, this, + if (info.offsetAfter == preferred) info.offsetAfter else info.offsetBefore) + } + + override fun offsetAtImpl(instant: Instant): UtcOffset = tzid.infoAtInstant(instant) +} diff --git a/core/native/src/internal/TimeZoneDatabase.kt b/core/native/src/internal/TimeZoneDatabase.kt new file mode 100644 index 000000000..80d8bc253 --- /dev/null +++ b/core/native/src/internal/TimeZoneDatabase.kt @@ -0,0 +1,11 @@ +/* + * 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 interface TimeZoneDatabase { + fun rulesForId(id: String): TimeZoneRules + fun availableTimeZoneIds(): Set +} diff --git a/core/nix/src/internal/TzdbOnFilesystem.kt b/core/nix/src/internal/TzdbOnFilesystem.kt index dcf1680fd..e106419cf 100644 --- a/core/nix/src/internal/TzdbOnFilesystem.kt +++ b/core/nix/src/internal/TzdbOnFilesystem.kt @@ -5,40 +5,57 @@ package kotlinx.datetime.internal -internal class TzdbOnFilesystem(defaultTzdbPath: Path) { +internal class TzdbOnFilesystem(defaultTzdbPath: Path? = null): TimeZoneDatabase { - internal fun rulesForId(id: String): TimeZoneRules = + private val tzdbPath = tzdbPaths(defaultTzdbPath).find { + it.chaseSymlinks().check()?.isDirectory == true + } ?: throw IllegalStateException("Could not find the path to the timezone database") + + override fun rulesForId(id: String): TimeZoneRules = readTzFile(tzdbPath.resolve(Path.fromString(id)).readBytes()).toTimeZoneRules() - internal fun availableTimeZoneIds(): Set = buildSet { + override fun availableTimeZoneIds(): Set = buildSet { tzdbPath.traverseDirectory(exclude = tzdbUnneededFiles) { add(it.toString()) } } - internal fun currentSystemDefault(): Pair? { - val info = Path(true, listOf("etc", "localtime")).readLink() ?: return null - val i = info.components.indexOf("zoneinfo") - if (!info.isAbsolute || i == -1 || i == info.components.size - 1) return null - return Pair( - Path(true, info.components.subList(0, i + 1)), - Path(false, info.components.subList(i + 1, info.components.size)) - ) - } - - private val tzdbPath = defaultTzdbPath.check()?.let { defaultTzdbPath } - ?: currentSystemDefault()?.first ?: throw IllegalStateException("Could not find the path to the timezone database") - } +/** The files that sometimes lie in the `zoneinfo` directory but aren't actually time zones. */ private val tzdbUnneededFiles = setOf( + // taken from https://github.com/tzinfo/tzinfo/blob/9953fc092424d55deaea2dcdf6279943f3495724/lib/tzinfo/data_sources/zoneinfo_data_source.rb#L88C29-L97C21 + "+VERSION", + "leapseconds", + "localtime", "posix", "posixrules", + "right", + "SECURITY", + "src", + "timeconfig", + // taken from https://github.com/HowardHinnant/date/blob/ab37c362e35267d6dee02cb47760f9e9c669d3be/src/tz.cpp#L2863-L2874 "Factory", "iso3166.tab", - "right", - "+VERSION", "zone.tab", "zone1970.tab", "tzdata.zi", - "leapseconds", "leap-seconds.list" ) + +/** The directories checked for a valid timezone database. */ +internal fun tzdbPaths(defaultTzdbPath: Path?) = sequence { + defaultTzdbPath?.let { yield(it) } + // taken from https://github.com/tzinfo/tzinfo/blob/9953fc092424d55deaea2dcdf6279943f3495724/lib/tzinfo/data_sources/zoneinfo_data_source.rb#L70 + yieldAll(listOf("/usr/share/zoneinfo", "/usr/share/lib/zoneinfo", "/etc/zoneinfo").map { Path.fromString(it) }) + pathToSystemDefault()?.first?.let { yield(it) } +} + +// taken from https://github.com/HowardHinnant/date/blob/ab37c362e35267d6dee02cb47760f9e9c669d3be/src/tz.cpp#L3951-L3952 +internal fun pathToSystemDefault(): Pair? { + val info = Path(true, listOf("etc", "localtime")).chaseSymlinks() + val i = info.components.indexOf("zoneinfo") + if (!info.isAbsolute || i == -1 || i == info.components.size - 1) return null + return Pair( + Path(true, info.components.subList(0, i + 1)), + Path(false, info.components.subList(i + 1, info.components.size)) + ) +} diff --git a/core/nix/src/internal/filesystem.kt b/core/nix/src/internal/filesystem.kt index 83952a381..bea045810 100644 --- a/core/nix/src/internal/filesystem.kt +++ b/core/nix/src/internal/filesystem.kt @@ -53,6 +53,16 @@ internal class Path(val isAbsolute: Boolean, val components: List) { } } +internal fun Path.chaseSymlinks(maxDepth: Int = 100): Path { + var realPath = this + var depth = maxDepth + while (true) { + realPath = realPath.readLink() ?: break + if (depth-- == 0) throw RuntimeException("Too many levels of symbolic links") + } + return realPath +} + // `stat(2)` lists the other available fields internal interface PathInfo { val isDirectory: Boolean diff --git a/core/linux/test/TimeZoneRulesCompleteTest.kt b/core/nix/test/TimeZoneRulesCompleteTest.kt similarity index 100% rename from core/linux/test/TimeZoneRulesCompleteTest.kt rename to core/nix/test/TimeZoneRulesCompleteTest.kt diff --git a/core/windows/src/TimeZoneNative.kt b/core/windows/src/TimeZoneNative.kt deleted file mode 100644 index 95df051c9..000000000 --- a/core/windows/src/TimeZoneNative.kt +++ /dev/null @@ -1,72 +0,0 @@ -/* - * 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. - */ -@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) -package kotlinx.datetime - -import kotlinx.cinterop.* -import kotlinx.datetime.internal.* -import platform.posix.* -import platform.windows.* - -internal actual class RegionTimeZone(private val tzid: TimeZoneRules, actual override val id: String) : TimeZone() { - actual companion object { - actual fun of(zoneId: String): RegionTimeZone = try { - RegionTimeZone(tzdbInRegistry.rulesForId(zoneId), zoneId) - } catch (e: Exception) { - throw IllegalTimeZoneException("Invalid zone ID: $zoneId", e) - } - - actual fun currentSystemDefault(): RegionTimeZone { - val (name, zoneRules) = tzdbInRegistry.currentSystemDefault() - return RegionTimeZone(zoneRules, name) - } - - actual val availableZoneIds: Set - get() = tzdbInRegistry.availableTimeZoneIds() - } - - actual override fun atStartOfDay(date: LocalDate): Instant = memScoped { - val ldt = LocalDateTime(date, LocalTime.MIN) - when (val info = tzid.infoAtDatetime(ldt)) { - is OffsetInfo.Regular -> ldt.toInstant(info.offset) - is OffsetInfo.Gap -> info.start - is OffsetInfo.Overlap -> ldt.toInstant(info.offsetBefore) - } - } - - actual override fun atZone(dateTime: LocalDateTime, preferred: UtcOffset?): ZonedDateTime = - when (val info = tzid.infoAtDatetime(dateTime)) { - is OffsetInfo.Regular -> ZonedDateTime(dateTime, this, info.offset) - is OffsetInfo.Gap -> { - try { - ZonedDateTime(dateTime.plusSeconds(info.transitionDurationSeconds), this, info.offsetAfter) - } catch (e: IllegalArgumentException) { - throw DateTimeArithmeticException( - "Overflow whet correcting the date-time to not be in the transition gap", - e - ) - } - } - - is OffsetInfo.Overlap -> ZonedDateTime(dateTime, this, - if (info.offsetAfter == preferred) info.offsetAfter else info.offsetBefore) - } - - actual override fun offsetAtImpl(instant: Instant): UtcOffset = tzid.infoAtInstant(instant) -} - -private val tzdbInRegistry = TzdbInRegistry() - -internal actual fun currentTime(): Instant = memScoped { - val tm = alloc() - val error = clock_gettime(CLOCK_REALTIME, tm.ptr) - check(error == 0) { "Error when reading the system clock: ${strerror(errno)}" } - try { - require(tm.tv_nsec in 0 until NANOS_PER_ONE) - Instant(tm.tv_sec, tm.tv_nsec) - } catch (e: IllegalArgumentException) { - throw IllegalStateException("The readings from the system clock (${tm.tv_sec} seconds, ${tm.tv_nsec} nanoseconds) are not representable as an Instant") - } -} diff --git a/core/windows/src/internal/TimeZoneNative.kt b/core/windows/src/internal/TimeZoneNative.kt new file mode 100644 index 000000000..0d9462855 --- /dev/null +++ b/core/windows/src/internal/TimeZoneNative.kt @@ -0,0 +1,13 @@ +/* + * 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 val systemTzdb: TimeZoneDatabase get() = tzdbInRegistry.getOrThrow() + +internal actual fun currentSystemDefaultZone(): Pair = + tzdbInRegistry.getOrThrow().currentSystemDefault() + +private val tzdbInRegistry = runCatching { TzdbInRegistry() } diff --git a/core/windows/src/TzdbInRegistry.kt b/core/windows/src/internal/TzdbInRegistry.kt similarity index 97% rename from core/windows/src/TzdbInRegistry.kt rename to core/windows/src/internal/TzdbInRegistry.kt index 7a741ac16..6ef006198 100644 --- a/core/windows/src/TzdbInRegistry.kt +++ b/core/windows/src/internal/TzdbInRegistry.kt @@ -2,14 +2,15 @@ * 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. */ -@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) -package kotlinx.datetime +@file:OptIn(ExperimentalForeignApi::class) +package kotlinx.datetime.internal +import kotlinx.datetime.* import kotlinx.cinterop.* -import kotlinx.datetime.internal.* +import platform.posix.* import platform.windows.* -internal class TzdbInRegistry { +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. @@ -44,13 +45,13 @@ internal class TzdbInRegistry { } } - internal fun rulesForId(id: String): TimeZoneRules { + override fun rulesForId(id: String): TimeZoneRules { val standardName = standardToWindows[id] ?: throw IllegalTimeZoneException("Unknown time zone $id") return windowsToRules[standardName] ?: throw IllegalTimeZoneException("The rules for time zone $id are absent in the Windows registry") } - internal fun availableTimeZoneIds(): Set = standardToWindows.filter { + override fun availableTimeZoneIds(): Set = standardToWindows.filter { windowsToRules.containsKey(it.value) }.keys diff --git a/core/windows/src/WindowsZoneNames.kt b/core/windows/src/internal/WindowsZoneNames.kt similarity index 99% rename from core/windows/src/WindowsZoneNames.kt rename to core/windows/src/internal/WindowsZoneNames.kt index 2396f074a..4c5c16d10 100644 --- a/core/windows/src/WindowsZoneNames.kt +++ b/core/windows/src/internal/WindowsZoneNames.kt @@ -1,5 +1,5 @@ // generated with gradle task `downloadWindowsZonesMapping` -package kotlinx.datetime +package kotlinx.datetime.internal internal val standardToWindows: Map = mutableMapOf( "Africa/Abidjan" to "Greenwich Standard Time", "Africa/Accra" to "Greenwich Standard Time",