From 56fbbf20bc3ae06f1bde75b508a18788299766eb Mon Sep 17 00:00:00 2001 From: Drew Hamilton Date: Fri, 3 Nov 2023 13:00:36 -0700 Subject: [PATCH 01/11] Add DateTimeConverter to runtime to handle converting to ICU Calendar --- runtime/build.gradle.kts | 6 + .../paraphrase/AndroidDateTimeConverter.kt | 128 +++++++++++++++++ .../app/cash/paraphrase/DateTimeConverter.kt | 81 +++++++++++ sample/library/build.gradle.kts | 4 + .../sample/library/JvmDateTimeConverter.kt | 130 ++++++++++++++++++ 5 files changed, 349 insertions(+) create mode 100644 runtime/src/main/java/app/cash/paraphrase/AndroidDateTimeConverter.kt create mode 100644 runtime/src/main/java/app/cash/paraphrase/DateTimeConverter.kt create mode 100644 sample/library/src/test/kotlin/app/cash/paraphrase/sample/library/JvmDateTimeConverter.kt diff --git a/runtime/build.gradle.kts b/runtime/build.gradle.kts index 67cc051f..d3a3b12a 100644 --- a/runtime/build.gradle.kts +++ b/runtime/build.gradle.kts @@ -14,11 +14,17 @@ android { defaultConfig { minSdk = 24 } + + compileOptions { + isCoreLibraryDesugaringEnabled = true + } } dependencies { api(libs.androidAnnotation) + coreLibraryDesugaring(libs.coreLibraryDesugaring) + testImplementation(libs.junit) testImplementation(libs.truth) } diff --git a/runtime/src/main/java/app/cash/paraphrase/AndroidDateTimeConverter.kt b/runtime/src/main/java/app/cash/paraphrase/AndroidDateTimeConverter.kt new file mode 100644 index 00000000..845ffdd2 --- /dev/null +++ b/runtime/src/main/java/app/cash/paraphrase/AndroidDateTimeConverter.kt @@ -0,0 +1,128 @@ +/* + * Copyright (C) 2023 Cash App + * + * 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 app.cash.paraphrase + +import android.icu.util.Calendar +import android.icu.util.TimeZone +import android.icu.util.ULocale +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.OffsetDateTime +import java.time.OffsetTime +import java.time.ZoneOffset +import java.time.ZonedDateTime + +/** + * Converts `java.time` types used by Paraphrase to a [Calendar] that can be used by ICU to format. + */ +public object AndroidDateTimeConverter : DateTimeConverter { + + private val Iso8601Locale = ULocale.Builder() + .setExtension('u', "ca-iso8601") + .build() + + override fun createDateCalendar(date: LocalDate): Calendar { + return Calendar.getInstance( + TimeZone.GMT_ZONE, + Iso8601Locale, + ).apply { + set(date.year, date.monthValue - 1, date.dayOfMonth) + } + } + + override fun createOffsetTimeCalendar(time: OffsetTime): Calendar { + return Calendar.getInstance( + TimeZone.getTimeZone("GMT${time.offset.id}"), + Iso8601Locale, + ).apply { + set(Calendar.HOUR_OF_DAY, time.hour) + set(Calendar.MINUTE, time.minute) + set(Calendar.SECOND, time.second) + set(Calendar.MILLISECOND, time.nano / 1_000_000) + } + } + + override fun createLocalTimeCalendar(time: LocalTime): Calendar { + return Calendar.getInstance( + TimeZone.GMT_ZONE, + Iso8601Locale, + ).apply { + set(Calendar.HOUR_OF_DAY, time.hour) + set(Calendar.MINUTE, time.minute) + set(Calendar.SECOND, time.second) + set(Calendar.MILLISECOND, time.nano / 1_000_000) + } + } + + override fun createZonedDateTimeCalendar(dateTime: ZonedDateTime): Calendar { + return Calendar.getInstance( + TimeZone.getTimeZone(dateTime.zone.id), + Iso8601Locale, + ).apply { + set( + dateTime.year, + dateTime.monthValue - 1, + dateTime.dayOfMonth, + dateTime.hour, + dateTime.minute, + dateTime.second, + ) + set(Calendar.MILLISECOND, dateTime.nano / 1_000_000) + } + } + + override fun createOffsetDateTimeCalendar(dateTime: OffsetDateTime): Calendar { + return Calendar.getInstance( + TimeZone.getTimeZone("GMT${dateTime.offset.id}"), + Iso8601Locale, + ).apply { + set( + dateTime.year, + dateTime.monthValue - 1, + dateTime.dayOfMonth, + dateTime.hour, + dateTime.minute, + dateTime.second, + ) + set(Calendar.MILLISECOND, dateTime.nano / 1_000_000) + } + } + + override fun createLocalDateTimeCalendar(dateTime: LocalDateTime): Calendar { + return Calendar.getInstance( + TimeZone.GMT_ZONE, + Iso8601Locale, + ).apply { + set( + dateTime.year, + dateTime.monthValue - 1, + dateTime.dayOfMonth, + dateTime.hour, + dateTime.minute, + dateTime.second, + ) + set(Calendar.MILLISECOND, dateTime.nano / 1_000_000) + } + } + + override fun createZoneOffsetCalendar(zoneOffset: ZoneOffset): Calendar { + return Calendar.getInstance( + TimeZone.getTimeZone("GMT${zoneOffset.id}"), + Iso8601Locale, + ) + } +} diff --git a/runtime/src/main/java/app/cash/paraphrase/DateTimeConverter.kt b/runtime/src/main/java/app/cash/paraphrase/DateTimeConverter.kt new file mode 100644 index 00000000..350124d5 --- /dev/null +++ b/runtime/src/main/java/app/cash/paraphrase/DateTimeConverter.kt @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2023 Cash App + * + * 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 app.cash.paraphrase + +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.OffsetDateTime +import java.time.OffsetTime +import java.time.ZoneOffset +import java.time.ZonedDateTime + +/** + * Converts `java.time` types used by Paraphrase to a [Calendar] that can be used by ICU to format. + * + * [Calendar] is generic so the system-appropriate ICU calendar implementation can be used: + * `android.icu.util` on Android, or `com.ibm.icu` on the JVM. + */ +public interface DateTimeConverter { + + /** + * Converts [date] to a [Calendar] used by ICU to format. + * + * The resulting calendar's time fields are undefined and its time zone is GMT. These are ignored + * by the formatter. + */ + public fun createDateCalendar(date: LocalDate): Calendar + + /** + * Converts [time] to a [Calendar] used by ICU to format. + * + * The resulting calendar's date fields are undefined. They are ignored by the formatter. + */ + public fun createOffsetTimeCalendar(time: OffsetTime): Calendar + + /** + * Converts [time] to a [Calendar] used by ICU to format. + * + * The resulting calendar's date fields are undefined and its time zone is GMT. These are ignored + * by the formatter. + */ + public fun createLocalTimeCalendar(time: LocalTime): Calendar + + /** + * Converts [dateTime] to a [Calendar] used by ICU to format. + */ + public fun createZonedDateTimeCalendar(dateTime: ZonedDateTime): Calendar + + /** + * Converts [dateTime] to a [Calendar] used by ICU to format. + */ + public fun createOffsetDateTimeCalendar(dateTime: OffsetDateTime): Calendar + + /** + * Converts [dateTime] to a [Calendar] used by ICU to format. + * + * The resulting calendar's time zone is GMT. This is ignored by the formatter. + */ + public fun createLocalDateTimeCalendar(dateTime: LocalDateTime): Calendar + + /** + * Converts [zoneOffset] to a [Calendar] used by ICU to format. + * + * The resulting calendar's date and time fields are undefined. These are ignored by the + * formatter. + */ + public fun createZoneOffsetCalendar(zoneOffset: ZoneOffset): Calendar +} diff --git a/sample/library/build.gradle.kts b/sample/library/build.gradle.kts index e68a51c2..1ae9139b 100644 --- a/sample/library/build.gradle.kts +++ b/sample/library/build.gradle.kts @@ -21,3 +21,7 @@ androidComponents { } } } + +dependencies { + testImplementation(libs.icu4j) +} diff --git a/sample/library/src/test/kotlin/app/cash/paraphrase/sample/library/JvmDateTimeConverter.kt b/sample/library/src/test/kotlin/app/cash/paraphrase/sample/library/JvmDateTimeConverter.kt new file mode 100644 index 00000000..ee93955f --- /dev/null +++ b/sample/library/src/test/kotlin/app/cash/paraphrase/sample/library/JvmDateTimeConverter.kt @@ -0,0 +1,130 @@ +/* + * Copyright (C) 2023 Cash App + * + * 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 app.cash.paraphrase.sample.library + +import app.cash.paraphrase.DateTimeConverter +import com.ibm.icu.util.Calendar +import com.ibm.icu.util.TimeZone +import com.ibm.icu.util.ULocale +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.OffsetDateTime +import java.time.OffsetTime +import java.time.ZoneOffset +import java.time.ZonedDateTime + +/** + * Converts `java.time` types used by Paraphrase to a [Calendar] that can be used by ICU to format. + */ +// TODO: Ship this in a new artifact? +object JvmDateTimeConverter : DateTimeConverter { + + private val Iso8601Locale = ULocale.Builder() + .setExtension('u', "ca-iso8601") + .build() + + override fun createDateCalendar(date: LocalDate): Calendar { + return Calendar.getInstance( + TimeZone.GMT_ZONE, + Iso8601Locale, + ).apply { + set(date.year, date.monthValue - 1, date.dayOfMonth) + } + } + + override fun createOffsetTimeCalendar(time: OffsetTime): Calendar { + return Calendar.getInstance( + TimeZone.getTimeZone("GMT${time.offset.id}"), + Iso8601Locale, + ).apply { + set(Calendar.HOUR_OF_DAY, time.hour) + set(Calendar.MINUTE, time.minute) + set(Calendar.SECOND, time.second) + set(Calendar.MILLISECOND, time.nano / 1_000_000) + } + } + + override fun createLocalTimeCalendar(time: LocalTime): Calendar { + return Calendar.getInstance( + TimeZone.GMT_ZONE, + Iso8601Locale, + ).apply { + set(Calendar.HOUR_OF_DAY, time.hour) + set(Calendar.MINUTE, time.minute) + set(Calendar.SECOND, time.second) + set(Calendar.MILLISECOND, time.nano / 1_000_000) + } + } + + override fun createZonedDateTimeCalendar(dateTime: ZonedDateTime): Calendar { + return Calendar.getInstance( + TimeZone.getTimeZone(dateTime.zone.id), + Iso8601Locale, + ).apply { + set( + dateTime.year, + dateTime.monthValue - 1, + dateTime.dayOfMonth, + dateTime.hour, + dateTime.minute, + dateTime.second, + ) + set(Calendar.MILLISECOND, dateTime.nano / 1_000_000) + } + } + + override fun createOffsetDateTimeCalendar(dateTime: OffsetDateTime): Calendar { + return Calendar.getInstance( + TimeZone.getTimeZone("GMT${dateTime.offset.id}"), + Iso8601Locale, + ).apply { + set( + dateTime.year, + dateTime.monthValue - 1, + dateTime.dayOfMonth, + dateTime.hour, + dateTime.minute, + dateTime.second, + ) + set(Calendar.MILLISECOND, dateTime.nano / 1_000_000) + } + } + + override fun createLocalDateTimeCalendar(dateTime: LocalDateTime): Calendar { + return Calendar.getInstance( + TimeZone.GMT_ZONE, + Iso8601Locale, + ).apply { + set( + dateTime.year, + dateTime.monthValue - 1, + dateTime.dayOfMonth, + dateTime.hour, + dateTime.minute, + dateTime.second, + ) + set(Calendar.MILLISECOND, dateTime.nano / 1_000_000) + } + } + + override fun createZoneOffsetCalendar(zoneOffset: ZoneOffset): Calendar { + return Calendar.getInstance( + TimeZone.getTimeZone("GMT${zoneOffset.id}"), + Iso8601Locale, + ) + } +} From fbc6f5ea9b9a71f69d19bb5dcfe018b2e1f734b5 Mon Sep 17 00:00:00 2001 From: Drew Hamilton Date: Fri, 3 Nov 2023 14:05:50 -0700 Subject: [PATCH 02/11] Generate DateTimeConverter instance in FormattedResources --- .../app/cash/paraphrase/plugin/ResourceWriter.kt | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/plugin/src/main/java/app/cash/paraphrase/plugin/ResourceWriter.kt b/plugin/src/main/java/app/cash/paraphrase/plugin/ResourceWriter.kt index af709d64..3c3d0af8 100644 --- a/plugin/src/main/java/app/cash/paraphrase/plugin/ResourceWriter.kt +++ b/plugin/src/main/java/app/cash/paraphrase/plugin/ResourceWriter.kt @@ -28,6 +28,7 @@ import com.squareup.kotlinpoet.KModifier import com.squareup.kotlinpoet.NOTHING import com.squareup.kotlinpoet.ParameterSpec import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import com.squareup.kotlinpoet.PropertySpec import com.squareup.kotlinpoet.STRING import com.squareup.kotlinpoet.TypeName import com.squareup.kotlinpoet.TypeSpec @@ -62,6 +63,17 @@ internal fun writeResources( .addType( TypeSpec.objectBuilder("FormattedResources") .apply { + addProperty( + PropertySpec.builder( + name = "dateTimeConverter", + type = Types.DateTimeConverter.parameterizedBy(ANY.copy()).copy(), + ) + .addModifiers(KModifier.INTERNAL) + .mutable(true) + .initializer("%T", Types.AndroidDateTimeConverter) + .build() + ) + mergedResources.forEach { mergedResource -> val funSpec = mergedResource.toFunSpec(packageStringsType) addFunction(funSpec) @@ -278,8 +290,10 @@ private fun MergedResource.toIntOverloadFunSpec(overloaded: FunSpec): FunSpec { } private object Types { + val AndroidDateTimeConverter = ClassName("app.cash.paraphrase", "AndroidDateTimeConverter") val ArrayMap = ClassName("androidx.collection", "ArrayMap") val Calendar = ClassName("android.icu.util", "Calendar") + val DateTimeConverter = ClassName("app.cash.paraphrase", "DateTimeConverter") val FormattedResource = ClassName("app.cash.paraphrase", "FormattedResource") val TimeZone = ClassName("android.icu.util", "TimeZone") val ULocale = ClassName("android.icu.util", "ULocale") From 4f23e4693b60d520fef9454d518c1d99bcf02ebd Mon Sep 17 00:00:00 2001 From: Drew Hamilton Date: Fri, 3 Nov 2023 14:13:27 -0700 Subject: [PATCH 03/11] Rename all functions to `convertToCalendar` --- .../cash/paraphrase/AndroidDateTimeConverter.kt | 14 +++++++------- .../java/app/cash/paraphrase/DateTimeConverter.kt | 14 +++++++------- .../sample/library/JvmDateTimeConverter.kt | 14 +++++++------- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/runtime/src/main/java/app/cash/paraphrase/AndroidDateTimeConverter.kt b/runtime/src/main/java/app/cash/paraphrase/AndroidDateTimeConverter.kt index 845ffdd2..6d7cbeb8 100644 --- a/runtime/src/main/java/app/cash/paraphrase/AndroidDateTimeConverter.kt +++ b/runtime/src/main/java/app/cash/paraphrase/AndroidDateTimeConverter.kt @@ -35,7 +35,7 @@ public object AndroidDateTimeConverter : DateTimeConverter { .setExtension('u', "ca-iso8601") .build() - override fun createDateCalendar(date: LocalDate): Calendar { + override fun convertToCalendar(date: LocalDate): Calendar { return Calendar.getInstance( TimeZone.GMT_ZONE, Iso8601Locale, @@ -44,7 +44,7 @@ public object AndroidDateTimeConverter : DateTimeConverter { } } - override fun createOffsetTimeCalendar(time: OffsetTime): Calendar { + override fun convertToCalendar(time: OffsetTime): Calendar { return Calendar.getInstance( TimeZone.getTimeZone("GMT${time.offset.id}"), Iso8601Locale, @@ -56,7 +56,7 @@ public object AndroidDateTimeConverter : DateTimeConverter { } } - override fun createLocalTimeCalendar(time: LocalTime): Calendar { + override fun convertToCalendar(time: LocalTime): Calendar { return Calendar.getInstance( TimeZone.GMT_ZONE, Iso8601Locale, @@ -68,7 +68,7 @@ public object AndroidDateTimeConverter : DateTimeConverter { } } - override fun createZonedDateTimeCalendar(dateTime: ZonedDateTime): Calendar { + override fun convertToCalendar(dateTime: ZonedDateTime): Calendar { return Calendar.getInstance( TimeZone.getTimeZone(dateTime.zone.id), Iso8601Locale, @@ -85,7 +85,7 @@ public object AndroidDateTimeConverter : DateTimeConverter { } } - override fun createOffsetDateTimeCalendar(dateTime: OffsetDateTime): Calendar { + override fun convertToCalendar(dateTime: OffsetDateTime): Calendar { return Calendar.getInstance( TimeZone.getTimeZone("GMT${dateTime.offset.id}"), Iso8601Locale, @@ -102,7 +102,7 @@ public object AndroidDateTimeConverter : DateTimeConverter { } } - override fun createLocalDateTimeCalendar(dateTime: LocalDateTime): Calendar { + override fun convertToCalendar(dateTime: LocalDateTime): Calendar { return Calendar.getInstance( TimeZone.GMT_ZONE, Iso8601Locale, @@ -119,7 +119,7 @@ public object AndroidDateTimeConverter : DateTimeConverter { } } - override fun createZoneOffsetCalendar(zoneOffset: ZoneOffset): Calendar { + override fun convertToCalendar(zoneOffset: ZoneOffset): Calendar { return Calendar.getInstance( TimeZone.getTimeZone("GMT${zoneOffset.id}"), Iso8601Locale, diff --git a/runtime/src/main/java/app/cash/paraphrase/DateTimeConverter.kt b/runtime/src/main/java/app/cash/paraphrase/DateTimeConverter.kt index 350124d5..a86e04db 100644 --- a/runtime/src/main/java/app/cash/paraphrase/DateTimeConverter.kt +++ b/runtime/src/main/java/app/cash/paraphrase/DateTimeConverter.kt @@ -37,14 +37,14 @@ public interface DateTimeConverter { * The resulting calendar's time fields are undefined and its time zone is GMT. These are ignored * by the formatter. */ - public fun createDateCalendar(date: LocalDate): Calendar + public fun convertToCalendar(date: LocalDate): Calendar /** * Converts [time] to a [Calendar] used by ICU to format. * * The resulting calendar's date fields are undefined. They are ignored by the formatter. */ - public fun createOffsetTimeCalendar(time: OffsetTime): Calendar + public fun convertToCalendar(time: OffsetTime): Calendar /** * Converts [time] to a [Calendar] used by ICU to format. @@ -52,24 +52,24 @@ public interface DateTimeConverter { * The resulting calendar's date fields are undefined and its time zone is GMT. These are ignored * by the formatter. */ - public fun createLocalTimeCalendar(time: LocalTime): Calendar + public fun convertToCalendar(time: LocalTime): Calendar /** * Converts [dateTime] to a [Calendar] used by ICU to format. */ - public fun createZonedDateTimeCalendar(dateTime: ZonedDateTime): Calendar + public fun convertToCalendar(dateTime: ZonedDateTime): Calendar /** * Converts [dateTime] to a [Calendar] used by ICU to format. */ - public fun createOffsetDateTimeCalendar(dateTime: OffsetDateTime): Calendar + public fun convertToCalendar(dateTime: OffsetDateTime): Calendar /** * Converts [dateTime] to a [Calendar] used by ICU to format. * * The resulting calendar's time zone is GMT. This is ignored by the formatter. */ - public fun createLocalDateTimeCalendar(dateTime: LocalDateTime): Calendar + public fun convertToCalendar(dateTime: LocalDateTime): Calendar /** * Converts [zoneOffset] to a [Calendar] used by ICU to format. @@ -77,5 +77,5 @@ public interface DateTimeConverter { * The resulting calendar's date and time fields are undefined. These are ignored by the * formatter. */ - public fun createZoneOffsetCalendar(zoneOffset: ZoneOffset): Calendar + public fun convertToCalendar(zoneOffset: ZoneOffset): Calendar } diff --git a/sample/library/src/test/kotlin/app/cash/paraphrase/sample/library/JvmDateTimeConverter.kt b/sample/library/src/test/kotlin/app/cash/paraphrase/sample/library/JvmDateTimeConverter.kt index ee93955f..384cffbd 100644 --- a/sample/library/src/test/kotlin/app/cash/paraphrase/sample/library/JvmDateTimeConverter.kt +++ b/sample/library/src/test/kotlin/app/cash/paraphrase/sample/library/JvmDateTimeConverter.kt @@ -37,7 +37,7 @@ object JvmDateTimeConverter : DateTimeConverter { .setExtension('u', "ca-iso8601") .build() - override fun createDateCalendar(date: LocalDate): Calendar { + override fun convertToCalendar(date: LocalDate): Calendar { return Calendar.getInstance( TimeZone.GMT_ZONE, Iso8601Locale, @@ -46,7 +46,7 @@ object JvmDateTimeConverter : DateTimeConverter { } } - override fun createOffsetTimeCalendar(time: OffsetTime): Calendar { + override fun convertToCalendar(time: OffsetTime): Calendar { return Calendar.getInstance( TimeZone.getTimeZone("GMT${time.offset.id}"), Iso8601Locale, @@ -58,7 +58,7 @@ object JvmDateTimeConverter : DateTimeConverter { } } - override fun createLocalTimeCalendar(time: LocalTime): Calendar { + override fun convertToCalendar(time: LocalTime): Calendar { return Calendar.getInstance( TimeZone.GMT_ZONE, Iso8601Locale, @@ -70,7 +70,7 @@ object JvmDateTimeConverter : DateTimeConverter { } } - override fun createZonedDateTimeCalendar(dateTime: ZonedDateTime): Calendar { + override fun convertToCalendar(dateTime: ZonedDateTime): Calendar { return Calendar.getInstance( TimeZone.getTimeZone(dateTime.zone.id), Iso8601Locale, @@ -87,7 +87,7 @@ object JvmDateTimeConverter : DateTimeConverter { } } - override fun createOffsetDateTimeCalendar(dateTime: OffsetDateTime): Calendar { + override fun convertToCalendar(dateTime: OffsetDateTime): Calendar { return Calendar.getInstance( TimeZone.getTimeZone("GMT${dateTime.offset.id}"), Iso8601Locale, @@ -104,7 +104,7 @@ object JvmDateTimeConverter : DateTimeConverter { } } - override fun createLocalDateTimeCalendar(dateTime: LocalDateTime): Calendar { + override fun convertToCalendar(dateTime: LocalDateTime): Calendar { return Calendar.getInstance( TimeZone.GMT_ZONE, Iso8601Locale, @@ -121,7 +121,7 @@ object JvmDateTimeConverter : DateTimeConverter { } } - override fun createZoneOffsetCalendar(zoneOffset: ZoneOffset): Calendar { + override fun convertToCalendar(zoneOffset: ZoneOffset): Calendar { return Calendar.getInstance( TimeZone.getTimeZone("GMT${zoneOffset.id}"), Iso8601Locale, From 15f45f9ca8a2ad7f17abe8ce616391609cfc08d7 Mon Sep 17 00:00:00 2001 From: Drew Hamilton Date: Fri, 3 Nov 2023 14:16:39 -0700 Subject: [PATCH 04/11] Generate use of dateTimeConverter in FormattedResources --- .../cash/paraphrase/plugin/ResourceWriter.kt | 90 +++---------------- 1 file changed, 11 insertions(+), 79 deletions(-) diff --git a/plugin/src/main/java/app/cash/paraphrase/plugin/ResourceWriter.kt b/plugin/src/main/java/app/cash/paraphrase/plugin/ResourceWriter.kt index 3c3d0af8..7bb87119 100644 --- a/plugin/src/main/java/app/cash/paraphrase/plugin/ResourceWriter.kt +++ b/plugin/src/main/java/app/cash/paraphrase/plugin/ResourceWriter.kt @@ -71,7 +71,7 @@ internal fun writeResources( .addModifiers(KModifier.INTERNAL) .mutable(true) .initializer("%T", Types.AndroidDateTimeConverter) - .build() + .build(), ) mergedResources.forEach { mergedResource -> @@ -154,90 +154,25 @@ private fun Argument.toParameterSpec(): ParameterSpec = }, ) -private fun Argument.toParameterCodeBlock(): CodeBlock = - when (type) { +private fun Argument.toParameterCodeBlock(): CodeBlock { + return when (type) { Duration::class -> CodeBlock.of("%L.inWholeSeconds", name) - LocalDate::class -> buildCodeBlock { - addCalendarInstance { - addStatement("set(%1L.year, %1L.monthValue·-·1, %1L.dayOfMonth)", name) - } - } - - LocalTime::class -> buildCodeBlock { - addCalendarInstance { - addStatement("set(%T.HOUR_OF_DAY, %L.hour)", Types.Calendar, name) - addStatement("set(%T.MINUTE, %L.minute)", Types.Calendar, name) - addStatement("set(%T.SECOND, %L.second)", Types.Calendar, name) - addStatement("set(%T.MILLISECOND, %L.nano·/·1_000_000)", Types.Calendar, name) - } - } - - LocalDateTime::class -> buildCodeBlock { - addCalendarInstance { - addDateTimeSetStatements(name) - } - } // `Nothing` arg must be null, but passing null to the formatter replaces the whole format with // "null". Passing an `Int` allows the formatter to function as expected. Nothing::class -> CodeBlock.of("-1") - OffsetTime::class -> buildCodeBlock { - addCalendarInstance(timeZoneId = "\"GMT\${%L.offset.id}\"", name) { - addStatement("set(%T.HOUR_OF_DAY, %L.hour)", Types.Calendar, name) - addStatement("set(%T.MINUTE, %L.minute)", Types.Calendar, name) - addStatement("set(%T.SECOND, %L.second)", Types.Calendar, name) - addStatement("set(%T.MILLISECOND, %L.nano·/·1_000_000)", Types.Calendar, name) - } - } - - OffsetDateTime::class -> buildCodeBlock { - addCalendarInstance(timeZoneId = "\"GMT\${%L.offset.id}\"", name) { - addDateTimeSetStatements(name) - } - } - - ZonedDateTime::class -> buildCodeBlock { - addCalendarInstance(timeZoneId = "%L.zone.id", name) { - addDateTimeSetStatements(name) - } - } - - ZoneOffset::class -> buildCodeBlock { - addCalendarInstance(timeZoneId = "\"GMT\${%L.id}\"", name) - } + LocalDate::class, + LocalTime::class, + LocalDateTime::class, + OffsetTime::class, + OffsetDateTime::class, + ZonedDateTime::class, + ZoneOffset::class, + -> CodeBlock.of("dateTimeConverter.convertToCalendar(%L)", name) else -> CodeBlock.of("%L", name) } - -private fun CodeBlock.Builder.addCalendarInstance( - timeZoneId: String? = null, - vararg timeZoneIdArgs: Any? = emptyArray(), - applyBlock: (() -> Unit)? = null, -) { - val timeZoneReference = if (timeZoneId == null) "GMT_ZONE" else "getTimeZone($timeZoneId)" - add("%T.getInstance(\n⇥", Types.Calendar) - addStatement("%T.$timeZoneReference,", Types.TimeZone, *timeZoneIdArgs) - addStatement("%T.Builder().setExtension('u', \"ca-iso8601\").build(),", Types.ULocale) - add("⇤)") - - if (applyBlock != null) { - add(".apply·{\n⇥") - applyBlock.invoke() - add("⇤}") - } -} - -private fun CodeBlock.Builder.addDateTimeSetStatements(dateTimeArgName: String) { - add("set(\n⇥") - addStatement("%L.year,", dateTimeArgName) - addStatement("%L.monthValue·-·1,", dateTimeArgName) - addStatement("%L.dayOfMonth,", dateTimeArgName) - addStatement("%L.hour,", dateTimeArgName) - addStatement("%L.minute,", dateTimeArgName) - addStatement("%L.second,", dateTimeArgName) - add("⇤)\n") - addStatement("set(%T.MILLISECOND, %L.nano·/·1_000_000)", Types.Calendar, dateTimeArgName) } private fun MergedResource.Visibility.toKModifier(): KModifier { @@ -292,9 +227,6 @@ private fun MergedResource.toIntOverloadFunSpec(overloaded: FunSpec): FunSpec { private object Types { val AndroidDateTimeConverter = ClassName("app.cash.paraphrase", "AndroidDateTimeConverter") val ArrayMap = ClassName("androidx.collection", "ArrayMap") - val Calendar = ClassName("android.icu.util", "Calendar") val DateTimeConverter = ClassName("app.cash.paraphrase", "DateTimeConverter") val FormattedResource = ClassName("app.cash.paraphrase", "FormattedResource") - val TimeZone = ClassName("android.icu.util", "TimeZone") - val ULocale = ClassName("android.icu.util", "ULocale") } From da33b5d277b7eacf163fe565250ac9070eac6f22 Mon Sep 17 00:00:00 2001 From: Drew Hamilton Date: Fri, 3 Nov 2023 14:43:05 -0700 Subject: [PATCH 05/11] Convert AndroidDateTimeConverter locale to lazy This prevents the ULocale from being instantiated at class-loading time, which would crash JVM tests due to the missing Android implementation. --- .../java/app/cash/paraphrase/AndroidDateTimeConverter.kt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/runtime/src/main/java/app/cash/paraphrase/AndroidDateTimeConverter.kt b/runtime/src/main/java/app/cash/paraphrase/AndroidDateTimeConverter.kt index 6d7cbeb8..ab280ab9 100644 --- a/runtime/src/main/java/app/cash/paraphrase/AndroidDateTimeConverter.kt +++ b/runtime/src/main/java/app/cash/paraphrase/AndroidDateTimeConverter.kt @@ -31,9 +31,11 @@ import java.time.ZonedDateTime */ public object AndroidDateTimeConverter : DateTimeConverter { - private val Iso8601Locale = ULocale.Builder() - .setExtension('u', "ca-iso8601") - .build() + private val Iso8601Locale by lazy(LazyThreadSafetyMode.NONE) { + ULocale.Builder() + .setExtension('u', "ca-iso8601") + .build() + } override fun convertToCalendar(date: LocalDate): Calendar { return Calendar.getInstance( From 83c95236000041eaaa6bd181b5b0c10f7d686aaf Mon Sep 17 00:00:00 2001 From: Drew Hamilton Date: Fri, 3 Nov 2023 14:43:45 -0700 Subject: [PATCH 06/11] Add sample FormattedResourcesTest using dateTimeConverter substitution --- sample/library/build.gradle.kts | 2 + .../sample/library/FormattedResourcesTest.kt | 63 +++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 sample/library/src/test/kotlin/app/cash/paraphrase/sample/library/FormattedResourcesTest.kt diff --git a/sample/library/build.gradle.kts b/sample/library/build.gradle.kts index 1ae9139b..4f08784d 100644 --- a/sample/library/build.gradle.kts +++ b/sample/library/build.gradle.kts @@ -24,4 +24,6 @@ androidComponents { dependencies { testImplementation(libs.icu4j) + testImplementation(libs.junit) + testImplementation(libs.truth) } diff --git a/sample/library/src/test/kotlin/app/cash/paraphrase/sample/library/FormattedResourcesTest.kt b/sample/library/src/test/kotlin/app/cash/paraphrase/sample/library/FormattedResourcesTest.kt new file mode 100644 index 00000000..5785e9fd --- /dev/null +++ b/sample/library/src/test/kotlin/app/cash/paraphrase/sample/library/FormattedResourcesTest.kt @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2023 Cash App + * + * 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 app.cash.paraphrase.sample.library + +import androidx.annotation.StringRes +import com.google.common.truth.Truth.assertThat +import com.ibm.icu.text.MessageFormat +import java.time.LocalDate +import java.time.LocalTime +import java.time.Month +import java.util.Locale +import org.junit.Before +import org.junit.Test + +class FormattedResourcesTest { + + private val stringResolver = FakeStringResolver( + R.string.library_date_argument to "{release_date, date, short}", + R.string.library_time_argument to "{showtime, time, short}", + ) + + @Before fun substituteDateTimeConverter() { + FormattedResources.dateTimeConverter = JvmDateTimeConverter + } + + @Test fun date() { + val formattedResource = + FormattedResources.library_date_argument(LocalDate.of(2023, Month.NOVEMBER, 3)) + val result = MessageFormat(stringResolver.getString(formattedResource.id), Locale.US) + .format(formattedResource.arguments) + assertThat(result).isEqualTo("11/3/23") + } + + @Test fun time() { + val formattedResource = + FormattedResources.library_time_argument(LocalTime.of(14, 37, 21)) + val result = MessageFormat(stringResolver.getString(formattedResource.id), Locale.US) + .format(formattedResource.arguments) + assertThat(result).isEqualTo("2:37 PM") + } + + private class FakeStringResolver( + private val strings: Map, + ) { + + constructor(vararg strings: Pair) : this(mapOf(*strings)) + + fun getString(@StringRes id: Int): String = strings.getValue(id) + } +} From 71417eb2000e54d66bd45874b01dfd6ad730161c Mon Sep 17 00:00:00 2001 From: Drew Hamilton Date: Fri, 3 Nov 2023 15:21:39 -0700 Subject: [PATCH 07/11] API dump --- runtime/api/runtime.api | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/runtime/api/runtime.api b/runtime/api/runtime.api index 3214fce8..76356924 100644 --- a/runtime/api/runtime.api +++ b/runtime/api/runtime.api @@ -1,3 +1,31 @@ +public final class app/cash/paraphrase/AndroidDateTimeConverter : app/cash/paraphrase/DateTimeConverter { + public static final field INSTANCE Lapp/cash/paraphrase/AndroidDateTimeConverter; + public fun convertToCalendar (Ljava/time/LocalDate;)Landroid/icu/util/Calendar; + public synthetic fun convertToCalendar (Ljava/time/LocalDate;)Ljava/lang/Object; + public fun convertToCalendar (Ljava/time/LocalDateTime;)Landroid/icu/util/Calendar; + public synthetic fun convertToCalendar (Ljava/time/LocalDateTime;)Ljava/lang/Object; + public fun convertToCalendar (Ljava/time/LocalTime;)Landroid/icu/util/Calendar; + public synthetic fun convertToCalendar (Ljava/time/LocalTime;)Ljava/lang/Object; + public fun convertToCalendar (Ljava/time/OffsetDateTime;)Landroid/icu/util/Calendar; + public synthetic fun convertToCalendar (Ljava/time/OffsetDateTime;)Ljava/lang/Object; + public fun convertToCalendar (Ljava/time/OffsetTime;)Landroid/icu/util/Calendar; + public synthetic fun convertToCalendar (Ljava/time/OffsetTime;)Ljava/lang/Object; + public fun convertToCalendar (Ljava/time/ZoneOffset;)Landroid/icu/util/Calendar; + public synthetic fun convertToCalendar (Ljava/time/ZoneOffset;)Ljava/lang/Object; + public fun convertToCalendar (Ljava/time/ZonedDateTime;)Landroid/icu/util/Calendar; + public synthetic fun convertToCalendar (Ljava/time/ZonedDateTime;)Ljava/lang/Object; +} + +public abstract interface class app/cash/paraphrase/DateTimeConverter { + public abstract fun convertToCalendar (Ljava/time/LocalDate;)Ljava/lang/Object; + public abstract fun convertToCalendar (Ljava/time/LocalDateTime;)Ljava/lang/Object; + public abstract fun convertToCalendar (Ljava/time/LocalTime;)Ljava/lang/Object; + public abstract fun convertToCalendar (Ljava/time/OffsetDateTime;)Ljava/lang/Object; + public abstract fun convertToCalendar (Ljava/time/OffsetTime;)Ljava/lang/Object; + public abstract fun convertToCalendar (Ljava/time/ZoneOffset;)Ljava/lang/Object; + public abstract fun convertToCalendar (Ljava/time/ZonedDateTime;)Ljava/lang/Object; +} + public final class app/cash/paraphrase/FormattedResource { public fun (ILjava/lang/Object;)V public fun equals (Ljava/lang/Object;)Z From 3db0ac1db8dc115426fa00013c2142d55ff0d14c Mon Sep 17 00:00:00 2001 From: Drew Hamilton Date: Fri, 3 Nov 2023 18:01:16 -0700 Subject: [PATCH 08/11] Delete extra `copy`s --- .../src/main/java/app/cash/paraphrase/plugin/ResourceWriter.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/src/main/java/app/cash/paraphrase/plugin/ResourceWriter.kt b/plugin/src/main/java/app/cash/paraphrase/plugin/ResourceWriter.kt index 7bb87119..95c7448a 100644 --- a/plugin/src/main/java/app/cash/paraphrase/plugin/ResourceWriter.kt +++ b/plugin/src/main/java/app/cash/paraphrase/plugin/ResourceWriter.kt @@ -66,7 +66,7 @@ internal fun writeResources( addProperty( PropertySpec.builder( name = "dateTimeConverter", - type = Types.DateTimeConverter.parameterizedBy(ANY.copy()).copy(), + type = Types.DateTimeConverter.parameterizedBy(ANY), ) .addModifiers(KModifier.INTERNAL) .mutable(true) From 78dfe601132311b411dfa133bc5ed04b78a99943 Mon Sep 17 00:00:00 2001 From: Drew Hamilton Date: Fri, 3 Nov 2023 18:06:00 -0700 Subject: [PATCH 09/11] Add SubclassOptInRequired to DateTimeConverter --- runtime/api/runtime.api | 3 +++ .../java/app/cash/paraphrase/AndroidDateTimeConverter.kt | 1 + .../main/java/app/cash/paraphrase/DateTimeConverter.kt | 9 +++++++++ .../paraphrase/sample/library/JvmDateTimeConverter.kt | 1 + 4 files changed, 14 insertions(+) diff --git a/runtime/api/runtime.api b/runtime/api/runtime.api index 76356924..af298457 100644 --- a/runtime/api/runtime.api +++ b/runtime/api/runtime.api @@ -26,6 +26,9 @@ public abstract interface class app/cash/paraphrase/DateTimeConverter { public abstract fun convertToCalendar (Ljava/time/ZonedDateTime;)Ljava/lang/Object; } +public abstract interface annotation class app/cash/paraphrase/DateTimeConverter$SubclassOptIn : java/lang/annotation/Annotation { +} + public final class app/cash/paraphrase/FormattedResource { public fun (ILjava/lang/Object;)V public fun equals (Ljava/lang/Object;)Z diff --git a/runtime/src/main/java/app/cash/paraphrase/AndroidDateTimeConverter.kt b/runtime/src/main/java/app/cash/paraphrase/AndroidDateTimeConverter.kt index ab280ab9..2b6161fc 100644 --- a/runtime/src/main/java/app/cash/paraphrase/AndroidDateTimeConverter.kt +++ b/runtime/src/main/java/app/cash/paraphrase/AndroidDateTimeConverter.kt @@ -29,6 +29,7 @@ import java.time.ZonedDateTime /** * Converts `java.time` types used by Paraphrase to a [Calendar] that can be used by ICU to format. */ +@OptIn(DateTimeConverter.SubclassOptIn::class) public object AndroidDateTimeConverter : DateTimeConverter { private val Iso8601Locale by lazy(LazyThreadSafetyMode.NONE) { diff --git a/runtime/src/main/java/app/cash/paraphrase/DateTimeConverter.kt b/runtime/src/main/java/app/cash/paraphrase/DateTimeConverter.kt index a86e04db..8cdbb6f6 100644 --- a/runtime/src/main/java/app/cash/paraphrase/DateTimeConverter.kt +++ b/runtime/src/main/java/app/cash/paraphrase/DateTimeConverter.kt @@ -28,7 +28,10 @@ import java.time.ZonedDateTime * * [Calendar] is generic so the system-appropriate ICU calendar implementation can be used: * `android.icu.util` on Android, or `com.ibm.icu` on the JVM. + * + * This interface's public API may change. */ +@SubclassOptInRequired(DateTimeConverter.SubclassOptIn::class) public interface DateTimeConverter { /** @@ -78,4 +81,10 @@ public interface DateTimeConverter { * formatter. */ public fun convertToCalendar(zoneOffset: ZoneOffset): Calendar + + /** + * [DateTimeConverter] is not stable for public extension; its public API may change. + */ + @RequiresOptIn + public annotation class SubclassOptIn } diff --git a/sample/library/src/test/kotlin/app/cash/paraphrase/sample/library/JvmDateTimeConverter.kt b/sample/library/src/test/kotlin/app/cash/paraphrase/sample/library/JvmDateTimeConverter.kt index 384cffbd..c7bf574b 100644 --- a/sample/library/src/test/kotlin/app/cash/paraphrase/sample/library/JvmDateTimeConverter.kt +++ b/sample/library/src/test/kotlin/app/cash/paraphrase/sample/library/JvmDateTimeConverter.kt @@ -31,6 +31,7 @@ import java.time.ZonedDateTime * Converts `java.time` types used by Paraphrase to a [Calendar] that can be used by ICU to format. */ // TODO: Ship this in a new artifact? +@OptIn(DateTimeConverter.SubclassOptIn::class) object JvmDateTimeConverter : DateTimeConverter { private val Iso8601Locale = ULocale.Builder() From fef216f3df3db1b07e280346366183c66e11cefe Mon Sep 17 00:00:00 2001 From: Drew Hamilton Date: Mon, 6 Nov 2023 15:47:06 -0800 Subject: [PATCH 10/11] Test ICU4J in TypesTest to compare results with Android ICU --- tests/build.gradle.kts | 1 + .../paraphrase/tests/JvmDateTimeConverter.kt | 131 ++++++++++++++ .../app/cash/paraphrase/tests/TypesTest.kt | 164 +++++++++++------- 3 files changed, 230 insertions(+), 66 deletions(-) create mode 100644 tests/src/main/kotlin/app/cash/paraphrase/tests/JvmDateTimeConverter.kt diff --git a/tests/build.gradle.kts b/tests/build.gradle.kts index e4cc3752..59aec393 100644 --- a/tests/build.gradle.kts +++ b/tests/build.gradle.kts @@ -31,6 +31,7 @@ dependencies { implementation(libs.truth) implementation(libs.androidTestRunner) implementation(libs.testParameterInjector) + implementation(libs.icu4j) coreLibraryDesugaring(libs.coreLibraryDesugaring) } diff --git a/tests/src/main/kotlin/app/cash/paraphrase/tests/JvmDateTimeConverter.kt b/tests/src/main/kotlin/app/cash/paraphrase/tests/JvmDateTimeConverter.kt new file mode 100644 index 00000000..9da0a00f --- /dev/null +++ b/tests/src/main/kotlin/app/cash/paraphrase/tests/JvmDateTimeConverter.kt @@ -0,0 +1,131 @@ +/* + * Copyright (C) 2023 Cash App + * + * 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 app.cash.paraphrase.tests + +import app.cash.paraphrase.DateTimeConverter +import com.ibm.icu.util.Calendar +import com.ibm.icu.util.TimeZone +import com.ibm.icu.util.ULocale +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.OffsetDateTime +import java.time.OffsetTime +import java.time.ZoneOffset +import java.time.ZonedDateTime + +/** + * Converts `java.time` types used by Paraphrase to a [Calendar] that can be used by ICU to format. + */ +// TODO: Deduplicate with :sample:library +@OptIn(DateTimeConverter.SubclassOptIn::class) +object JvmDateTimeConverter : DateTimeConverter { + + private val Iso8601Locale = ULocale.Builder() + .setExtension('u', "ca-iso8601") + .build() + + override fun convertToCalendar(date: LocalDate): Calendar { + return Calendar.getInstance( + TimeZone.GMT_ZONE, + Iso8601Locale, + ).apply { + set(date.year, date.monthValue - 1, date.dayOfMonth) + } + } + + override fun convertToCalendar(time: OffsetTime): Calendar { + return Calendar.getInstance( + TimeZone.getTimeZone("GMT${time.offset.id}"), + Iso8601Locale, + ).apply { + set(Calendar.HOUR_OF_DAY, time.hour) + set(Calendar.MINUTE, time.minute) + set(Calendar.SECOND, time.second) + set(Calendar.MILLISECOND, time.nano / 1_000_000) + } + } + + override fun convertToCalendar(time: LocalTime): Calendar { + return Calendar.getInstance( + TimeZone.GMT_ZONE, + Iso8601Locale, + ).apply { + set(Calendar.HOUR_OF_DAY, time.hour) + set(Calendar.MINUTE, time.minute) + set(Calendar.SECOND, time.second) + set(Calendar.MILLISECOND, time.nano / 1_000_000) + } + } + + override fun convertToCalendar(dateTime: ZonedDateTime): Calendar { + return Calendar.getInstance( + TimeZone.getTimeZone(dateTime.zone.id), + Iso8601Locale, + ).apply { + set( + dateTime.year, + dateTime.monthValue - 1, + dateTime.dayOfMonth, + dateTime.hour, + dateTime.minute, + dateTime.second, + ) + set(Calendar.MILLISECOND, dateTime.nano / 1_000_000) + } + } + + override fun convertToCalendar(dateTime: OffsetDateTime): Calendar { + return Calendar.getInstance( + TimeZone.getTimeZone("GMT${dateTime.offset.id}"), + Iso8601Locale, + ).apply { + set( + dateTime.year, + dateTime.monthValue - 1, + dateTime.dayOfMonth, + dateTime.hour, + dateTime.minute, + dateTime.second, + ) + set(Calendar.MILLISECOND, dateTime.nano / 1_000_000) + } + } + + override fun convertToCalendar(dateTime: LocalDateTime): Calendar { + return Calendar.getInstance( + TimeZone.GMT_ZONE, + Iso8601Locale, + ).apply { + set( + dateTime.year, + dateTime.monthValue - 1, + dateTime.dayOfMonth, + dateTime.hour, + dateTime.minute, + dateTime.second, + ) + set(Calendar.MILLISECOND, dateTime.nano / 1_000_000) + } + } + + override fun convertToCalendar(zoneOffset: ZoneOffset): Calendar { + return Calendar.getInstance( + TimeZone.getTimeZone("GMT${zoneOffset.id}"), + Iso8601Locale, + ) + } +} diff --git a/tests/src/main/kotlin/app/cash/paraphrase/tests/TypesTest.kt b/tests/src/main/kotlin/app/cash/paraphrase/tests/TypesTest.kt index c11c8f48..5b3ab700 100644 --- a/tests/src/main/kotlin/app/cash/paraphrase/tests/TypesTest.kt +++ b/tests/src/main/kotlin/app/cash/paraphrase/tests/TypesTest.kt @@ -17,8 +17,13 @@ package app.cash.paraphrase.tests import android.os.Build import androidx.test.platform.app.InstrumentationRegistry +import app.cash.paraphrase.AndroidDateTimeConverter +import app.cash.paraphrase.FormattedResource import app.cash.paraphrase.getString import com.google.common.truth.Truth.assertThat +import com.google.testing.junit.testparameterinjector.TestParameter +import com.google.testing.junit.testparameterinjector.TestParameterInjector +import com.ibm.icu.text.MessageFormat as JvmMessageFormat import java.time.LocalDate import java.time.LocalTime import java.time.Month @@ -30,10 +35,15 @@ import java.util.Locale import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds +import org.junit.Before import org.junit.Rule import org.junit.Test +import org.junit.runner.RunWith -class TypesTest { +@RunWith(TestParameterInjector::class) +class TypesTest( + @TestParameter private val icuImpl: IcuImpl, +) { @get:Rule val localeRule = LocaleAndTimeZoneRule( locale = Locale("en", "US"), ) @@ -47,78 +57,85 @@ class TypesTest { ZoneId.of("Pacific/Honolulu"), ) + @Before fun setDateTimeConverter() { + FormattedResources.dateTimeConverter = when (icuImpl) { + IcuImpl.Android -> AndroidDateTimeConverter + IcuImpl.Jvm -> JvmDateTimeConverter + } + } + @Test fun typeNone() { - val formattedString = context.getString(FormattedResources.type_none("Z")) + val formattedString = getString(FormattedResources.type_none("Z")) assertThat(formattedString).isEqualTo("A Z B") - val formattedInteger = context.getString(FormattedResources.type_none(2)) + val formattedInteger = getString(FormattedResources.type_none(2)) assertThat(formattedInteger).isEqualTo("A 2 B") - val formattedDouble = context.getString(FormattedResources.type_none(2.345)) + val formattedDouble = getString(FormattedResources.type_none(2.345)) assertThat(formattedDouble).isEqualTo("A 2.345 B") val formattedInstant = - context.getString(FormattedResources.type_none(releaseDateTime.toInstant())) + getString(FormattedResources.type_none(releaseDateTime.toInstant())) assertThat(formattedInstant).isEqualTo("A 2022-03-25T05:23:45Z B") } @Test fun typeNumber() { - val formattedInteger = context.getString(FormattedResources.type_number(2)) + val formattedInteger = getString(FormattedResources.type_number(2)) assertThat(formattedInteger).isEqualTo("A 2 B") - val formattedDouble = context.getString(FormattedResources.type_number(2.345)) + val formattedDouble = getString(FormattedResources.type_number(2.345)) assertThat(formattedDouble).isEqualTo("A 2.345 B") } @Test fun typeNumberInteger() { - val formatted = context.getString(FormattedResources.type_number_integer(2)) + val formatted = getString(FormattedResources.type_number_integer(2)) assertThat(formatted).isEqualTo("A 2 B") } @Test fun typeNumberCurrency() { - val formatted = context.getString(FormattedResources.type_number_currency(2)) + val formatted = getString(FormattedResources.type_number_currency(2)) assertThat(formatted).isEqualTo("A $2.00 B") } @Test fun typeNumberPercent() { - val formatted = context.getString(FormattedResources.type_number_percent(.2)) + val formatted = getString(FormattedResources.type_number_percent(.2)) assertThat(formatted).isEqualTo("A 20% B") } @Test fun typeNumberCustom() { - val formatted = context.getString(FormattedResources.type_number_custom(1234567)) + val formatted = getString(FormattedResources.type_number_custom(1234567)) assertThat(formatted).isEqualTo("A 12,34,567 B") } @Test fun typeDate() { - val formatted = context.getString(FormattedResources.type_date(releaseDate)) + val formatted = getString(FormattedResources.type_date(releaseDate)) assertThat(formatted).isEqualTo("A Mar 24, 2022 B") } @Test fun typeDateShort() { - val formatted = context.getString(FormattedResources.type_date_short(releaseDate)) + val formatted = getString(FormattedResources.type_date_short(releaseDate)) assertThat(formatted).isEqualTo("A 3/24/22 B") } @Test fun typeDateMedium() { - val formatted = context.getString(FormattedResources.type_date_medium(releaseDate)) + val formatted = getString(FormattedResources.type_date_medium(releaseDate)) assertThat(formatted).isEqualTo("A Mar 24, 2022 B") } @Test fun typeDateLong() { - val formatted = context.getString(FormattedResources.type_date_long(releaseDate)) + val formatted = getString(FormattedResources.type_date_long(releaseDate)) assertThat(formatted).isEqualTo("A March 24, 2022 B") } @Test fun typeDateFull() { - val formatted = context.getString(FormattedResources.type_date_full(releaseDate)) + val formatted = getString(FormattedResources.type_date_full(releaseDate)) assertThat(formatted).isEqualTo("A Thursday, March 24, 2022 B") } @Test fun typeDatePatternDateTimeZone() { val formatted = - context.getString(FormattedResources.type_date_pattern_date_time_zone(releaseDateTime)) + getString(FormattedResources.type_date_pattern_date_time_zone(releaseDateTime)) assertThat(formatted).isEqualTo("A 3-24, 7PM HST B") } @Test fun typeDatePatternDateTimeOffset() { - val formatted = context.getString( + val formatted = getString( FormattedResources.type_date_pattern_date_time_offset(releaseDateTime.toOffsetDateTime()), ) assertThat(formatted).isEqualTo("A 3-24, 7PM -10:00 B") @@ -126,36 +143,36 @@ class TypesTest { @Test fun typeDatePatternDateTime() { val localDateTime = releaseDateTime.toLocalDateTime() - val formatted = context.getString(FormattedResources.type_date_pattern_date_time(localDateTime)) + val formatted = getString(FormattedResources.type_date_pattern_date_time(localDateTime)) assertThat(formatted).isEqualTo("A 3-24 7PM B") } @Test fun typeDatePatternDateZone() { val formatted = - context.getString(FormattedResources.type_date_pattern_date_zone(releaseDateTime)) + getString(FormattedResources.type_date_pattern_date_zone(releaseDateTime)) assertThat(formatted).isEqualTo("A March (HST) B") } @Test fun typeDatePatternDateOffset() { - val formatted = context.getString( + val formatted = getString( FormattedResources.type_date_pattern_date_offset(releaseDateTime.toOffsetDateTime()), ) assertThat(formatted).isEqualTo("A March (-10:00) B") } @Test fun typeDatePatternDate() { - val formatted = context.getString(FormattedResources.type_date_pattern_date(releaseDate)) + val formatted = getString(FormattedResources.type_date_pattern_date(releaseDate)) assertThat(formatted).isEqualTo("A 2022-03-24 B") } @Test fun typeDatePatternTimeZone() { val formatted = - context.getString(FormattedResources.type_date_pattern_time_zone(releaseDateTime)) + getString(FormattedResources.type_date_pattern_time_zone(releaseDateTime)) assertThat(formatted).isEqualTo("A 19:23 HST B") } @Test fun typeDatePatternTimeOffset() { - val formatted = context.getString( + val formatted = getString( FormattedResources.type_date_pattern_time_offset( // Ensures the UTC/GMT case works: releaseDateTime.withZoneSameLocal(ZoneOffset.UTC).toOffsetDateTime().toOffsetTime(), @@ -165,60 +182,60 @@ class TypesTest { } @Test fun typeDatePatternTime() { - val formatted = context.getString(FormattedResources.type_date_pattern_time(releaseTime)) + val formatted = getString(FormattedResources.type_date_pattern_time(releaseTime)) assertThat(formatted).isEqualTo("A 23 past 7 B") } @Test fun typeDatePatternZone() { - val formatted = context.getString(FormattedResources.type_date_pattern_zone(releaseDateTime)) + val formatted = getString(FormattedResources.type_date_pattern_zone(releaseDateTime)) assertThat(formatted).isEqualTo("A Hawaii-Aleutian Standard Time B") } @Test fun typeDatePatternOffset() { - val formatted = context.getString( + val formatted = getString( FormattedResources.type_date_pattern_offset(releaseDateTime.offset), ) assertThat(formatted).isEqualTo("A GMT-10:00 B") } @Test fun typeDatePatternNone() { - val formatted = context.getString(FormattedResources.type_date_pattern_none(null)) + val formatted = getString(FormattedResources.type_date_pattern_none(null)) assertThat(formatted).isEqualTo("A What is this for? B") } @Test fun typeTime() { - val formatted = context.getString(FormattedResources.type_time(releaseTime)) + val formatted = getString(FormattedResources.type_time(releaseTime)) assertThat(formatted).isEqualTo("A 7:23:45 PM B") } @Test fun typeTimeShort() { - val formatted = context.getString(FormattedResources.type_time_short(releaseTime)) + val formatted = getString(FormattedResources.type_time_short(releaseTime)) assertThat(formatted).isEqualTo("A 7:23 PM B") } @Test fun typeTimeMedium() { - val formatted = context.getString(FormattedResources.type_time_medium(releaseTime)) + val formatted = getString(FormattedResources.type_time_medium(releaseTime)) assertThat(formatted).isEqualTo("A 7:23:45 PM B") } @Test fun typeTimeLong() { - val formatted = context.getString(FormattedResources.type_time_long(releaseDateTime)) + val formatted = getString(FormattedResources.type_time_long(releaseDateTime)) assertThat(formatted).isEqualTo("A 7:23:45 PM HST B") } @Test fun typeTimeFull() { - val formatted = context.getString(FormattedResources.type_time_full(releaseDateTime)) + val formatted = getString(FormattedResources.type_time_full(releaseDateTime)) assertThat(formatted).isEqualTo("A 7:23:45 PM Hawaii-Aleutian Standard Time B") } @Test fun typeTimePatternDateTimeZone() { val formatted = - context.getString(FormattedResources.type_time_pattern_date_time_zone(releaseDateTime)) + getString(FormattedResources.type_time_pattern_date_time_zone(releaseDateTime)) assertThat(formatted).isEqualTo("A 3-24, 7PM HST B") } @Test fun typeTimePatternDateTimeOffset() { - val formatted = context.getString( + val formatted = getString( FormattedResources.type_time_pattern_date_time_offset(releaseDateTime.toOffsetDateTime()), ) assertThat(formatted).isEqualTo("A 3-24, 7PM -10 B") @@ -226,36 +243,36 @@ class TypesTest { @Test fun typeTimePatternDateTime() { val localDateTime = releaseDateTime.toLocalDateTime() - val formatted = context.getString(FormattedResources.type_time_pattern_date_time(localDateTime)) + val formatted = getString(FormattedResources.type_time_pattern_date_time(localDateTime)) assertThat(formatted).isEqualTo("A 3-24 7PM B") } @Test fun typeTimePatternDateZone() { val formatted = - context.getString(FormattedResources.type_time_pattern_date_zone(releaseDateTime)) + getString(FormattedResources.type_time_pattern_date_zone(releaseDateTime)) assertThat(formatted).isEqualTo("A March (HST) B") } @Test fun typeTimePatternDateOffset() { - val formatted = context.getString( + val formatted = getString( FormattedResources.type_time_pattern_date_offset(releaseDateTime.toOffsetDateTime()), ) assertThat(formatted).isEqualTo("A March (-10) B") } @Test fun typeTimePatternDate() { - val formatted = context.getString(FormattedResources.type_time_pattern_date(releaseDate)) + val formatted = getString(FormattedResources.type_time_pattern_date(releaseDate)) assertThat(formatted).isEqualTo("A 2022-03-24 B") } @Test fun typeTimePatternTimeZone() { val formatted = - context.getString(FormattedResources.type_time_pattern_time_zone(releaseDateTime)) + getString(FormattedResources.type_time_pattern_time_zone(releaseDateTime)) assertThat(formatted).isEqualTo("A 19:23 HST B") } @Test fun typeTimePatternTimeOffset() { - val formatted = context.getString( + val formatted = getString( FormattedResources.type_time_pattern_time_offset( OffsetTime.of(releaseDateTime.toLocalTime(), releaseDateTime.offset), ), @@ -264,24 +281,24 @@ class TypesTest { } @Test fun typeTimePatternTime() { - val formatted = context.getString(FormattedResources.type_time_pattern_time(releaseTime)) + val formatted = getString(FormattedResources.type_time_pattern_time(releaseTime)) assertThat(formatted).isEqualTo("A 19-23-45 B") } @Test fun typeTimePatternZone() { - val formatted = context.getString(FormattedResources.type_time_pattern_zone(releaseDateTime)) + val formatted = getString(FormattedResources.type_time_pattern_zone(releaseDateTime)) assertThat(formatted).isEqualTo("A Hawaii-Aleutian Standard Time B") } @Test fun typeTimePatternOffset() { - val formatted = context.getString( + val formatted = getString( FormattedResources.type_time_pattern_offset(releaseDateTime.offset), ) assertThat(formatted).isEqualTo("A GMT-10:00 B") } @Test fun typeTimePatternNone() { - val formatted = context.getString(FormattedResources.type_time_pattern_none(null)) + val formatted = getString(FormattedResources.type_time_pattern_none(null)) assertThat(formatted).isEqualTo("A What is this for? B") } @@ -291,7 +308,7 @@ class TypesTest { LocalTime.NOON, ZoneId.of("America/Chicago"), ) - val formatted = context.getString(FormattedResources.type_time_long(winterDateTime)) + val formatted = getString(FormattedResources.type_time_long(winterDateTime)) assertThat(formatted).isEqualTo("A 12:00:00 PM CST B") } @@ -301,33 +318,33 @@ class TypesTest { LocalTime.NOON, ZoneId.of("America/Chicago"), ) - val formatted = context.getString(FormattedResources.type_time_long(summerDateTime)) + val formatted = getString(FormattedResources.type_time_long(summerDateTime)) assertThat(formatted).isEqualTo("A 12:00:00 PM CDT B") } @Test fun typeDuration() { - val formattedSeconds = context.getString(FormattedResources.type_duration(3.seconds)) + val formattedSeconds = getString(FormattedResources.type_duration(3.seconds)) assertThat(formattedSeconds).isEqualTo("A 3 sec. B") - val formattedMinutes = context.getString(FormattedResources.type_duration(3.minutes + 2.seconds)) + val formattedMinutes = getString(FormattedResources.type_duration(3.minutes + 2.seconds)) assertThat(formattedMinutes).isEqualTo("A 3:02 B") - val formattedHours = context.getString(FormattedResources.type_duration(3.hours + 2.minutes + 1.seconds)) + val formattedHours = getString(FormattedResources.type_duration(3.hours + 2.minutes + 1.seconds)) assertThat(formattedHours).isEqualTo("A 3:02:01 B") } @Test fun typeOrdinal() { val zero = 0 // Requires an int overload to be invoked - val formattedZero = context.getString(FormattedResources.type_ordinal(zero)) + val formattedZero = getString(FormattedResources.type_ordinal(zero)) assertThat(formattedZero).isEqualTo("A 0th B") - val formattedOne = context.getString(FormattedResources.type_ordinal(1)) + val formattedOne = getString(FormattedResources.type_ordinal(1)) assertThat(formattedOne).isEqualTo("A 1st B") - val formattedTwo = context.getString(FormattedResources.type_ordinal(2)) + val formattedTwo = getString(FormattedResources.type_ordinal(2)) assertThat(formattedTwo).isEqualTo("A 2nd B") - val formattedThree = context.getString(FormattedResources.type_ordinal(3)) + val formattedThree = getString(FormattedResources.type_ordinal(3)) assertThat(formattedThree).isEqualTo("A 3rd B") - val formattedFour = context.getString(FormattedResources.type_ordinal(4)) + val formattedFour = getString(FormattedResources.type_ordinal(4)) assertThat(formattedFour).isEqualTo("A 4th B") - val formattedLong = context.getString(FormattedResources.type_ordinal(Long.MAX_VALUE)) - val expected = if (Build.VERSION.SDK_INT >= 26) { + val formattedLong = getString(FormattedResources.type_ordinal(Long.MAX_VALUE)) + val expected = if (Build.VERSION.SDK_INT >= 26 || icuImpl == IcuImpl.Jvm) { "9,223,372,036,854,775,807th" } else { // ICU versions on older Android platforms lose bits by internally converting Long to Double: @@ -337,29 +354,44 @@ class TypesTest { } @Test fun typeSpellout() { - val formattedOnes = context.getString(FormattedResources.type_spellout(3)) + val formattedOnes = getString(FormattedResources.type_spellout(3)) assertThat(formattedOnes).isEqualTo("A three B") - val formattedTens = context.getString(FormattedResources.type_spellout(32)) + val formattedTens = getString(FormattedResources.type_spellout(32)) assertThat(formattedTens).isEqualTo("A thirty-two B") - val formattedHundreds = context.getString(FormattedResources.type_spellout(321)) + val formattedHundreds = getString(FormattedResources.type_spellout(321)) assertThat(formattedHundreds).isEqualTo("A three hundred twenty-one B") } @Test fun typePlural() { - val formatted0 = context.getString(FormattedResources.type_count_plural(0)) + val formatted0 = getString(FormattedResources.type_count_plural(0)) assertThat(formatted0).isEqualTo("A Z B") - val formatted1 = context.getString(FormattedResources.type_count_plural(1)) + val formatted1 = getString(FormattedResources.type_count_plural(1)) assertThat(formatted1).isEqualTo("A Y B") - val formatted2 = context.getString(FormattedResources.type_count_plural(2)) + val formatted2 = getString(FormattedResources.type_count_plural(2)) assertThat(formatted2).isEqualTo("A X B") } @Test fun typeSelect() { - val formattedAlpha = context.getString(FormattedResources.type_verse_select("alpha")) + val formattedAlpha = getString(FormattedResources.type_verse_select("alpha")) assertThat(formattedAlpha).isEqualTo("A Z B") - val formattedBeta = context.getString(FormattedResources.type_verse_select("beta")) + val formattedBeta = getString(FormattedResources.type_verse_select("beta")) assertThat(formattedBeta).isEqualTo("A Y B") - val formattedGamma = context.getString(FormattedResources.type_verse_select("gamma")) + val formattedGamma = getString(FormattedResources.type_verse_select("gamma")) assertThat(formattedGamma).isEqualTo("A X B") } + + private fun getString(formattedResource: FormattedResource): String { + return when (icuImpl) { + IcuImpl.Android -> context.getString(formattedResource) + IcuImpl.Jvm -> JvmMessageFormat(context.getString(formattedResource.id)) + .format(formattedResource.arguments) + // Android doesn't use ' ', so replace with a normal space for consistency: + .replace(' ', ' ') + } + } + + enum class IcuImpl { + Android, + Jvm, + } } From f9feae2bed677f67d27ec727c592fe4f01177ab6 Mon Sep 17 00:00:00 2001 From: Drew Hamilton Date: Mon, 6 Nov 2023 15:55:55 -0800 Subject: [PATCH 11/11] Make `dateTimeConverter` public, visible for testing --- .../main/java/app/cash/paraphrase/plugin/ResourceWriter.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plugin/src/main/java/app/cash/paraphrase/plugin/ResourceWriter.kt b/plugin/src/main/java/app/cash/paraphrase/plugin/ResourceWriter.kt index 95c7448a..6d1a4f02 100644 --- a/plugin/src/main/java/app/cash/paraphrase/plugin/ResourceWriter.kt +++ b/plugin/src/main/java/app/cash/paraphrase/plugin/ResourceWriter.kt @@ -68,7 +68,8 @@ internal fun writeResources( name = "dateTimeConverter", type = Types.DateTimeConverter.parameterizedBy(ANY), ) - .addModifiers(KModifier.INTERNAL) + .addModifiers(KModifier.PUBLIC) + .addAnnotation(AnnotationSpec.builder(Types.VisibleForTesting).build()) .mutable(true) .initializer("%T", Types.AndroidDateTimeConverter) .build(), @@ -229,4 +230,5 @@ private object Types { val ArrayMap = ClassName("androidx.collection", "ArrayMap") val DateTimeConverter = ClassName("app.cash.paraphrase", "DateTimeConverter") val FormattedResource = ClassName("app.cash.paraphrase", "FormattedResource") + val VisibleForTesting = ClassName("androidx.annotation", "VisibleForTesting") }