Skip to content

Implement kotlinx-datetime for the Android native targets #344

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

Merged
merged 3 commits into from
Mar 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions core/androidNative/src/internal/TimeZoneNative.kt
Original file line number Diff line number Diff line change
@@ -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<String, TimeZoneRules?> = 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<ByteVar>(92)
val error = __system_property_get(name, result)
if (error == 0) null else result.toKString()
}
72 changes: 72 additions & 0 deletions core/androidNative/src/internal/TzdbBionic.kt
Original file line number Diff line number Diff line change
@@ -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<String, Entry>) : TimeZoneDatabase {
override fun rulesForId(id: String): TimeZoneRules =
rules[id]?.readRules() ?: throw IllegalStateException("Unknown time zone $id")

override fun availableTimeZoneIds(): Set<String> = 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<String, TzdbBionic.Entry> {
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
)) {
Comment on lines +25 to +28
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't the immutable fallback be last in list? @dkhalanskyjb

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@natario1, why? On line 42, the last put to execute will determine what gets used in the end. So, for Europe/London, for example, first, the map will receive the value from the fallback, but then, the value will be overwritten by the up-to-date timezone database. No?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, sorry for bothering then! It just felt wrong, I'm used to see 'fallbacks' at the end of lists. Thanks for working on this 🙏

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" }
}
}
}
80 changes: 41 additions & 39 deletions core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -37,47 +37,48 @@ kotlin {
explicitApi()

infra {
common("nix") {
common("tzfile") {
// Tiers are in accordance with <https://kotlinlang.org/docs/native-target-support.html>
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") {
Expand Down Expand Up @@ -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 -> {
Expand Down
13 changes: 11 additions & 2 deletions core/common/src/internal/BinaryDataReader.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
43 changes: 43 additions & 0 deletions core/tzdbOnFilesystem/src/internal/filesystem.kt
Original file line number Diff line number Diff line change
@@ -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<String> = 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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ private inline fun runUnixCommand(command: String): Sequence<String> = sequence
// read line by line
while (true) {
val linePtr = alloc<CPointerVar<ByteVar>>()
val nPtr = alloc<ULongVar>()
val nPtr = alloc<size_tVar>()
try {
val result = getline(linePtr.ptr, nPtr.ptr, pipe)
if (result != (-1).convert<ssize_t>()) {
Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -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.
*/

Expand Down Expand Up @@ -53,45 +53,12 @@ internal class Path(val isAbsolute: Boolean, val components: List<String>) {
}
}

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<String> = 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 {
Expand Down
File renamed without changes.
3 changes: 3 additions & 0 deletions license/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
25 changes: 25 additions & 0 deletions license/thirdparty/bionic_license.txt
Original file line number Diff line number Diff line change
@@ -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.