diff --git a/README.md b/README.md index 8f35807c..182d5a44 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ In an Android View: import app.cash.paraphrase.getString val orderDescription = resources.getString( - FormattedResources.order_description( + AndroidParaphraseResources.order_description( count = 12, name = "Jobu Tupaki", ) @@ -86,7 +86,7 @@ In Compose UI: import app.cash.paraphrase.compose.formattedResource val orderDescription = formattedResource( - FormattedResources.order_description( + paraphraseResources.order_description( count = 12, name = "Jobu Tupaki", ), 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..f1cbfc11 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 @@ -51,7 +52,9 @@ internal fun writeResources( ): FileSpec { val packageStringsType = ClassName(packageName = packageName, "R", "string") val maxVisibility = mergedResources.maxOf { it.visibility } - return FileSpec.builder(packageName = packageName, fileName = "FormattedResources") + val genClassName = "ParaphraseResources" + val defaultInstanceName = "Android$genClassName" + return FileSpec.builder(packageName = packageName, fileName = genClassName) .addFileComment( """ This code was generated by the Paraphrase Gradle plugin. @@ -59,8 +62,53 @@ internal fun writeResources( """.trimIndent(), ) .addImport(packageName = packageName, "R") + .addProperty( + PropertySpec.builder( + name = defaultInstanceName, + type = Types.paraphraseResources(packageName), + ) + .initializer("$genClassName(%T)", Types.AndroidDateTimeConverter) + .build(), + ) + // TODO: Remove deprecated val after a few releases + .addProperty( + PropertySpec.builder( + name = "FormattedResources", + type = Types.paraphraseResources(packageName), + ) + .initializer(defaultInstanceName, Types.AndroidDateTimeConverter) + .addAnnotation( + AnnotationSpec.builder( + type = ClassName("kotlin", "Deprecated"), + ) + .addMember("message = \"\"\"The `FormattedResources` object has been replaced by the `$genClassName` class and the default `$defaultInstanceName` instance. Use the class to allow testing on the JVM, or use the default instance to maintain previous static-like invocation.\"\"\"") + .addMember("replaceWith = ReplaceWith(\"$defaultInstanceName\")") + .addMember("level = DeprecationLevel.ERROR") + .build(), + ) + .build(), + ) .addType( - TypeSpec.objectBuilder("FormattedResources") + TypeSpec.classBuilder(genClassName) + .primaryConstructor( + FunSpec.constructorBuilder() + .addParameter( + ParameterSpec( + name = "dateTimeConverter", + type = Types.DateTimeConverter.parameterizedBy(ANY), + ), + ) + .build(), + ) + .addProperty( + PropertySpec.builder( + name = "dateTimeConverter", + type = Types.DateTimeConverter.parameterizedBy(ANY), + ) + .addModifiers(KModifier.PRIVATE) + .initializer("dateTimeConverter") + .build(), + ) .apply { mergedResources.forEach { mergedResource -> val funSpec = mergedResource.toFunSpec(packageStringsType) @@ -142,90 +190,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 +261,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") + + fun paraphraseResources(packageName: String) = ClassName(packageName, "ParaphraseResources") } diff --git a/plugin/src/test/java/app/cash/paraphrase/plugin/ResourceWriterTest.kt b/plugin/src/test/java/app/cash/paraphrase/plugin/ResourceWriterTest.kt index d4f1bd64..69733d8e 100644 --- a/plugin/src/test/java/app/cash/paraphrase/plugin/ResourceWriterTest.kt +++ b/plugin/src/test/java/app/cash/paraphrase/plugin/ResourceWriterTest.kt @@ -121,11 +121,11 @@ class ResourceWriterTest { expectedClassVisibility: KModifier, vararg expectedFunctionVisibility: Pair, ) { - assertOnFormattedResourcesObject { formattedResourcesObject -> - assertThat(formattedResourcesObject.modifiers).contains(expectedClassVisibility) + assertOnParaphraseResourcesClass { paraphraseResourcesClass -> + assertThat(paraphraseResourcesClass.modifiers).contains(expectedClassVisibility) expectedFunctionVisibility.forEach { (name, expectedVisibility) -> - val function = formattedResourcesObject.funSpecs.find { it.name == name } + val function = paraphraseResourcesClass.funSpecs.find { it.name == name } if (function == null) { fail("Function with name <$name> not found") } else { @@ -152,8 +152,8 @@ class ResourceWriterTest { ), ) - result.assertOnFormattedResourcesObject { formattedResourcesObject -> - val testFun = formattedResourcesObject.funSpecs.single { it.name == "testFun" } + result.assertOnParaphraseResourcesClass { paraphraseResourcesClass -> + val testFun = paraphraseResourcesClass.funSpecs.single { it.name == "testFun" } assertThat(testFun.annotations).contains( AnnotationSpec.builder(Deprecated::class) .addMember("%S", "Test message") @@ -162,16 +162,16 @@ class ResourceWriterTest { } } - private inline fun FileSpec.assertOnFormattedResourcesObject( - block: (formattedResourcesObject: TypeSpec) -> Unit, + private inline fun FileSpec.assertOnParaphraseResourcesClass( + block: (paraphraseResourcesClass: TypeSpec) -> Unit, ) { - val formattedResourcesObject = members + val paraphraseResourcesClass = members .filterIsInstance() - .find { it.name == "FormattedResources" } - if (formattedResourcesObject == null) { - fail("FormattedResources object not found") + .find { it.name == "ParaphraseResources" } + if (paraphraseResourcesClass == null) { + fail("ParaphraseResources class not found") } else { - block(formattedResourcesObject) + block(paraphraseResourcesClass) } } } diff --git a/runtime-test/api/runtime-test.api b/runtime-test/api/runtime-test.api new file mode 100644 index 00000000..cd16cbde --- /dev/null +++ b/runtime-test/api/runtime-test.api @@ -0,0 +1,18 @@ +public final class app/cash/paraphrase/runtime/test/JvmDateTimeConverter : app/cash/paraphrase/DateTimeConverter { + public static final field INSTANCE Lapp/cash/paraphrase/runtime/test/JvmDateTimeConverter; + public fun convertToCalendar (Ljava/time/LocalDate;)Lcom/ibm/icu/util/Calendar; + public synthetic fun convertToCalendar (Ljava/time/LocalDate;)Ljava/lang/Object; + public fun convertToCalendar (Ljava/time/LocalDateTime;)Lcom/ibm/icu/util/Calendar; + public synthetic fun convertToCalendar (Ljava/time/LocalDateTime;)Ljava/lang/Object; + public fun convertToCalendar (Ljava/time/LocalTime;)Lcom/ibm/icu/util/Calendar; + public synthetic fun convertToCalendar (Ljava/time/LocalTime;)Ljava/lang/Object; + public fun convertToCalendar (Ljava/time/OffsetDateTime;)Lcom/ibm/icu/util/Calendar; + public synthetic fun convertToCalendar (Ljava/time/OffsetDateTime;)Ljava/lang/Object; + public fun convertToCalendar (Ljava/time/OffsetTime;)Lcom/ibm/icu/util/Calendar; + public synthetic fun convertToCalendar (Ljava/time/OffsetTime;)Ljava/lang/Object; + public fun convertToCalendar (Ljava/time/ZoneOffset;)Lcom/ibm/icu/util/Calendar; + public synthetic fun convertToCalendar (Ljava/time/ZoneOffset;)Ljava/lang/Object; + public fun convertToCalendar (Ljava/time/ZonedDateTime;)Lcom/ibm/icu/util/Calendar; + public synthetic fun convertToCalendar (Ljava/time/ZonedDateTime;)Ljava/lang/Object; +} + diff --git a/runtime-test/build.gradle.kts b/runtime-test/build.gradle.kts new file mode 100644 index 00000000..2a811d8b --- /dev/null +++ b/runtime-test/build.gradle.kts @@ -0,0 +1,26 @@ +plugins { + alias(libs.plugins.androidLibrary) + alias(libs.plugins.kotlinAndroid) + alias(libs.plugins.mavenPublish) +} + +android { + namespace = "app.cash.paraphrase.test" + compileSdk = 34 + + defaultConfig { + minSdk = 24 + } + + compileOptions { + isCoreLibraryDesugaringEnabled = true + } +} + +dependencies { + api(projects.runtime) + + api(libs.icu4j) + + coreLibraryDesugaring(libs.coreLibraryDesugaring) +} diff --git a/runtime-test/gradle.properties b/runtime-test/gradle.properties new file mode 100644 index 00000000..15bfefb4 --- /dev/null +++ b/runtime-test/gradle.properties @@ -0,0 +1,3 @@ +# Maven +POM_ARTIFACT_ID=paraphrase-runtime-test +POM_NAME=Paraphrase test runtime diff --git a/runtime-test/src/main/java/app/cash/paraphrase/runtime/test/JvmDateTimeConverter.kt b/runtime-test/src/main/java/app/cash/paraphrase/runtime/test/JvmDateTimeConverter.kt new file mode 100644 index 00000000..d96bff6b --- /dev/null +++ b/runtime-test/src/main/java/app/cash/paraphrase/runtime/test/JvmDateTimeConverter.kt @@ -0,0 +1,130 @@ +/* + * Copyright (C) 2024 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.runtime.test + +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. + */ +@OptIn(DateTimeConverter.SubclassOptIn::class) +public 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/runtime/api/runtime.api b/runtime/api/runtime.api index 3214fce8..af298457 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 28da52ff..43cc12c9 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..2b6161fc --- /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 00000000..6f308e74 --- /dev/null +++ b/runtime/src/main/java/app/cash/paraphrase/DateTimeConverter.kt @@ -0,0 +1,91 @@ +/* + * 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. + */ +@OptIn(ExperimentalSubclassOptIn::class) +@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/app/src/androidTest/kotlin/app/cash/paraphrase/sample/app/ParaphraseTest.kt b/sample/app/src/androidTest/kotlin/app/cash/paraphrase/sample/app/ParaphraseTest.kt index 23642c84..b4e0e747 100644 --- a/sample/app/src/androidTest/kotlin/app/cash/paraphrase/sample/app/ParaphraseTest.kt +++ b/sample/app/src/androidTest/kotlin/app/cash/paraphrase/sample/app/ParaphraseTest.kt @@ -15,11 +15,11 @@ */ package app.cash.paraphrase.sample.app -import app.cash.paraphrase.sample.app.test.FormattedResources +import app.cash.paraphrase.sample.app.test.AndroidParaphraseResources // Note: This is a compilation test, not a runtime test, so no assertions are needed. class ParaphraseTest { - fun testFormattedResources() { - FormattedResources.app_test_text_argument("Jobu Tupaki") + fun testParaphraseResources() { + AndroidParaphraseResources.app_test_text_argument("Jobu Tupaki") } } diff --git a/sample/app/src/main/java/app/cash/paraphrase/sample/app/MainActivity.kt b/sample/app/src/main/java/app/cash/paraphrase/sample/app/MainActivity.kt index e4e26797..0b517c27 100644 --- a/sample/app/src/main/java/app/cash/paraphrase/sample/app/MainActivity.kt +++ b/sample/app/src/main/java/app/cash/paraphrase/sample/app/MainActivity.kt @@ -31,8 +31,10 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import app.cash.paraphrase.FormattedResource import app.cash.paraphrase.compose.formattedResource -import app.cash.paraphrase.sample.app.FormattedResources as AppFormattedResources -import app.cash.paraphrase.sample.library.FormattedResources as LibraryFormattedResources +import app.cash.paraphrase.sample.app.AndroidParaphraseResources as AppAndroidParaphraseResources +import app.cash.paraphrase.sample.app.ParaphraseResources as AppParaphraseResources +import app.cash.paraphrase.sample.library.AndroidParaphraseResources as LibraryAndroidParaphraseResources +import app.cash.paraphrase.sample.library.ParaphraseResources as LibraryParaphraseResources import java.time.LocalDate import java.time.LocalTime import java.time.ZonedDateTime @@ -40,13 +42,15 @@ import java.time.ZonedDateTime class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + val appSamples = appSamples(AppAndroidParaphraseResources) + val librarySamples = librarySamples(LibraryAndroidParaphraseResources) setContent { LazyColumn { item { Header(text = "App Strings") } - items(APP_SAMPLES) { SampleRow(it) } + items(appSamples) { SampleRow(it) } item { Header(text = "Library Strings") } - items(LIBRARY_SAMPLES) { SampleRow(it) } + items(librarySamples) { SampleRow(it) } } } } @@ -83,70 +87,74 @@ class MainActivity : ComponentActivity() { companion object { data class Sample(val label: String, val resource: FormattedResource) - private val APP_SAMPLES = listOf( + private fun appSamples( + paraphraseResources: AppParaphraseResources, + ): List = listOf( Sample( label = "Text Argument", - resource = AppFormattedResources.app_text_argument(name = "Jobu Tupaki"), + resource = paraphraseResources.app_text_argument(name = "Jobu Tupaki"), ), Sample( label = "Date Argument", - resource = AppFormattedResources.app_date_argument(release_date = LocalDate.now()), + resource = paraphraseResources.app_date_argument(release_date = LocalDate.now()), ), Sample( label = "Number Argument", - resource = AppFormattedResources.app_number_argument(budget = 10_000_000), + resource = paraphraseResources.app_number_argument(budget = 10_000_000), ), Sample( label = "Time Argument", - resource = AppFormattedResources.app_time_argument(showtime = ZonedDateTime.now()), + resource = paraphraseResources.app_time_argument(showtime = ZonedDateTime.now()), ), Sample( label = "Plural Argument", - resource = AppFormattedResources.app_plural_argument(count = 5), + resource = paraphraseResources.app_plural_argument(count = 5), ), Sample( label = "Select Argument", - resource = AppFormattedResources.app_select_argument(verse = "alpha"), + resource = paraphraseResources.app_select_argument(verse = "alpha"), ), Sample( label = "Select Ordinal Argument", - resource = AppFormattedResources.app_select_ordinal_argument(count = 5), + resource = paraphraseResources.app_select_ordinal_argument(count = 5), ), ) - private val LIBRARY_SAMPLES = listOf( + private fun librarySamples( + paraphraseResources: LibraryParaphraseResources, + ): List = listOf( Sample( label = "Text Argument", - resource = LibraryFormattedResources.library_text_argument(name = "Jobu Tupaki"), + resource = paraphraseResources.library_text_argument(name = "Jobu Tupaki"), ), Sample( label = "Date Argument", - resource = LibraryFormattedResources.library_date_argument(release_date = LocalDate.now()), + resource = paraphraseResources.library_date_argument(release_date = LocalDate.now()), ), Sample( label = "Number Argument", - resource = LibraryFormattedResources.library_number_argument(budget = 10_000_000), + resource = paraphraseResources.library_number_argument(budget = 10_000_000), ), Sample( label = "Time Argument", - resource = LibraryFormattedResources.library_time_argument(showtime = LocalTime.now()), + resource = paraphraseResources.library_time_argument(showtime = LocalTime.now()), ), Sample( label = "Plural Argument", - resource = LibraryFormattedResources.library_plural_argument(count = 5), + resource = paraphraseResources.library_plural_argument(count = 5), ), Sample( label = "Select Argument", - resource = LibraryFormattedResources.library_select_argument(verse = "alpha"), + resource = paraphraseResources.library_select_argument(verse = "alpha"), ), Sample( label = "Select Ordinal Argument", - resource = LibraryFormattedResources.library_select_ordinal_argument(count = 5), + resource = paraphraseResources.library_select_ordinal_argument(count = 5), ), @Suppress("DEPRECATION") Sample( label = "Choice argument", - resource = LibraryFormattedResources.library_choice_argument(outlook = 100), + resource = paraphraseResources.library_choice_argument(outlook = 100), ), ) } diff --git a/sample/library/build.gradle.kts b/sample/library/build.gradle.kts index e68a51c2..92a8c58d 100644 --- a/sample/library/build.gradle.kts +++ b/sample/library/build.gradle.kts @@ -12,6 +12,10 @@ android { defaultConfig { minSdk = 24 } + + compileOptions { + isCoreLibraryDesugaringEnabled = true + } } androidComponents { @@ -21,3 +25,13 @@ androidComponents { } } } + +dependencies { + coreLibraryDesugaring(libs.coreLibraryDesugaring) + + testImplementation(projects.runtimeTest) + + testImplementation(libs.icu4j) + testImplementation(libs.junit) + testImplementation(libs.truth) +} diff --git a/sample/library/src/androidTest/kotlin/app/cash/paraphrase/sample/library/ParaphraseTest.kt b/sample/library/src/androidTest/kotlin/app/cash/paraphrase/sample/library/ParaphraseTest.kt index 46e30fe6..877a6cd3 100644 --- a/sample/library/src/androidTest/kotlin/app/cash/paraphrase/sample/library/ParaphraseTest.kt +++ b/sample/library/src/androidTest/kotlin/app/cash/paraphrase/sample/library/ParaphraseTest.kt @@ -15,11 +15,11 @@ */ package app.cash.paraphrase.sample.library -import app.cash.paraphrase.sample.library.test.FormattedResources +import app.cash.paraphrase.sample.library.test.AndroidParaphraseResources // Note: This is a compilation test, not a runtime test, so no assertions are needed. class ParaphraseTest { - fun testFormattedResources() { - FormattedResources.library_test_text_argument("Jobu Tupaki") + fun testParaphraseResources() { + AndroidParaphraseResources.library_test_text_argument("Jobu Tupaki") } } diff --git a/sample/library/src/test/kotlin/app/cash/paraphrase/sample/library/ParaphraseResourcesTest.kt b/sample/library/src/test/kotlin/app/cash/paraphrase/sample/library/ParaphraseResourcesTest.kt new file mode 100644 index 00000000..e96aa578 --- /dev/null +++ b/sample/library/src/test/kotlin/app/cash/paraphrase/sample/library/ParaphraseResourcesTest.kt @@ -0,0 +1,61 @@ +/* + * 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 app.cash.paraphrase.runtime.test.JvmDateTimeConverter +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.Test + +class ParaphraseResourcesTest { + + private val stringResolver = FakeStringResolver( + R.string.library_date_argument to "{release_date, date, short}", + R.string.library_time_argument to "{showtime, time, short}", + ) + + private val paraphraseResources = ParaphraseResources(JvmDateTimeConverter) + + @Test fun date() { + val formattedResource = + paraphraseResources.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 = + paraphraseResources.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/settings.gradle.kts b/settings.gradle.kts index 2694861b..8131f969 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -22,6 +22,7 @@ include( ":plugin", ":runtime", ":runtime-compose-ui", + ":runtime-test", ":sample:app", ":sample:library", ":tests", diff --git a/tests/build.gradle.kts b/tests/build.gradle.kts index ca2a402a..aa1eb641 100644 --- a/tests/build.gradle.kts +++ b/tests/build.gradle.kts @@ -27,10 +27,13 @@ android { } dependencies { + implementation(projects.runtimeTest) + implementation(libs.junit) 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/LocalesTest.kt b/tests/src/main/kotlin/app/cash/paraphrase/tests/LocalesTest.kt index fce850e4..f8369434 100644 --- a/tests/src/main/kotlin/app/cash/paraphrase/tests/LocalesTest.kt +++ b/tests/src/main/kotlin/app/cash/paraphrase/tests/LocalesTest.kt @@ -50,7 +50,7 @@ class LocalesTest( private lateinit var resource: FormattedResource @Before fun instantiateResource() { - resource = FormattedResources.locale_date(releaseDate) + resource = AndroidParaphraseResources.locale_date(releaseDate) } @Test fun defaultLocale() { diff --git a/tests/src/main/kotlin/app/cash/paraphrase/tests/NamedTest.kt b/tests/src/main/kotlin/app/cash/paraphrase/tests/NamedTest.kt index 62b3850d..b301620c 100644 --- a/tests/src/main/kotlin/app/cash/paraphrase/tests/NamedTest.kt +++ b/tests/src/main/kotlin/app/cash/paraphrase/tests/NamedTest.kt @@ -22,9 +22,10 @@ import org.junit.Test class NamedTest { private val context = InstrumentationRegistry.getInstrumentation().context + private val paraphraseResources = AndroidParaphraseResources @Test fun numberedSparseOne() { - val formattedResource = FormattedResources.named_one("Z") + val formattedResource = paraphraseResources.named_one("Z") assertThat(formattedResource.arguments as? Map) .containsExactly("one", "Z") @@ -33,7 +34,7 @@ class NamedTest { } @Test fun numberedSparseThree() { - val formattedResource = FormattedResources.named_three("Z", "Y", "X") + val formattedResource = paraphraseResources.named_three("Z", "Y", "X") assertThat(formattedResource.arguments as? Map) .containsExactly("one", "Z", "two", "Y", "three", "X") diff --git a/tests/src/main/kotlin/app/cash/paraphrase/tests/NumberedTest.kt b/tests/src/main/kotlin/app/cash/paraphrase/tests/NumberedTest.kt index 634ab4b3..d62e1827 100644 --- a/tests/src/main/kotlin/app/cash/paraphrase/tests/NumberedTest.kt +++ b/tests/src/main/kotlin/app/cash/paraphrase/tests/NumberedTest.kt @@ -22,9 +22,10 @@ import org.junit.Test class NumberedTest { private val context = InstrumentationRegistry.getInstrumentation().context + private val paraphraseResources = AndroidParaphraseResources @Test fun numberedContiguousOne() { - val formattedResource = FormattedResources.numbered_contiguous_one("Z") + val formattedResource = paraphraseResources.numbered_contiguous_one("Z") assertThat(formattedResource.arguments as? Array) .asList() .containsExactly("Z") @@ -35,7 +36,7 @@ class NumberedTest { } @Test fun numberedContiguousThree() { - val formattedResource = FormattedResources.numbered_contiguous_three("Z", "Y", "X") + val formattedResource = paraphraseResources.numbered_contiguous_three("Z", "Y", "X") assertThat(formattedResource.arguments as? Array) .asList() .containsExactly("Z", "Y", "X") @@ -46,7 +47,7 @@ class NumberedTest { } @Test fun numberedSparseOne() { - val formattedResource = FormattedResources.numbered_sparse_one("Z") + val formattedResource = paraphraseResources.numbered_sparse_one("Z") assertThat(formattedResource.arguments as? Map) .containsExactly("1", "Z") @@ -55,7 +56,7 @@ class NumberedTest { } @Test fun numberedSparseThree() { - val formattedResource = FormattedResources.numbered_sparse_three("Z", "Y", "X") + val formattedResource = paraphraseResources.numbered_sparse_three("Z", "Y", "X") assertThat(formattedResource.arguments as? Map) .containsExactly("1", "Z", "3", "Y", "5", "X") 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..e00497dc 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.FormattedResource import app.cash.paraphrase.getString +import app.cash.paraphrase.runtime.test.JvmDateTimeConverter 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 @@ -32,8 +37,12 @@ import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds 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,116 +56,121 @@ class TypesTest { ZoneId.of("Pacific/Honolulu"), ) + private val paraphraseResources = when (icuImpl) { + IcuImpl.Android -> AndroidParaphraseResources + IcuImpl.Jvm -> ParaphraseResources(dateTimeConverter = JvmDateTimeConverter) + } + @Test fun typeNone() { - val formattedString = context.getString(FormattedResources.type_none("Z")) + val formattedString = getString(paraphraseResources.type_none("Z")) assertThat(formattedString).isEqualTo("A Z B") - val formattedInteger = context.getString(FormattedResources.type_none(2)) + val formattedInteger = getString(paraphraseResources.type_none(2)) assertThat(formattedInteger).isEqualTo("A 2 B") - val formattedDouble = context.getString(FormattedResources.type_none(2.345)) + val formattedDouble = getString(paraphraseResources.type_none(2.345)) assertThat(formattedDouble).isEqualTo("A 2.345 B") val formattedInstant = - context.getString(FormattedResources.type_none(releaseDateTime.toInstant())) + getString(paraphraseResources.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(paraphraseResources.type_number(2)) assertThat(formattedInteger).isEqualTo("A 2 B") - val formattedDouble = context.getString(FormattedResources.type_number(2.345)) + val formattedDouble = getString(paraphraseResources.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(paraphraseResources.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(paraphraseResources.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(paraphraseResources.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(paraphraseResources.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(paraphraseResources.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(paraphraseResources.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(paraphraseResources.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(paraphraseResources.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(paraphraseResources.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(paraphraseResources.type_date_pattern_date_time_zone(releaseDateTime)) assertThat(formatted).isEqualTo("A 3-24, 7PM HST B") } @Test fun typeDatePatternDateTimeOffset() { - val formatted = context.getString( - FormattedResources.type_date_pattern_date_time_offset(releaseDateTime.toOffsetDateTime()), + val formatted = getString( + paraphraseResources.type_date_pattern_date_time_offset(releaseDateTime.toOffsetDateTime()), ) assertThat(formatted).isEqualTo("A 3-24, 7PM -10:00 B") } @Test fun typeDatePatternDateTime() { val localDateTime = releaseDateTime.toLocalDateTime() - val formatted = context.getString(FormattedResources.type_date_pattern_date_time(localDateTime)) + val formatted = getString(paraphraseResources.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(paraphraseResources.type_date_pattern_date_zone(releaseDateTime)) assertThat(formatted).isEqualTo("A March (HST) B") } @Test fun typeDatePatternDateOffset() { - val formatted = context.getString( - FormattedResources.type_date_pattern_date_offset(releaseDateTime.toOffsetDateTime()), + val formatted = getString( + paraphraseResources.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(paraphraseResources.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(paraphraseResources.type_date_pattern_time_zone(releaseDateTime)) assertThat(formatted).isEqualTo("A 19:23 HST B") } @Test fun typeDatePatternTimeOffset() { - val formatted = context.getString( - FormattedResources.type_date_pattern_time_offset( + val formatted = getString( + paraphraseResources.type_date_pattern_time_offset( // Ensures the UTC/GMT case works: releaseDateTime.withZoneSameLocal(ZoneOffset.UTC).toOffsetDateTime().toOffsetTime(), ), @@ -165,98 +179,98 @@ class TypesTest { } @Test fun typeDatePatternTime() { - val formatted = context.getString(FormattedResources.type_date_pattern_time(releaseTime)) + val formatted = getString(paraphraseResources.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(paraphraseResources.type_date_pattern_zone(releaseDateTime)) assertThat(formatted).isEqualTo("A Hawaii-Aleutian Standard Time B") } @Test fun typeDatePatternOffset() { - val formatted = context.getString( - FormattedResources.type_date_pattern_offset(releaseDateTime.offset), + val formatted = getString( + paraphraseResources.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(paraphraseResources.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)) - assertThat(formatted).isEqualTo("A 7:23:45 PM B") + val formatted = getString(paraphraseResources.type_time(releaseTime)) + assertThat(formatted).isEqualTo("A 7:23:45${androidNnbsp}PM B") } @Test fun typeTimeShort() { - val formatted = context.getString(FormattedResources.type_time_short(releaseTime)) - assertThat(formatted).isEqualTo("A 7:23 PM B") + val formatted = getString(paraphraseResources.type_time_short(releaseTime)) + assertThat(formatted).isEqualTo("A 7:23${androidNnbsp}PM B") } @Test fun typeTimeMedium() { - val formatted = context.getString(FormattedResources.type_time_medium(releaseTime)) - assertThat(formatted).isEqualTo("A 7:23:45 PM B") + val formatted = getString(paraphraseResources.type_time_medium(releaseTime)) + assertThat(formatted).isEqualTo("A 7:23:45${androidNnbsp}PM B") } @Test fun typeTimeLong() { - val formatted = context.getString(FormattedResources.type_time_long(releaseDateTime)) - assertThat(formatted).isEqualTo("A 7:23:45 PM HST B") + val formatted = getString(paraphraseResources.type_time_long(releaseDateTime)) + assertThat(formatted).isEqualTo("A 7:23:45${androidNnbsp}PM HST B") } @Test fun typeTimeFull() { - val formatted = context.getString(FormattedResources.type_time_full(releaseDateTime)) - assertThat(formatted).isEqualTo("A 7:23:45 PM Hawaii-Aleutian Standard Time B") + val formatted = getString(paraphraseResources.type_time_full(releaseDateTime)) + assertThat(formatted).isEqualTo("A 7:23:45${androidNnbsp}PM Hawaii-Aleutian Standard Time B") } @Test fun typeTimePatternDateTimeZone() { val formatted = - context.getString(FormattedResources.type_time_pattern_date_time_zone(releaseDateTime)) + getString(paraphraseResources.type_time_pattern_date_time_zone(releaseDateTime)) assertThat(formatted).isEqualTo("A 3-24, 7PM HST B") } @Test fun typeTimePatternDateTimeOffset() { - val formatted = context.getString( - FormattedResources.type_time_pattern_date_time_offset(releaseDateTime.toOffsetDateTime()), + val formatted = getString( + paraphraseResources.type_time_pattern_date_time_offset(releaseDateTime.toOffsetDateTime()), ) assertThat(formatted).isEqualTo("A 3-24, 7PM -10 B") } @Test fun typeTimePatternDateTime() { val localDateTime = releaseDateTime.toLocalDateTime() - val formatted = context.getString(FormattedResources.type_time_pattern_date_time(localDateTime)) + val formatted = getString(paraphraseResources.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(paraphraseResources.type_time_pattern_date_zone(releaseDateTime)) assertThat(formatted).isEqualTo("A March (HST) B") } @Test fun typeTimePatternDateOffset() { - val formatted = context.getString( - FormattedResources.type_time_pattern_date_offset(releaseDateTime.toOffsetDateTime()), + val formatted = getString( + paraphraseResources.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(paraphraseResources.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(paraphraseResources.type_time_pattern_time_zone(releaseDateTime)) assertThat(formatted).isEqualTo("A 19:23 HST B") } @Test fun typeTimePatternTimeOffset() { - val formatted = context.getString( - FormattedResources.type_time_pattern_time_offset( + val formatted = getString( + paraphraseResources.type_time_pattern_time_offset( OffsetTime.of(releaseDateTime.toLocalTime(), releaseDateTime.offset), ), ) @@ -264,24 +278,24 @@ class TypesTest { } @Test fun typeTimePatternTime() { - val formatted = context.getString(FormattedResources.type_time_pattern_time(releaseTime)) + val formatted = getString(paraphraseResources.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(paraphraseResources.type_time_pattern_zone(releaseDateTime)) assertThat(formatted).isEqualTo("A Hawaii-Aleutian Standard Time B") } @Test fun typeTimePatternOffset() { - val formatted = context.getString( - FormattedResources.type_time_pattern_offset(releaseDateTime.offset), + val formatted = getString( + paraphraseResources.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(paraphraseResources.type_time_pattern_none(null)) assertThat(formatted).isEqualTo("A What is this for? B") } @@ -291,8 +305,8 @@ class TypesTest { LocalTime.NOON, ZoneId.of("America/Chicago"), ) - val formatted = context.getString(FormattedResources.type_time_long(winterDateTime)) - assertThat(formatted).isEqualTo("A 12:00:00 PM CST B") + val formatted = getString(paraphraseResources.type_time_long(winterDateTime)) + assertThat(formatted).isEqualTo("A 12:00:00${androidNnbsp}PM CST B") } @Test fun typeTimeWithSummerTimeZone() { @@ -301,33 +315,33 @@ class TypesTest { LocalTime.NOON, ZoneId.of("America/Chicago"), ) - val formatted = context.getString(FormattedResources.type_time_long(summerDateTime)) - assertThat(formatted).isEqualTo("A 12:00:00 PM CDT B") + val formatted = getString(paraphraseResources.type_time_long(summerDateTime)) + assertThat(formatted).isEqualTo("A 12:00:00${androidNnbsp}PM CDT B") } @Test fun typeDuration() { - val formattedSeconds = context.getString(FormattedResources.type_duration(3.seconds)) + val formattedSeconds = getString(paraphraseResources.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(paraphraseResources.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(paraphraseResources.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(paraphraseResources.type_ordinal(zero)) assertThat(formattedZero).isEqualTo("A 0th B") - val formattedOne = context.getString(FormattedResources.type_ordinal(1)) + val formattedOne = getString(paraphraseResources.type_ordinal(1)) assertThat(formattedOne).isEqualTo("A 1st B") - val formattedTwo = context.getString(FormattedResources.type_ordinal(2)) + val formattedTwo = getString(paraphraseResources.type_ordinal(2)) assertThat(formattedTwo).isEqualTo("A 2nd B") - val formattedThree = context.getString(FormattedResources.type_ordinal(3)) + val formattedThree = getString(paraphraseResources.type_ordinal(3)) assertThat(formattedThree).isEqualTo("A 3rd B") - val formattedFour = context.getString(FormattedResources.type_ordinal(4)) + val formattedFour = getString(paraphraseResources.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(paraphraseResources.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 +351,56 @@ class TypesTest { } @Test fun typeSpellout() { - val formattedOnes = context.getString(FormattedResources.type_spellout(3)) + val formattedOnes = getString(paraphraseResources.type_spellout(3)) assertThat(formattedOnes).isEqualTo("A three B") - val formattedTens = context.getString(FormattedResources.type_spellout(32)) + val formattedTens = getString(paraphraseResources.type_spellout(32)) assertThat(formattedTens).isEqualTo("A thirty-two B") - val formattedHundreds = context.getString(FormattedResources.type_spellout(321)) + val formattedHundreds = getString(paraphraseResources.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(paraphraseResources.type_count_plural(0)) assertThat(formatted0).isEqualTo("A Z B") - val formatted1 = context.getString(FormattedResources.type_count_plural(1)) + val formatted1 = getString(paraphraseResources.type_count_plural(1)) assertThat(formatted1).isEqualTo("A Y B") - val formatted2 = context.getString(FormattedResources.type_count_plural(2)) + val formatted2 = getString(paraphraseResources.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(paraphraseResources.type_verse_select("alpha")) assertThat(formattedAlpha).isEqualTo("A Z B") - val formattedBeta = context.getString(FormattedResources.type_verse_select("beta")) + val formattedBeta = getString(paraphraseResources.type_verse_select("beta")) assertThat(formattedBeta).isEqualTo("A Y B") - val formattedGamma = context.getString(FormattedResources.type_verse_select("gamma")) + val formattedGamma = getString(paraphraseResources.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) + .replace(' ', androidNnbsp) + } + } + + enum class IcuImpl { + Android, + Jvm, + } + + private companion object { + /** + * Resolves to ' ' (NNBSP) on Android 34+, but to ' ' (regular space) on older Android OSes, + * reflecting how different Android versions break up the time and the day period. + * + * Example usage: "7:23${androidNnbsp}PM" + */ + val androidNnbsp = when { + Build.VERSION.SDK_INT >= 34 -> ' ' // NNBSP + else -> ' ' // Regular space + } + } }