diff --git a/core/androidNative/src/internal/TimeZoneNative.kt b/core/androidNative/src/internal/TimeZoneNative.kt new file mode 100644 index 000000000..08b600413 --- /dev/null +++ b/core/androidNative/src/internal/TimeZoneNative.kt @@ -0,0 +1,27 @@ +/* + * 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.internal + +import kotlinx.cinterop.* +import platform.posix.* + +internal actual val systemTzdb: TimeZoneDatabase get() = tzdb.getOrThrow() + +private val tzdb = runCatching { TzdbBionic() } + +internal actual fun currentSystemDefaultZone(): Pair = memScoped { + val name = readSystemProperty("persist.sys.timezone") + ?: throw IllegalStateException("The system property 'persist.sys.timezone' should contain the system timezone") + return name to null +} + +private fun readSystemProperty(name: String): String? = memScoped { + // see https://android.googlesource.com/platform/bionic/+/froyo/libc/include/sys/system_properties.h + val result = allocArray(92) + val error = __system_property_get(name, result) + if (error == 0) null else result.toKString() +} diff --git a/core/androidNative/src/internal/TzdbBionic.kt b/core/androidNative/src/internal/TzdbBionic.kt new file mode 100644 index 000000000..d889060bf --- /dev/null +++ b/core/androidNative/src/internal/TzdbBionic.kt @@ -0,0 +1,72 @@ +/* + * 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. + */ +/* + * Based on the bionic project. + * Copyright (C) 2017 The Android Open Source Project + */ + +package kotlinx.datetime.internal + +private class TzdbBionic(private val rules: Map) : TimeZoneDatabase { + override fun rulesForId(id: String): TimeZoneRules = + rules[id]?.readRules() ?: throw IllegalStateException("Unknown time zone $id") + + override fun availableTimeZoneIds(): Set = rules.keys + + class Entry(val file: ByteArray, val offset: Int, val length: Int) { + fun readRules(): TimeZoneRules = readTzFile(file.copyOfRange(offset, offset + length)).toTimeZoneRules() + } +} + +// see https://android.googlesource.com/platform/bionic/+/master/libc/tzcode/bionic.cpp for the format +internal fun TzdbBionic(): TimeZoneDatabase = TzdbBionic(buildMap { + for (path in listOf( + Path.fromString("/system/usr/share/zoneinfo/tzdata"), // immutable fallback tzdb + Path.fromString("/apex/com.android.tzdata/etc/tz/tzdata"), // an up-to-date tzdb, may not exist + )) { + if (path.check() == null) continue // the file does not exist + // be careful to only read each file a single time and keep many references to the same ByteArray in memory. + val content = path.readBytes() + val header = BionicTzdbHeader.parse(content) + val indexSize = header.data_offset - header.index_offset + check(indexSize % 52 == 0) { "Invalid index size: $indexSize (must be a multiple of 52)" } + val reader = BinaryDataReader(content, header.index_offset) + repeat(indexSize / 52) { + val name = reader.readNullTerminatedUtf8String(40) + val start = reader.readInt() + val length = reader.readInt() + reader.readInt() // unused + // intentionally overwrite the older entries + put(name, TzdbBionic.Entry(content, header.data_offset + start, length)) + } + } +}) + +// bionic_tzdata_header_t +private class BionicTzdbHeader( + val version: String, + val index_offset: Int, + val data_offset: Int, + val final_offset: Int, +) { + override fun toString(): String = + "BionicTzdbHeader(version='$version', index_offset=$index_offset, " + + "data_offset=$data_offset, final_offset=$final_offset)" + + companion object { + fun parse(content: ByteArray): BionicTzdbHeader = + with(BinaryDataReader(content)) { + BionicTzdbHeader( + version = readNullTerminatedUtf8String(12), + index_offset = readInt(), + data_offset = readInt(), + final_offset = readInt(), + ) + }.apply { + check(version.startsWith("tzdata") && version.length < 12) { "Unknown tzdata version: $version" } + check(index_offset <= data_offset) { "Invalid data and index offsets: $data_offset and $index_offset" } + } + } +} diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 801746e4b..9576bad56 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -37,47 +37,48 @@ kotlin { explicitApi() infra { - common("nix") { + common("tzfile") { // Tiers are in accordance with - common("linux") { - // Tier 1 - target("linuxX64") - // Tier 2 - target("linuxArm64") - // Tier 4 (deprecated, but still in demand) - target("linuxArm32Hfp") - } - // the following targets are not supported, as we don't have timezone database implementations for them: - /* - target("androidNativeArm32") - target("androidNativeArm64") - target("androidNativeX86") - target("androidNativeX64") - */ - common("darwin") { - common("darwinDevices") { + common("tzdbOnFilesystem") { + common("linux") { // Tier 1 - target("macosX64") - target("macosArm64") + target("linuxX64") // Tier 2 - target("watchosX64") - target("watchosArm32") - target("watchosArm64") - target("tvosX64") - target("tvosArm64") - target("iosArm64") - // Tier 3 - target("watchosDeviceArm64") + target("linuxArm64") + // Tier 4 (deprecated, but still in demand) + target("linuxArm32Hfp") } - common("darwinSimulator") { - // Tier 1 - target("iosSimulatorArm64") - target("iosX64") - // Tier 2 - target("watchosSimulatorArm64") - target("tvosSimulatorArm64") + common("darwin") { + 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") + } } } + common("androidNative") { + target("androidNativeArm32") + target("androidNativeArm64") + target("androidNativeX86") + target("androidNativeX64") + } } // Tier 3 common("windows") { @@ -157,10 +158,11 @@ kotlin { } } } - konanTarget.family == org.jetbrains.kotlin.konan.target.Family.LINUX -> { - // do nothing special - } - konanTarget.family.isAppleFamily -> { + + konanTarget.family == org.jetbrains.kotlin.konan.target.Family.LINUX || + konanTarget.family == org.jetbrains.kotlin.konan.target.Family.ANDROID || + konanTarget.family.isAppleFamily -> + { // do nothing special } else -> { diff --git a/core/common/src/internal/BinaryDataReader.kt b/core/common/src/internal/BinaryDataReader.kt index 9703d33b7..774f29025 100644 --- a/core/common/src/internal/BinaryDataReader.kt +++ b/core/common/src/internal/BinaryDataReader.kt @@ -42,8 +42,17 @@ internal class BinaryDataReader(private val bytes: ByteArray, private var positi (bytes[position + 6].toLong() and 0xFF shl 8) or (bytes[position + 7].toLong() and 0xFF).also { position += 8 } - fun readUtf8String(length: Int) = - bytes.decodeToString(position, position + length).also { position += length } + fun readUtf8String(exactLength: Int) = + bytes.decodeToString(position, position + exactLength).also { position += exactLength } + + fun readNullTerminatedUtf8String(fieldSize: Int): String { + var exactLength = 0 + while (position + exactLength < bytes.size && bytes[position + exactLength] != 0.toByte() && exactLength < fieldSize) { + ++exactLength + } + return bytes.decodeToString(position, position + exactLength) + .also { position += fieldSize } + } fun readAsciiChar(): Char = readByte().toInt().toChar() diff --git a/core/nix/src/internal/TzdbOnFilesystem.kt b/core/tzdbOnFilesystem/src/internal/TzdbOnFilesystem.kt similarity index 100% rename from core/nix/src/internal/TzdbOnFilesystem.kt rename to core/tzdbOnFilesystem/src/internal/TzdbOnFilesystem.kt diff --git a/core/tzdbOnFilesystem/src/internal/filesystem.kt b/core/tzdbOnFilesystem/src/internal/filesystem.kt new file mode 100644 index 000000000..3d48bfbbb --- /dev/null +++ b/core/tzdbOnFilesystem/src/internal/filesystem.kt @@ -0,0 +1,43 @@ +/* + * 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(ExperimentalForeignApi::class, UnsafeNumber::class) +package kotlinx.datetime.internal + +import kotlinx.cinterop.* +import platform.posix.* + +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 +} + +internal fun Path.traverseDirectory(exclude: Set = emptySet(), stripLeadingComponents: Int = this.components.size, actionOnFile: (Path) -> Unit) { + val handler = opendir(this.toString()) ?: return + try { + while (true) { + val entry = readdir(handler) ?: break + val name = entry.pointed.d_name.toKString() + if (name == "." || name == "..") continue + if (name in exclude) continue + val path = Path(isAbsolute, components + name) + val info = path.check() ?: continue // skip broken symlinks + if (info.isDirectory) { + if (!info.isSymlink) { + path.traverseDirectory(exclude, stripLeadingComponents, actionOnFile) + } + } else { + actionOnFile(Path(false, path.components.drop(stripLeadingComponents))) + } + } + } finally { + closedir(handler) + } +} diff --git a/core/nix/test/TimeZoneRulesCompleteTest.kt b/core/tzdbOnFilesystem/test/TimeZoneRulesCompleteTest.kt similarity index 99% rename from core/nix/test/TimeZoneRulesCompleteTest.kt rename to core/tzdbOnFilesystem/test/TimeZoneRulesCompleteTest.kt index e7b6a5c84..3aa52da47 100644 --- a/core/nix/test/TimeZoneRulesCompleteTest.kt +++ b/core/tzdbOnFilesystem/test/TimeZoneRulesCompleteTest.kt @@ -67,7 +67,7 @@ private inline fun runUnixCommand(command: String): Sequence = sequence // read line by line while (true) { val linePtr = alloc>() - val nPtr = alloc() + val nPtr = alloc() try { val result = getline(linePtr.ptr, nPtr.ptr, pipe) if (result != (-1).convert()) { diff --git a/core/nix/src/internal/Tzfile.kt b/core/tzfile/src/internal/Tzfile.kt similarity index 100% rename from core/nix/src/internal/Tzfile.kt rename to core/tzfile/src/internal/Tzfile.kt diff --git a/core/nix/src/internal/filesystem.kt b/core/tzfile/src/internal/filesystem.kt similarity index 69% rename from core/nix/src/internal/filesystem.kt rename to core/tzfile/src/internal/filesystem.kt index bea045810..090debff7 100644 --- a/core/nix/src/internal/filesystem.kt +++ b/core/tzfile/src/internal/filesystem.kt @@ -1,5 +1,5 @@ /* - * Copyright 2019-2023 JetBrains s.r.o. and contributors. + * 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. */ @@ -53,45 +53,12 @@ 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 val isSymlink: Boolean } -internal fun Path.traverseDirectory(exclude: Set = emptySet(), stripLeadingComponents: Int = this.components.size, actionOnFile: (Path) -> Unit) { - val handler = opendir(this.toString()) ?: return - try { - while (true) { - val entry = readdir(handler) ?: break - val name = entry.pointed.d_name.toKString() - if (name == "." || name == "..") continue - if (name in exclude) continue - val path = Path(isAbsolute, components + name) - val info = path.check() ?: continue // skip broken symlinks - if (info.isDirectory) { - if (!info.isSymlink) { - path.traverseDirectory(exclude, stripLeadingComponents, actionOnFile) - } - } else { - actionOnFile(Path(false, path.components.drop(stripLeadingComponents))) - } - } - } finally { - closedir(handler) - } -} - internal fun Path.readBytes(): ByteArray { val handler = fopen(this.toString(), "rb") ?: throw RuntimeException("Cannot open file $this") try { diff --git a/core/nix/test/TimeZoneRulesTest.kt b/core/tzfile/test/TimeZoneRulesTest.kt similarity index 100% rename from core/nix/test/TimeZoneRulesTest.kt rename to core/tzfile/test/TimeZoneRulesTest.kt diff --git a/core/nix/test/Util.kt b/core/tzfile/test/Util.kt similarity index 100% rename from core/nix/test/Util.kt rename to core/tzfile/test/Util.kt diff --git a/license/README.md b/license/README.md index d55b4875f..62a8d071f 100644 --- a/license/README.md +++ b/license/README.md @@ -27,5 +27,8 @@ may apply: https://raw.githubusercontent.com/unicode-org/cldr/master/common/supplemental/windowsZones.xml - License: Unicode ([license/thirdparty/unicode_license.txt](thirdparty/unicode_license.txt)) +- Path: `core/androidNative/src` + - Origin: implementation is based on the bionic project. + - License: BSD ([license/thirdparty/bionic_license.txt](thirdparty/bionic_license.txt)) [threetenbp]: thirdparty/threetenbp_license.txt diff --git a/license/thirdparty/bionic_license.txt b/license/thirdparty/bionic_license.txt new file mode 100644 index 000000000..5828550d5 --- /dev/null +++ b/license/thirdparty/bionic_license.txt @@ -0,0 +1,25 @@ +Copyright (C) 2017 The Android Open Source Project +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS +OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED +AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT +OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE.