From 3bcb813b4e8c8b0e01ae762041d38da453865f84 Mon Sep 17 00:00:00 2001 From: Jesse Wilson Date: Wed, 21 Feb 2024 09:40:14 -0500 Subject: [PATCH] Handle DOS dates in Kotlin/Multiplatform (#1438) * Handle DOS dates in Kotlin/Multiplatform This might be foolish. The total amount of new code is relatively small (45 lines of code), but that code does something we don't otherwise want to be doing in Okio - date math. But this unblocks implementing ZipFileSystem in Kotlin/Native. * Don't loop in addDay --- .../src/commonTest/kotlin/okio/OkioTesting.kt | 2 + .../jvmMain/kotlin/okio/internal/-ZlibJvm.kt | 17 ++ .../jvmMain/kotlin/okio/internal/ZipFiles.kt | 21 +- okio/src/jvmTest/kotlin/okio/JvmTesting.kt | 11 + .../kotlin/okio/internal/-ZlibNative.kt | 59 ++++ .../nonJvmTest/kotlin/okio/NonJvmTesting.kt | 4 + .../zlibMain/kotlin/okio/internal/-Zlib.kt | 19 ++ .../internal/DatePartsToEpochMillisTest.kt | 261 ++++++++++++++++++ 8 files changed, 381 insertions(+), 13 deletions(-) create mode 100644 okio/src/zlibTest/kotlin/okio/internal/DatePartsToEpochMillisTest.kt diff --git a/okio/src/commonTest/kotlin/okio/OkioTesting.kt b/okio/src/commonTest/kotlin/okio/OkioTesting.kt index bc495e99e3..62ac1ce528 100644 --- a/okio/src/commonTest/kotlin/okio/OkioTesting.kt +++ b/okio/src/commonTest/kotlin/okio/OkioTesting.kt @@ -102,3 +102,5 @@ expect fun assertRelativeToFails( b: Path, sameAsNio: Boolean = true, ): IllegalArgumentException + +expect fun withUtc(block: () -> T): T diff --git a/okio/src/jvmMain/kotlin/okio/internal/-ZlibJvm.kt b/okio/src/jvmMain/kotlin/okio/internal/-ZlibJvm.kt index 791af635fa..d0e8d23cc8 100644 --- a/okio/src/jvmMain/kotlin/okio/internal/-ZlibJvm.kt +++ b/okio/src/jvmMain/kotlin/okio/internal/-ZlibJvm.kt @@ -16,6 +16,23 @@ */ package okio.internal +import java.util.Calendar +import java.util.GregorianCalendar + internal actual val DEFAULT_COMPRESSION = java.util.zip.Deflater.DEFAULT_COMPRESSION internal actual typealias CRC32 = java.util.zip.CRC32 + +internal actual fun datePartsToEpochMillis( + year: Int, + month: Int, + day: Int, + hour: Int, + minute: Int, + second: Int, +): Long { + val calendar = GregorianCalendar() + calendar.set(Calendar.MILLISECOND, 0) + calendar.set(year, month - 1, day, hour, minute, second) + return calendar.time.time +} diff --git a/okio/src/jvmMain/kotlin/okio/internal/ZipFiles.kt b/okio/src/jvmMain/kotlin/okio/internal/ZipFiles.kt index 02b6a8489a..f65e82b32e 100644 --- a/okio/src/jvmMain/kotlin/okio/internal/ZipFiles.kt +++ b/okio/src/jvmMain/kotlin/okio/internal/ZipFiles.kt @@ -16,8 +16,6 @@ */ package okio.internal -import java.util.Calendar -import java.util.GregorianCalendar import okio.BufferedSource import okio.FileMetadata import okio.FileSystem @@ -435,17 +433,14 @@ private fun dosDateTimeToEpochMillis(date: Int, time: Int): Long? { return null } - // Note that this inherits the local time zone. - val cal = GregorianCalendar() - cal.set(Calendar.MILLISECOND, 0) - val year = 1980 + (date shr 9 and 0x7f) - val month = date shr 5 and 0xf - val day = date and 0x1f - val hour = time shr 11 and 0x1f - val minute = time shr 5 and 0x3f - val second = time and 0x1f shl 1 - cal.set(year, month - 1, day, hour, minute, second) - return cal.time.time + return datePartsToEpochMillis( + year = 1980 + (date shr 9 and 0x7f), + month = date shr 5 and 0xf, + day = date and 0x1f, + hour = time shr 11 and 0x1f, + minute = time shr 5 and 0x3f, + second = time and 0x1f shl 1, + ) } private class EocdRecord( diff --git a/okio/src/jvmTest/kotlin/okio/JvmTesting.kt b/okio/src/jvmTest/kotlin/okio/JvmTesting.kt index e6b091d775..c135095f2d 100644 --- a/okio/src/jvmTest/kotlin/okio/JvmTesting.kt +++ b/okio/src/jvmTest/kotlin/okio/JvmTesting.kt @@ -15,6 +15,7 @@ */ package okio +import java.util.TimeZone import kotlin.test.assertEquals import kotlin.test.assertFailsWith import okio.Path.Companion.toOkioPath @@ -52,3 +53,13 @@ actual fun assertRelativeToFails( // Return okio. return assertFailsWith { b.relativeTo(a) } } + +actual fun withUtc(block: () -> T): T { + val original = TimeZone.getDefault() + TimeZone.setDefault(TimeZone.getTimeZone("UTC")) + try { + return block() + } finally { + TimeZone.setDefault(original) + } +} diff --git a/okio/src/nativeMain/kotlin/okio/internal/-ZlibNative.kt b/okio/src/nativeMain/kotlin/okio/internal/-ZlibNative.kt index 5bcc3f53b0..a7ee44e065 100644 --- a/okio/src/nativeMain/kotlin/okio/internal/-ZlibNative.kt +++ b/okio/src/nativeMain/kotlin/okio/internal/-ZlibNative.kt @@ -17,3 +17,62 @@ package okio.internal internal actual val DEFAULT_COMPRESSION: Int = platform.zlib.Z_DEFAULT_COMPRESSION + +/** + * Roll our own date math because Kotlin doesn't include a built-in date math API, and the + * kotlinx.datetime library doesn't offer a stable at this time. + * + * Also, we don't necessarily want to take on that dependency for Okio. + * + * This implementation assumes UTC. + * + * This code is broken for years before 1970. It doesn't implement subtraction for leap years. + * + * This code is broken for out-of-range values. For example, it doesn't correctly implement leap + * year offsets when the month is -24 or when the day is -365. + */ +internal actual fun datePartsToEpochMillis( + year: Int, + month: Int, + day: Int, + hour: Int, + minute: Int, + second: Int, +): Long { + // Make sure month is in 1..12, adding or subtracting years as necessary. + val rawMonth = month + val month = (month - 1).mod(12) + 1 + val year = year + (rawMonth - month) / 12 + + // Start with the cumulative number of days elapsed preceding the current year. + var dayCount = (year - 1970) * 365L + + // Adjust by leap years. Years that divide 4 are leap years, unless they divide 100 but not 400. + val leapYear = if (month > 2) year else year - 1 + dayCount += (leapYear - 1968) / 4 - (leapYear - 1900) / 100 + (leapYear - 1600) / 400 + + // Add the cumulative number of days elapsed preceding the current month. + dayCount += when (month) { + 1 -> 0 + 2 -> 31 + 3 -> 59 + 4 -> 90 + 5 -> 120 + 6 -> 151 + 7 -> 181 + 8 -> 212 + 9 -> 243 + 10 -> 273 + 11 -> 304 + else -> 334 + } + + // Add the cumulative number of days that precede the current day. + dayCount += (day - 1) + + // Add hours + minutes + seconds for the current day. + val hourCount = dayCount * 24 + hour + val minuteCount = hourCount * 60 + minute + val secondCount = minuteCount * 60 + second + return secondCount * 1_000L +} diff --git a/okio/src/nonJvmTest/kotlin/okio/NonJvmTesting.kt b/okio/src/nonJvmTest/kotlin/okio/NonJvmTesting.kt index a5e9a780f2..0483999b20 100644 --- a/okio/src/nonJvmTest/kotlin/okio/NonJvmTesting.kt +++ b/okio/src/nonJvmTest/kotlin/okio/NonJvmTesting.kt @@ -36,3 +36,7 @@ actual fun assertRelativeToFails( ): IllegalArgumentException { return assertFailsWith { b.relativeTo(a) } } + +actual fun withUtc(block: () -> T): T { + return block() +} diff --git a/okio/src/zlibMain/kotlin/okio/internal/-Zlib.kt b/okio/src/zlibMain/kotlin/okio/internal/-Zlib.kt index 6c42210569..b31c9e1553 100644 --- a/okio/src/zlibMain/kotlin/okio/internal/-Zlib.kt +++ b/okio/src/zlibMain/kotlin/okio/internal/-Zlib.kt @@ -17,3 +17,22 @@ package okio.internal internal expect val DEFAULT_COMPRESSION: Int + +/** + * Note that this inherits the local time zone. + * + * @param year such as 1970 or 2024 + * @param month a value in the range 1 (January) through 12 (December). + * @param day a value in the range 1 through 31. + * @param hour a value in the range 0 through 23. + * @param minute a value in the range 0 through 59. + * @param second a value in the range 0 through 59. + */ +internal expect fun datePartsToEpochMillis( + year: Int, + month: Int, + day: Int, + hour: Int, + minute: Int, + second: Int, +): Long diff --git a/okio/src/zlibTest/kotlin/okio/internal/DatePartsToEpochMillisTest.kt b/okio/src/zlibTest/kotlin/okio/internal/DatePartsToEpochMillisTest.kt new file mode 100644 index 0000000000..203404989f --- /dev/null +++ b/okio/src/zlibTest/kotlin/okio/internal/DatePartsToEpochMillisTest.kt @@ -0,0 +1,261 @@ +/* + * Copyright (C) 2024 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okio.internal + +import kotlin.test.Test +import kotlin.test.assertEquals +import okio.withUtc + +class DatePartsToEpochMillisTest { + /** + * Test every day from 1970-01-01 (epochMillis = 0) until 2200-01-01. Note that this includes the + * full range of ZIP DOS dates (1980-01-01 until 2107-12-31). + */ + @Test + fun everySingleDay() { + val dateTester = DateTester() + while (dateTester.year < 2200) { + dateTester.addDay() + dateTester.check() + } + } + + /** Test the boundaries of the ZIP DOS date format. */ + @Test + fun dosDateRange() { + assertEquals( + (365 * 10 + 2) * (24 * 60 * 60 * 1000L), + datePartsToEpochMillisUtc(year = 1980, month = 1, day = 1), + ) + assertEquals( + (365 * 138 + 33) * (24 * 60 * 60 * 1000L) - 1_000L, + datePartsToEpochMillisUtc( + year = 2107, + month = 12, + day = 31, + hour = 23, + minute = 59, + second = 59, + ), + ) + } + + @Test + fun monthOutOfBounds() { + // Month -21 is the same as March, 22 months ago. + assertEquals( + (-365 + -365 + 31 + 28) * (24 * 60 * 60 * 1000L), + datePartsToEpochMillisUtc(month = -21, day = 1), + ) + + // Month -12 is the same as December, 13 months ago. + assertEquals( + (-365 + -31) * (24 * 60 * 60 * 1000L), + datePartsToEpochMillisUtc(year = 1970, month = -12, day = 1), + ) + + // Month -11 is the same as January, 12 months ago. + assertEquals( + -365 * (24 * 60 * 60 * 1000L), + datePartsToEpochMillisUtc(year = 1970, month = -11, day = 1), + ) + + // Month -1 is the same as November, 2 months ago. + assertEquals( + (-31 + -30) * (24 * 60 * 60 * 1000L), + datePartsToEpochMillisUtc(year = 1970, month = -1, day = 1), + ) + + // Month 0 is the same as December, 1 month ago. + assertEquals( + -31 * (24 * 60 * 60 * 1000L), + datePartsToEpochMillisUtc(year = 1970, month = 0, day = 1), + ) + + // Month 13 is the same as January, 12 months from now. + assertEquals( + 365 * (24 * 60 * 60 * 1000L), + datePartsToEpochMillisUtc(year = 1970, month = 13, day = 1), + ) + + // Month 24 is the same as December, 23 months from now + assertEquals( + (365 + 365 - 31) * (24 * 60 * 60 * 1000L), + datePartsToEpochMillisUtc(year = 1970, month = 24, day = 1), + ) + + // Month 25 is the same as January, 24 months from now + assertEquals( + (365 + 365) * (24 * 60 * 60 * 1000L), + datePartsToEpochMillisUtc(year = 1970, month = 25, day = 1), + ) + } + + @Test + fun dayOutOfBounds() { + // Day -364 is the same as January 1 of the previous year. + assertEquals( + -365 * (24 * 60 * 60 * 1000L), + datePartsToEpochMillisUtc(year = 1970, month = 1, day = -364), + ) + + // Day -1 is the same as December 30 of the previous year. + assertEquals( + -2 * (24 * 60 * 60 * 1000L), + datePartsToEpochMillisUtc(year = 1970, month = 1, day = -1), + ) + + // Day 0 is the same as December 31 of the previous year. + assertEquals( + -1 * (24 * 60 * 60 * 1000L), + datePartsToEpochMillisUtc(year = 1970, month = 1, day = 0), + ) + + // Day 32 is the same as February 1. + assertEquals( + 31 * (24 * 60 * 60 * 1000L), + datePartsToEpochMillisUtc(year = 1970, month = 1, day = 32), + ) + + // Day 33 is the same as February 2. + assertEquals( + 32 * (24 * 60 * 60 * 1000L), + datePartsToEpochMillisUtc(year = 1970, month = 1, day = 33), + ) + } + + @Test + fun hourOutOfBounds() { + assertEquals( + (-24 * 60 * 60 * 1000L), + datePartsToEpochMillisUtc(hour = -24), + ) + assertEquals( + (-1 * 60 * 60 * 1000L), + datePartsToEpochMillisUtc(hour = -1), + ) + assertEquals( + (24 * 60 * 60 * 1000L), + datePartsToEpochMillisUtc(hour = 24), + ) + assertEquals( + (25 * 60 * 60 * 1000L), + datePartsToEpochMillisUtc(hour = 25), + ) + } + + @Test + fun minuteOutOfBounds() { + assertEquals( + (-1 * 60 * 1000L), + datePartsToEpochMillisUtc(minute = -1), + ) + assertEquals( + (60 * 60 * 1000L), + datePartsToEpochMillisUtc(minute = 60), + ) + assertEquals( + (61 * 60 * 1000L), + datePartsToEpochMillisUtc(minute = 61), + ) + } + + @Test + fun secondOutOfBounds() { + assertEquals( + (-1 * 1000L), + datePartsToEpochMillisUtc(hour = 0, second = -1), + ) + assertEquals( + (60 * 1000L), + datePartsToEpochMillisUtc(hour = 0, second = 60), + ) + assertEquals( + (61 * 1000L), + datePartsToEpochMillisUtc(hour = 0, second = 61), + ) + } + + private class DateTester { + var epochMillis = 0L + var year = 1970 + var month = 1 + var day = 1 + + fun addDay() { + day++ + epochMillis += 24L * 60 * 60 * 1000 + + val monthSize = when (month) { + 1 -> 31 + 2 -> { + when { + year % 400 == 0 -> 29 + year % 100 == 0 -> 28 + year % 4 == 0 -> 29 + else -> 28 + } + } + + 3 -> 31 + 4 -> 30 + 5 -> 31 + 6 -> 30 + 7 -> 31 + 8 -> 31 + 9 -> 30 + 10 -> 31 + 11 -> 30 + 12 -> 31 + else -> error("unexpected month $month") + } + + if (day > monthSize) { + day -= monthSize + month++ + if (month > 12) { + month -= 12 + year++ + } + } + } + + fun check() { + assertEquals( + expected = epochMillis, + actual = datePartsToEpochMillisUtc( + year = year, + month = month, + day = day, + ), + message = "y=$year m=$month d=$day", + ) + } + } +} + +fun datePartsToEpochMillisUtc( + year: Int = 1970, + month: Int = 1, + day: Int = 1, + hour: Int = 0, + minute: Int = 0, + second: Int = 0, +): Long { + return withUtc { + datePartsToEpochMillis(year, month, day, hour, minute, second) + } +}