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 af709d6..6d1a4f0 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,18 @@ internal fun writeResources( .addType( TypeSpec.objectBuilder("FormattedResources") .apply { + addProperty( + PropertySpec.builder( + name = "dateTimeConverter", + type = Types.DateTimeConverter.parameterizedBy(ANY), + ) + .addModifiers(KModifier.PUBLIC) + .addAnnotation(AnnotationSpec.builder(Types.VisibleForTesting).build()) + .mutable(true) + .initializer("%T", Types.AndroidDateTimeConverter) + .build(), + ) + mergedResources.forEach { mergedResource -> val funSpec = mergedResource.toFunSpec(packageStringsType) addFunction(funSpec) @@ -142,90 +155,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 { @@ -278,9 +226,9 @@ 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") + val VisibleForTesting = ClassName("androidx.annotation", "VisibleForTesting") } diff --git a/runtime/api/runtime.api b/runtime/api/runtime.api index 3214fce..af29845 100644 --- a/runtime/api/runtime.api +++ b/runtime/api/runtime.api @@ -1,3 +1,34 @@ +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 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/build.gradle.kts b/runtime/build.gradle.kts index 67cc051..d3a3b12 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 0000000..2b6161f --- /dev/null +++ b/runtime/src/main/java/app/cash/paraphrase/AndroidDateTimeConverter.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 + +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. + */ +@OptIn(DateTimeConverter.SubclassOptIn::class) +public object AndroidDateTimeConverter : DateTimeConverter { + + private val Iso8601Locale by lazy(LazyThreadSafetyMode.NONE) { + 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/runtime/src/main/java/app/cash/paraphrase/DateTimeConverter.kt b/runtime/src/main/java/app/cash/paraphrase/DateTimeConverter.kt new file mode 100644 index 0000000..8cdbb6f --- /dev/null +++ b/runtime/src/main/java/app/cash/paraphrase/DateTimeConverter.kt @@ -0,0 +1,90 @@ +/* + * 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. + * + * This interface's public API may change. + */ +@SubclassOptInRequired(DateTimeConverter.SubclassOptIn::class) +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 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 convertToCalendar(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 convertToCalendar(time: LocalTime): Calendar + + /** + * Converts [dateTime] to a [Calendar] used by ICU to format. + */ + public fun convertToCalendar(dateTime: ZonedDateTime): Calendar + + /** + * Converts [dateTime] to a [Calendar] used by ICU to format. + */ + 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 convertToCalendar(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 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/build.gradle.kts b/sample/library/build.gradle.kts index e68a51c..4f08784 100644 --- a/sample/library/build.gradle.kts +++ b/sample/library/build.gradle.kts @@ -21,3 +21,9 @@ 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 0000000..5785e9f --- /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) + } +} 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 0000000..c7bf574 --- /dev/null +++ b/sample/library/src/test/kotlin/app/cash/paraphrase/sample/library/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.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? +@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/build.gradle.kts b/tests/build.gradle.kts index e4cc375..59aec39 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 0000000..9da0a00 --- /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 c11c8f4..5b3ab70 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, + } }