diff --git a/app/src/sharedTest/java/org/oppia/android/app/parser/HtmlParserTest.kt b/app/src/sharedTest/java/org/oppia/android/app/parser/HtmlParserTest.kt index 571ba12ee85..1ac4f3d429b 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/parser/HtmlParserTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/parser/HtmlParserTest.kt @@ -852,8 +852,10 @@ class HtmlParserTest { } } - private fun createDisplayLocaleImpl(context: OppiaLocaleContext): DisplayLocaleImpl = - DisplayLocaleImpl(context, machineLocale, androidLocaleFactory, formatterFactory) + private fun createDisplayLocaleImpl(context: OppiaLocaleContext): DisplayLocaleImpl { + val formattingLocale = androidLocaleFactory.createOneOffAndroidLocale(context) + return DisplayLocaleImpl(context, formattingLocale, machineLocale, formatterFactory) + } private fun ActivityScenario.getDimensionPixelSize( @DimenRes dimenResId: Int diff --git a/app/src/test/java/org/oppia/android/app/parser/ListItemLeadingMarginSpanTest.kt b/app/src/test/java/org/oppia/android/app/parser/ListItemLeadingMarginSpanTest.kt index d993ffd3de8..a7904d0d1cf 100644 --- a/app/src/test/java/org/oppia/android/app/parser/ListItemLeadingMarginSpanTest.kt +++ b/app/src/test/java/org/oppia/android/app/parser/ListItemLeadingMarginSpanTest.kt @@ -1044,8 +1044,10 @@ class ListItemLeadingMarginSpanTest { return valueCaptor.value } - private fun createDisplayLocaleImpl(context: OppiaLocaleContext): DisplayLocaleImpl = - DisplayLocaleImpl(context, machineLocale, androidLocaleFactory, formatterFactory) + private fun createDisplayLocaleImpl(context: OppiaLocaleContext): DisplayLocaleImpl { + val formattingLocale = androidLocaleFactory.createOneOffAndroidLocale(context) + return DisplayLocaleImpl(context, formattingLocale, machineLocale, formatterFactory) + } private fun setUpTestApplicationComponent() { ApplicationProvider.getApplicationContext().inject(this) diff --git a/domain/src/main/java/org/oppia/android/domain/locale/LocaleController.kt b/domain/src/main/java/org/oppia/android/domain/locale/LocaleController.kt index 51be2481321..0eeb999c8fe 100644 --- a/domain/src/main/java/org/oppia/android/domain/locale/LocaleController.kt +++ b/domain/src/main/java/org/oppia/android/domain/locale/LocaleController.kt @@ -62,7 +62,8 @@ class LocaleController @Inject constructor( private val asyncDataSubscriptionManager: AsyncDataSubscriptionManager, private val machineLocale: MachineLocale, private val androidLocaleFactory: AndroidLocaleFactory, - private val formatterFactory: OppiaBidiFormatter.Factory + private val formatterFactory: OppiaBidiFormatter.Factory, + private val androidLocaleProfileFactory: AndroidLocaleProfile.Factory ) { private val definitionsLock = ReentrantLock() private lateinit var supportedLanguages: SupportedLanguages @@ -115,9 +116,8 @@ class LocaleController @Inject constructor( * for cases in which the user changed their selected language). */ fun reconstituteDisplayLocale(oppiaLocaleContext: OppiaLocaleContext): DisplayLocale { - return DisplayLocaleImpl( - oppiaLocaleContext, machineLocale, androidLocaleFactory, formatterFactory - ) + val formattingLocale = androidLocaleFactory.createOneOffAndroidLocale(oppiaLocaleContext) + return DisplayLocaleImpl(oppiaLocaleContext, formattingLocale, machineLocale, formatterFactory) } /** @@ -257,7 +257,7 @@ class LocaleController @Inject constructor( private fun getSystemLocaleProfile(): DataProvider { return dataProviders.createInMemoryDataProvider(ANDROID_SYSTEM_LOCALE_DATA_PROVIDER_ID) { - AndroidLocaleProfile.createFrom(getSystemLocale()) + androidLocaleProfileFactory.createFrom(getSystemLocale()) } } @@ -293,7 +293,7 @@ class LocaleController @Inject constructor( @Suppress("DEPRECATION") // Old API is needed for SDK versions < N. private fun getDefaultLocale(configuration: Configuration): Locale = configuration.locale - private suspend fun computeLocaleResult( + private suspend inline fun computeLocaleResult( language: OppiaLanguage, systemLocaleProfile: AndroidLocaleProfile, usageMode: LanguageUsageMode @@ -302,7 +302,6 @@ class LocaleController @Inject constructor( // internal weirdness that would lead to a wrong type being produced from the generic helpers. // This shouldn't actually ever happen in practice, but this code gracefully fails to a null // (and thus a failure). - @Suppress("UNCHECKED_CAST") // as? should always be a safe cast, even if unchecked. val locale = computeLocale(language, systemLocaleProfile, usageMode) as? T return locale?.let { AsyncResult.Success(it) @@ -324,7 +323,13 @@ class LocaleController @Inject constructor( retrieveLanguageDefinition(languageDefinition.fallbackMacroLanguage)?.let { fallbackLanguageDefinition = it } - regionDefinition = retrieveRegionDefinition(systemLocaleProfile.regionCode) + val regionCode = when (systemLocaleProfile) { + is AndroidLocaleProfile.LanguageAndRegionProfile -> systemLocaleProfile.regionCode + is AndroidLocaleProfile.RegionOnlyProfile -> systemLocaleProfile.regionCode + is AndroidLocaleProfile.LanguageAndWildcardRegionProfile, + is AndroidLocaleProfile.LanguageOnlyProfile, is AndroidLocaleProfile.RootProfile -> "" + } + regionDefinition = retrieveRegionDefinition(regionCode) this.usageMode = usageMode }.build() @@ -343,8 +348,10 @@ class LocaleController @Inject constructor( } return when (usageMode) { - APP_STRINGS -> - DisplayLocaleImpl(localeContext, machineLocale, androidLocaleFactory, formatterFactory) + APP_STRINGS -> { + val formattingLocale = androidLocaleFactory.createAndroidLocaleAsync(localeContext).await() + DisplayLocaleImpl(localeContext, formattingLocale, machineLocale, formatterFactory) + } CONTENT_STRINGS, AUDIO_TRANSLATIONS -> ContentLocaleImpl(localeContext) USAGE_MODE_UNSPECIFIED, UNRECOGNIZED -> null } @@ -413,9 +420,7 @@ class LocaleController @Inject constructor( return@mapNotNull definition.retrieveAppLanguageProfile()?.let { profile -> profile to definition } - }.find { (profile, _) -> - localeProfile.matches(machineLocale, profile) - }?.let { (_, definition) -> definition } + }.find { (profile, _) -> localeProfile.matches(profile) }?.let { (_, definition) -> definition } } private suspend fun retrieveRegionDefinition(countryCode: String): RegionSupportDefinition { @@ -431,7 +436,7 @@ class LocaleController @Inject constructor( } ?: RegionSupportDefinition.newBuilder().apply { region = OppiaRegion.REGION_UNSPECIFIED regionId = RegionSupportDefinition.IetfBcp47RegionId.newBuilder().apply { - ietfRegionTag = countryCode + ietfRegionTag = machineLocale.run { countryCode.toMachineUpperCase() } }.build() }.build() } @@ -455,10 +460,10 @@ class LocaleController @Inject constructor( private fun LanguageSupportDefinition.retrieveAppLanguageProfile(): AndroidLocaleProfile? { return when (appStringId.languageTypeCase) { LanguageSupportDefinition.LanguageId.LanguageTypeCase.IETF_BCP47_ID -> - AndroidLocaleProfile.createFromIetfDefinitions(appStringId, regionDefinition = null) + androidLocaleProfileFactory.createFromIetfDefinitions(appStringId, regionDefinition = null) LanguageSupportDefinition.LanguageId.LanguageTypeCase.MACARONIC_ID -> { // Likely won't match against system languages. - AndroidLocaleProfile.createFromMacaronicLanguage(appStringId) + androidLocaleProfileFactory.createFromMacaronicLanguage(appStringId) } LanguageSupportDefinition.LanguageId.LanguageTypeCase.LANGUAGETYPE_NOT_SET, null -> null } @@ -473,9 +478,12 @@ class LocaleController @Inject constructor( // must be part of the language definitions. Support for app strings is exposed so that a locale // can be constructed from it. appStringId = LanguageSupportDefinition.LanguageId.newBuilder().apply { - ietfBcp47Id = LanguageSupportDefinition.IetfBcp47LanguageId.newBuilder().apply { - ietfLanguageTag = systemLocaleProfile.computeIetfLanguageTag() - }.build() + // The root profile is assumed if there is no specific language ID to use. + if (systemLocaleProfile !is AndroidLocaleProfile.RootProfile) { + ietfBcp47Id = LanguageSupportDefinition.IetfBcp47LanguageId.newBuilder().apply { + ietfLanguageTag = systemLocaleProfile.ietfLanguageTag + }.build() + } }.build() }.build() diff --git a/domain/src/test/java/org/oppia/android/domain/locale/LocaleControllerTest.kt b/domain/src/test/java/org/oppia/android/domain/locale/LocaleControllerTest.kt index 30467ee6263..9a5eb36e8cc 100644 --- a/domain/src/test/java/org/oppia/android/domain/locale/LocaleControllerTest.kt +++ b/domain/src/test/java/org/oppia/android/domain/locale/LocaleControllerTest.kt @@ -114,12 +114,16 @@ class LocaleControllerTest { } @Test - fun testReconstituteDisplayLocale_defaultContext_returnsDisplayLocaleForContext() { + fun testReconstituteDisplayLocale_defaultContext_throwsException() { val context = OppiaLocaleContext.getDefaultInstance() - val locale = localeController.reconstituteDisplayLocale(context) + val exception = assertThrows() { + localeController.reconstituteDisplayLocale(context) + } - assertThat(locale.localeContext).isEqualToDefaultInstance() + // A default locale context isn't valid by itself (though it can represent the root locale when + // at least the app strings context is present & default). + assertThat(exception).hasMessageThat().contains("Invalid language case") } @Test @@ -243,6 +247,25 @@ class LocaleControllerTest { assertThat(context.regionDefinition.regionId.ietfRegionTag).isEqualTo("MC") } + @Test + fun testAppStringLocale_rootLocale_defaultLang_returnsRootLocale() { + forceDefaultLocale(Locale.ROOT) + + val localeProvider = localeController.retrieveAppStringDisplayLocale(LANGUAGE_UNSPECIFIED) + + // The locale will be forced to the root locale. The root locale also should never provide an + // IETF BCP-47 ID. + val locale = monitorFactory.waitForNextSuccessfulResult(localeProvider) + val context = locale.localeContext + val languageDefinition = context.languageDefinition + assertThat(languageDefinition.language).isEqualTo(LANGUAGE_UNSPECIFIED) + assertThat(languageDefinition.fallbackMacroLanguage).isEqualTo(LANGUAGE_UNSPECIFIED) + assertThat(languageDefinition.appStringId.hasIetfBcp47Id()).isFalse() + assertThat(context.hasFallbackLanguageDefinition()).isFalse() + assertThat(context.regionDefinition.region).isEqualTo(REGION_UNSPECIFIED) + assertThat(context.regionDefinition.regionId.ietfRegionTag).isEmpty() + } + @Test fun testAppStringLocale_newSystemLocale_doesNotNotifyProvider() { forceDefaultLocale(Locale.US) diff --git a/domain/src/test/java/org/oppia/android/domain/translation/TranslationControllerTest.kt b/domain/src/test/java/org/oppia/android/domain/translation/TranslationControllerTest.kt index 195a53887db..b3b501ff94c 100644 --- a/domain/src/test/java/org/oppia/android/domain/translation/TranslationControllerTest.kt +++ b/domain/src/test/java/org/oppia/android/domain/translation/TranslationControllerTest.kt @@ -19,6 +19,7 @@ import org.oppia.android.app.model.AppLanguageSelection.SelectionTypeCase.USE_SY import org.oppia.android.app.model.AudioTranslationLanguageSelection import org.oppia.android.app.model.HtmlTranslationList import org.oppia.android.app.model.LanguageSupportDefinition.LanguageId.LanguageTypeCase.IETF_BCP47_ID +import org.oppia.android.app.model.LanguageSupportDefinition.LanguageId.LanguageTypeCase.LANGUAGETYPE_NOT_SET import org.oppia.android.app.model.OppiaLanguage import org.oppia.android.app.model.OppiaLanguage.ARABIC import org.oppia.android.app.model.OppiaLanguage.BRAZILIAN_PORTUGUESE @@ -112,9 +113,7 @@ class TranslationControllerTest { val appStringId = context.languageDefinition.appStringId assertThat(context.usageMode).isEqualTo(APP_STRINGS) assertThat(context.languageDefinition.language).isEqualTo(LANGUAGE_UNSPECIFIED) - assertThat(appStringId.languageTypeCase).isEqualTo(IETF_BCP47_ID) - assertThat(appStringId.ietfBcp47Id.ietfLanguageTag).isEmpty() - assertThat(appStringId.androidResourcesLanguageId.languageCode).isEmpty() + assertThat(appStringId.languageTypeCase).isEqualTo(LANGUAGETYPE_NOT_SET) assertThat(context.regionDefinition.region).isEqualTo(REGION_UNSPECIFIED) } @@ -290,12 +289,28 @@ class TranslationControllerTest { val appStringId = context.languageDefinition.appStringId assertThat(context.usageMode).isEqualTo(APP_STRINGS) assertThat(context.languageDefinition.language).isEqualTo(LANGUAGE_UNSPECIFIED) - assertThat(appStringId.languageTypeCase).isEqualTo(IETF_BCP47_ID) - assertThat(appStringId.ietfBcp47Id.ietfLanguageTag).isEmpty() - assertThat(appStringId.androidResourcesLanguageId.languageCode).isEmpty() + assertThat(appStringId.languageTypeCase).isEqualTo(LANGUAGETYPE_NOT_SET) assertThat(context.regionDefinition.region).isEqualTo(REGION_UNSPECIFIED) } + @Test + fun testGetAppLanguageLocale_ptBrDefLocale_returnsLocaleWithIetfAndAndroidResourcesLangIds() { + forceDefaultLocale(BRAZIL_PORTUGUESE_LOCALE) + + val localeProvider = translationController.getAppLanguageLocale(PROFILE_ID_0) + + val locale = monitorFactory.waitForNextSuccessfulResult(localeProvider) + val context = locale.localeContext + val appStringId = context.languageDefinition.appStringId + assertThat(context.usageMode).isEqualTo(APP_STRINGS) + assertThat(context.languageDefinition.language).isEqualTo(BRAZILIAN_PORTUGUESE) + assertThat(appStringId.languageTypeCase).isEqualTo(IETF_BCP47_ID) + assertThat(appStringId.ietfBcp47Id.ietfLanguageTag).isEqualTo("pt-BR") + assertThat(appStringId.androidResourcesLanguageId.languageCode).isEqualTo("pt") + assertThat(appStringId.androidResourcesLanguageId.regionCode).isEqualTo("BR") + assertThat(context.regionDefinition.region).isEqualTo(BRAZIL) + } + @Test fun testGetAppLanguageLocale_updateLanguageToEnglish_returnsEnglishLocale() { forceDefaultLocale(Locale.ROOT) @@ -1937,6 +1952,7 @@ class TranslationControllerTest { private companion object { private val BRAZIL_ENGLISH_LOCALE = Locale("en", "BR") + private val BRAZIL_PORTUGUESE_LOCALE = Locale("pt", "BR") private val INDIA_HINDI_LOCALE = Locale("hi", "IN") private val KENYA_KISWAHILI_LOCALE = Locale("sw", "KE") diff --git a/scripts/assets/file_content_validation_checks.textproto b/scripts/assets/file_content_validation_checks.textproto index 67116fbd5a2..f830caf8ab2 100644 --- a/scripts/assets/file_content_validation_checks.textproto +++ b/scripts/assets/file_content_validation_checks.textproto @@ -598,3 +598,10 @@ file_content_checks { exempted_file_name: "scripts/src/javatests/org/oppia/android/scripts/regex/RegexPatternValidationCheckTest.kt" exempted_file_name: "testing/src/test/java/org/oppia/android/testing/espresso/TextInputActionTest.kt" } +file_content_checks { + file_path_regex: ".+?\\.kt" + prohibited_content_regex: "computeIfAbsent\\(" + failure_message: "computeIfAbsent won't desugar and requires Java 8 support (SDK 24+). Suggest using an atomic Kotlin-specific solution, instead." + exempted_file_name: "scripts/src/javatests/org/oppia/android/scripts/regex/RegexPatternValidationCheckTest.kt" + exempted_file_name: "utility/src/main/java/org/oppia/android/util/caching/testing/FakeAssetRepository.kt" +} diff --git a/scripts/src/javatests/org/oppia/android/scripts/regex/RegexPatternValidationCheckTest.kt b/scripts/src/javatests/org/oppia/android/scripts/regex/RegexPatternValidationCheckTest.kt index a3eb9c825d9..f2b8b214649 100644 --- a/scripts/src/javatests/org/oppia/android/scripts/regex/RegexPatternValidationCheckTest.kt +++ b/scripts/src/javatests/org/oppia/android/scripts/regex/RegexPatternValidationCheckTest.kt @@ -218,6 +218,9 @@ class RegexPatternValidationCheckTest { "ActivityScenarioRule can result in order dependence when static state leaks across tests" + " (such as static module variables), and can make staging much more difficult for platform" + " parameters. Use ActivityScenario directly, instead." + private val referenceComputeIfAbsent = + "computeIfAbsent won't desugar and requires Java 8 support (SDK 24+). Suggest using an atomic" + + " Kotlin-specific solution, instead." private val wikiReferenceNote = "Refer to https://github.com/oppia/oppia-android/wiki/Static-Analysis-Checks" + "#regexpatternvalidation-check for more details on how to fix this." @@ -2728,6 +2731,28 @@ class RegexPatternValidationCheckTest { ) } + @Test + fun testFileContent_includesReferenceToComputeIfAbsent_fileContentIsNotCorrect() { + val prohibitedContent = + """ + someMap.computeIfAbsent(key) { createOtherValue() } + """.trimIndent() + tempFolder.newFolder("testfiles", "app", "src", "main", "java", "org", "oppia", "android") + val stringFilePath = "app/src/main/java/org/oppia/android/TestPresenter.kt" + tempFolder.newFile("testfiles/$stringFilePath").writeText(prohibitedContent) + + val exception = assertThrows() { runScript() } + + assertThat(exception).hasMessageThat().contains(REGEX_CHECK_FAILED_OUTPUT_INDICATOR) + assertThat(outContent.toString().trim()) + .isEqualTo( + """ + $stringFilePath:1: $referenceComputeIfAbsent + $wikiReferenceNote + """.trimIndent() + ) + } + /** Runs the regex_pattern_validation_check. */ private fun runScript() { main(File(tempFolder.root, "testfiles").absolutePath) diff --git a/utility/src/main/java/org/oppia/android/util/data/DataProviders.kt b/utility/src/main/java/org/oppia/android/util/data/DataProviders.kt index 93049848c3c..4c11876168e 100644 --- a/utility/src/main/java/org/oppia/android/util/data/DataProviders.kt +++ b/utility/src/main/java/org/oppia/android/util/data/DataProviders.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.transform import kotlinx.coroutines.launch +import org.oppia.android.util.data.DataProviders.Companion.transform import org.oppia.android.util.logging.ExceptionLogger import org.oppia.android.util.threading.BackgroundDispatcher import java.util.concurrent.atomic.AtomicBoolean @@ -74,7 +75,12 @@ class DataProviders @Inject constructor( override fun getId(): Any = newId override suspend fun retrieveData(): AsyncResult { - return this@transformAsync.retrieveData().transformAsync(function) + return try { + this@transformAsync.retrieveData().transformAsync(function) + } catch (e: Exception) { + dataProviders.exceptionLogger.logException(e) + AsyncResult.Failure(e) + } } } } diff --git a/utility/src/main/java/org/oppia/android/util/locale/AndroidLocaleFactory.kt b/utility/src/main/java/org/oppia/android/util/locale/AndroidLocaleFactory.kt index ffe3e186646..e871355442b 100644 --- a/utility/src/main/java/org/oppia/android/util/locale/AndroidLocaleFactory.kt +++ b/utility/src/main/java/org/oppia/android/util/locale/AndroidLocaleFactory.kt @@ -1,6 +1,10 @@ package org.oppia.android.util.locale import android.os.Build +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async import org.oppia.android.app.model.LanguageSupportDefinition import org.oppia.android.app.model.LanguageSupportDefinition.LanguageId import org.oppia.android.app.model.LanguageSupportDefinition.LanguageId.LanguageTypeCase.IETF_BCP47_ID @@ -9,8 +13,8 @@ import org.oppia.android.app.model.LanguageSupportDefinition.LanguageId.Language import org.oppia.android.app.model.OppiaLocaleContext import org.oppia.android.app.model.OppiaLocaleContext.LanguageUsageMode import org.oppia.android.app.model.OppiaLocaleContext.LanguageUsageMode.APP_STRINGS +import org.oppia.android.util.threading.BlockingDispatcher import java.util.Locale -import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Singleton @@ -20,9 +24,28 @@ import javax.inject.Singleton */ @Singleton class AndroidLocaleFactory @Inject constructor( - private val profileChooserSelector: ProposalChooser.Selector + private val profileChooserSelector: ProposalChooser.Selector, + @BlockingDispatcher private val blockingDispatcher: CoroutineDispatcher, + private val androidLocaleProfileFactory: AndroidLocaleProfile.Factory ) { - private val memoizedLocales by lazy { ConcurrentHashMap() } + private val memoizedLocales = mutableMapOf() + + /** + * Creates and returns a new [Locale] that matches the given [OppiaLocaleContext]. + * + * See [createAndroidLocaleAsync] for specifics. Note that this function, unlike the async + * version, does **not** cache or try to load a pre-created [Locale] for the given context. + * Creating new [Locale]s can be expensive, so it's always preferred to use + * [createAndroidLocaleAsync] except in cases where that isn't an option. + */ + fun createOneOffAndroidLocale(localeContext: OppiaLocaleContext): Locale { + val chooser = profileChooserSelector.findBestChooser(localeContext) + val primaryLocaleSource = + LocaleSource.createFromPrimary(localeContext, androidLocaleProfileFactory) + val fallbackLocaleSource = + LocaleSource.createFromFallback(localeContext, androidLocaleProfileFactory) + return chooser.findBestProposal(primaryLocaleSource, fallbackLocaleSource).computedLocale + } /** * Creates a new [Locale] that matches the given [OppiaLocaleContext]. @@ -50,18 +73,17 @@ class AndroidLocaleFactory @Inject constructor( * - For other locale-based operations, the forced [Locale] will behave like the system's * [Locale.ROOT]. * + * Note that the returned [Locale] may be cached within the factory for performance reasons, so + * the returned value uses a [Deferred] to ensure that this method can guarantee thread-safe + * access. + * * @param localeContext the [OppiaLocaleContext] to use as a basis for finding a similar [Locale] * @return the best [Locale] to match the provided [localeContext] */ - fun createAndroidLocale(localeContext: OppiaLocaleContext): Locale { - // Note: computeIfAbsent is used here instead of getOrPut to ensure atomicity across multiple - // threads calling into this create function. - return memoizedLocales.computeIfAbsent(localeContext) { - val chooser = profileChooserSelector.findBestChooser(localeContext) - val primaryLocaleSource = LocaleSource.createFromPrimary(localeContext) - val fallbackLocaleSource = LocaleSource.createFromFallback(localeContext) - val proposal = chooser.findBestProposal(primaryLocaleSource, fallbackLocaleSource) - return@computeIfAbsent proposal.computedLocale + fun createAndroidLocaleAsync(localeContext: OppiaLocaleContext): Deferred { + // A blocking dispatcher is used to ensure thread safety when updating the locales map. + return CoroutineScope(blockingDispatcher).async { + memoizedLocales.getOrPut(localeContext) { createOneOffAndroidLocale(localeContext) } } } @@ -76,21 +98,17 @@ class AndroidLocaleFactory @Inject constructor( /** * A computed [Locale] that most closely represents the [AndroidLocaleProfile] of this proposal. */ - val computedLocale: Locale - get() = Locale(profile.languageCode, profile.getNonWildcardRegionCode()) + val computedLocale: Locale by lazy { profile.computeAndroidLocale() } /** * Determines whether the [AndroidLocaleProfile] of this proposal is a viable choice for using * to compute a [Locale] (e.g. via [computedLocale]). * - * @param machineLocale the app's [OppiaLocale.MachineLocale] - * @param systemProfiles [AndroidLocaleProfile]s representing the system's available locales + * @param localeProfileRepository the [LocaleProfileRepository]s representing the system's + * available locales * @return whether this proposal has a viable profile for creating a [Locale] */ - abstract fun isViable( - machineLocale: OppiaLocale.MachineLocale, - systemProfiles: List - ): Boolean + abstract fun isViable(localeProfileRepository: LocaleProfileRepository): Boolean /** * A [LocaleProfileProposal] that is only viable if its [profile] is among the available system @@ -101,10 +119,9 @@ class AndroidLocaleFactory @Inject constructor( val minAndroidSdkVersion: Int ) : LocaleProfileProposal() { override fun isViable( - machineLocale: OppiaLocale.MachineLocale, - systemProfiles: List + localeProfileRepository: LocaleProfileRepository ): Boolean { - return systemProfiles.any { it.matches(machineLocale, profile) } && + return localeProfileRepository.availableLocaleProfiles.any { it.matches(profile) } && minAndroidSdkVersion <= Build.VERSION.SDK_INT } } @@ -119,15 +136,8 @@ class AndroidLocaleFactory @Inject constructor( override val profile: AndroidLocaleProfile, val minAndroidSdkVersion: Int ) : LocaleProfileProposal() { - override fun isViable( - machineLocale: OppiaLocale.MachineLocale, - systemProfiles: List - ): Boolean = minAndroidSdkVersion <= Build.VERSION.SDK_INT - } - - private companion object { - private fun AndroidLocaleProfile.getNonWildcardRegionCode(): String = - regionCode.takeIf { it != AndroidLocaleProfile.REGION_WILDCARD } ?: "" + override fun isViable(localeProfileRepository: LocaleProfileRepository): Boolean = + minAndroidSdkVersion <= Build.VERSION.SDK_INT } } @@ -143,7 +153,8 @@ class AndroidLocaleFactory @Inject constructor( class LocaleSource private constructor( private val localeContext: OppiaLocaleContext, private val definition: LanguageSupportDefinition, - private val languageId: LanguageId + private val languageId: LanguageId, + private val androidLocaleProfileFactory: AndroidLocaleProfile.Factory ) { private val regionDefinition by lazy { localeContext.regionDefinition.takeIf { localeContext.hasRegionDefinition() } @@ -156,7 +167,7 @@ class AndroidLocaleFactory @Inject constructor( */ fun computeSystemMatchingProposals(): List { return listOfNotNull( - computeLocaleProfileFromAndroidId()?.toSystemProposal(), + createAndroidResourcesProfile()?.toSystemProposal(), createIetfProfile()?.toSystemProposal(), createMacaronicProfile()?.toSystemProposal() ) @@ -169,7 +180,7 @@ class AndroidLocaleFactory @Inject constructor( * configured for this source's context. */ fun computeForcedAndroidProposal(): LocaleProfileProposal? = - computeLocaleProfileFromAndroidId()?.toForcedProposal() + createAndroidResourcesProfile()?.toForcedProposal() /** * Returns a [LocaleProfileProposal] representing a [LocaleProfileProposal.ForcedProposal] that @@ -177,37 +188,33 @@ class AndroidLocaleFactory @Inject constructor( * * Note that the returned proposal will prioritize its Android ID configuration over * alternatives (such as IETF BCP 47 or a macaronic language configuration). + * + * @param fallBackToRootProfile whether to return a [AndroidLocaleProfile.RootProfile] for cases + * when a valid proposal cannot be determined rather than throwing an exception */ - fun computeForcedProposal(): LocaleProfileProposal = - computeForcedAndroidProposal() ?: languageId.toForcedProposal() - - private fun computeLocaleProfileFromAndroidId(): AndroidLocaleProfile? { - return languageId.androidResourcesLanguageId.takeIf { - languageId.hasAndroidResourcesLanguageId() && it.languageCode.isNotEmpty() - }?.let { - // Empty region codes are allowed for Android resource IDs since they should always be used - // verbatim to ensure the correct Android resource string can be computed (such as for macro - // languages). - AndroidLocaleProfile( - it.languageCode, - regionCode = it.regionCode.ifEmpty { AndroidLocaleProfile.REGION_WILDCARD } - ) - } - } + fun computeForcedProposal(fallBackToRootProfile: Boolean): LocaleProfileProposal = + computeForcedAndroidProposal() ?: toForcedProposal(fallBackToRootProfile) - private fun LanguageId.toForcedProposal(): LocaleProfileProposal { - return when (languageId.languageTypeCase) { + private fun toForcedProposal(fallBackToRootProfile: Boolean): LocaleProfileProposal { + return when (val languageTypeCase = languageId.languageTypeCase) { IETF_BCP47_ID -> createIetfProfile().expectedProfile() MACARONIC_ID -> createMacaronicProfile().expectedProfile() - LANGUAGETYPE_NOT_SET, null -> error("Invalid language case: $languageTypeCase.") + LANGUAGETYPE_NOT_SET, null -> { + if (fallBackToRootProfile) { + AndroidLocaleProfile.RootProfile + } else error("Invalid language case: $languageTypeCase.") + } }.toForcedProposal() } private fun createIetfProfile(): AndroidLocaleProfile? = - AndroidLocaleProfile.createFromIetfDefinitions(languageId, regionDefinition) + androidLocaleProfileFactory.createFromIetfDefinitions(languageId, regionDefinition) private fun createMacaronicProfile(): AndroidLocaleProfile? = - AndroidLocaleProfile.createFromMacaronicLanguage(languageId) + androidLocaleProfileFactory.createFromMacaronicLanguage(languageId) + + private fun createAndroidResourcesProfile(): AndroidLocaleProfile? = + androidLocaleProfileFactory.createFromAndroidResourcesLanguageId(languageId) private fun AndroidLocaleProfile?.expectedProfile() = this ?: error("Invalid ID: $languageId.") @@ -222,18 +229,31 @@ class AndroidLocaleFactory @Inject constructor( * Return a new [LocaleSource] that maps to [localeContext]'s primary language configuration * (i.e. fallback language details will be ignored). */ - fun createFromPrimary(localeContext: OppiaLocaleContext): LocaleSource = - LocaleSource(localeContext, localeContext.languageDefinition, localeContext.getLanguageId()) + fun createFromPrimary( + localeContext: OppiaLocaleContext, + androidLocaleProfileFactory: AndroidLocaleProfile.Factory + ): LocaleSource { + return LocaleSource( + localeContext, + localeContext.languageDefinition, + localeContext.getLanguageId(), + androidLocaleProfileFactory + ) + } /** * Return a new [LocaleSource] that maps to [localeContext]'s fallback (secondary) language * configuration (i.e. primary language details will be ignored). */ - fun createFromFallback(localeContext: OppiaLocaleContext): LocaleSource { + fun createFromFallback( + localeContext: OppiaLocaleContext, + androidLocaleProfileFactory: AndroidLocaleProfile.Factory + ): LocaleSource { return LocaleSource( localeContext, localeContext.fallbackLanguageDefinition, - localeContext.getFallbackLanguageId() + localeContext.getFallbackLanguageId(), + androidLocaleProfileFactory ) } } @@ -288,15 +308,15 @@ class AndroidLocaleFactory @Inject constructor( * system locales. */ class MatchedLocalePreferredChooser @Inject constructor( - private val machineLocale: OppiaLocale.MachineLocale + private val localeProfileRepository: LocaleProfileRepository ) : ProposalChooser { override fun findBestProposal( primarySource: LocaleSource, fallbackSource: LocaleSource ): LocaleProfileProposal { - return primarySource.computeSystemMatchingProposals().findFirstViable(machineLocale) - ?: fallbackSource.computeSystemMatchingProposals().findFirstViable(machineLocale) - ?: primarySource.computeForcedProposal() + return primarySource.computeSystemMatchingProposals().findFirstViable(localeProfileRepository) + ?: fallbackSource.computeSystemMatchingProposals().findFirstViable(localeProfileRepository) + ?: primarySource.computeForcedProposal(fallBackToRootProfile = false) } } @@ -308,31 +328,43 @@ class AndroidLocaleFactory @Inject constructor( * [Locale]s produced by such profiles in order to correctly produce app UI strings. */ class AndroidResourceCompatibilityPreferredChooser @Inject constructor( - private val machineLocale: OppiaLocale.MachineLocale + private val localeProfileRepository: LocaleProfileRepository ) : ProposalChooser { override fun findBestProposal( primarySource: LocaleSource, fallbackSource: LocaleSource ): LocaleProfileProposal { - return primarySource.computeSystemMatchingProposals().findFirstViable(machineLocale) - ?: primarySource.computeForcedAndroidProposal()?.takeOnlyIfViable(machineLocale) - ?: fallbackSource.computeSystemMatchingProposals().findFirstViable(machineLocale) - ?: fallbackSource.computeForcedAndroidProposal()?.takeOnlyIfViable(machineLocale) - ?: primarySource.computeForcedProposal() + // Note that defaulting to the root locale only makes sense for app strings (since app strings + // are picked based on the configured system locale). + return primarySource.computeSystemMatchingProposals().findFirstViable(localeProfileRepository) + ?: primarySource.computeForcedAndroidProposal()?.takeOnlyIfViable(localeProfileRepository) + ?: fallbackSource.computeSystemMatchingProposals().findFirstViable(localeProfileRepository) + ?: fallbackSource.computeForcedAndroidProposal()?.takeOnlyIfViable(localeProfileRepository) + ?: primarySource.computeForcedProposal(fallBackToRootProfile = true) } } - private companion object { - private val availableLocaleProfiles by lazy { - Locale.getAvailableLocales().map(AndroidLocaleProfile::createFrom) + /** + * An application-injectable repository storing all possible [AndroidLocaleProfile]s available to + * use on the local system for the lifetime of the current app instance. + */ + @Singleton + class LocaleProfileRepository @Inject constructor( + private val androidLocaleProfileFactory: AndroidLocaleProfile.Factory + ) { + /** + * All available [AndroidLocaleProfile]s that represent locales on the current running system. + */ + val availableLocaleProfiles: List by lazy { + Locale.getAvailableLocales().map { androidLocaleProfileFactory.createFrom(it) } } + } - private fun List.findFirstViable( - machineLocale: OppiaLocale.MachineLocale - ) = firstOrNull { it.isViable(machineLocale, availableLocaleProfiles) } + private companion object { + private fun List.findFirstViable(repository: LocaleProfileRepository) = + firstOrNull { it.isViable(repository) } - private fun LocaleProfileProposal.takeOnlyIfViable( - machineLocale: OppiaLocale.MachineLocale - ): LocaleProfileProposal? = takeIf { isViable(machineLocale, availableLocaleProfiles) } + private fun LocaleProfileProposal.takeOnlyIfViable(repository: LocaleProfileRepository) = + takeIf { isViable(repository) } } } diff --git a/utility/src/main/java/org/oppia/android/util/locale/AndroidLocaleProfile.kt b/utility/src/main/java/org/oppia/android/util/locale/AndroidLocaleProfile.kt index a410c2b06b6..9c96ec0f8fc 100644 --- a/utility/src/main/java/org/oppia/android/util/locale/AndroidLocaleProfile.kt +++ b/utility/src/main/java/org/oppia/android/util/locale/AndroidLocaleProfile.kt @@ -3,50 +3,156 @@ package org.oppia.android.util.locale import org.oppia.android.app.model.LanguageSupportDefinition.LanguageId import org.oppia.android.app.model.RegionSupportDefinition import java.util.Locale +import javax.inject.Inject /** * A profile to represent an Android [Locale] object which can be used to easily compare different * locales (based on the properties the app cares about), or reconstruct a [Locale] object. * - * @property languageCode the IETF BCP 47 or ISO 639-2/3 language code - * @property regionCode the IETF BCP 47 or ISO 3166 alpha-2 region code + * Subclasses of this sealed class operate on a language code and/or region code. The language code + * is an IETF BCP 47 or ISO 639-2/3 language code, or empty if unknown or not specified. The region + * code is an IETF BCP 47 or ISO 3166 alpha-2 region code, or empty if unknown or not specified. + * + * New instances should be created using [Factory]. */ -data class AndroidLocaleProfile(val languageCode: String, val regionCode: String) { +sealed class AndroidLocaleProfile { + /** + * An IETF BCP 47-esque language tag that represents this locale profile. For profiles that have + * valid IETF BCP 47 language & region codes, the returned tag should be a valid IETF BCP 47 + * language tag. + */ + abstract val ietfLanguageTag: String + /** Returns whether this profile matches the specified [otherProfile] for the given locale. */ - fun matches( - machineLocale: OppiaLocale.MachineLocale, - otherProfile: AndroidLocaleProfile, - ): Boolean { - return machineLocale.run { - languageCode.equalsIgnoreCase(otherProfile.languageCode) - } && machineLocale.run { - val regionsAreEqual = regionCode.equalsIgnoreCase(otherProfile.regionCode) - val eitherRegionIsWildcard = - regionCode == REGION_WILDCARD || otherProfile.regionCode == REGION_WILDCARD - return@run regionsAreEqual || eitherRegionIsWildcard + abstract fun matches(otherProfile: AndroidLocaleProfile): Boolean + + /** Returns an Android [Locale] compatible with this profile. */ + abstract fun computeAndroidLocale(): Locale + + /** + * An [AndroidLocaleProfile] that provides both a non-empty language and region code. + * + * Note that, generally, this should never need to be created directly. Instead, [Factory] should + * be used to create new instances of profiles. + * + * @property languageCode the lowercase two-letter language code in this profile + * @property regionCode the lowercase two-letter region code in this profile + * @property regionCodeUpperCase the uppercase version of [regionCode] + */ + data class LanguageAndRegionProfile( + val languageCode: String, + val regionCode: String, + private val regionCodeUpperCase: String + ) : AndroidLocaleProfile() { + // The region code is usually uppercase in IETF BCP-47 tags when extending a language code. + override val ietfLanguageTag = "$languageCode-$regionCodeUpperCase" + + override fun matches(otherProfile: AndroidLocaleProfile): Boolean { + return when (otherProfile) { + is LanguageAndRegionProfile -> + languageCode == otherProfile.languageCode && regionCode == otherProfile.regionCode + is LanguageAndWildcardRegionProfile -> languageCode == otherProfile.languageCode + is LanguageOnlyProfile, is RegionOnlyProfile, is RootProfile -> false + } } + + override fun computeAndroidLocale(): Locale = Locale(languageCode, regionCode) } /** - * Returns an IETF BCP 47-esque language tag that represents this locale profile. For profiles - * that have valid IETF BCP 47 language & region codes, the returned tag should be a valid IETF - * BCP 47 language tag. + * An [AndroidLocaleProfile] that provides only a non-empty region code. + * + * Note that, generally, this should never need to be created directly. Instead, [Factory] should + * be used to create new instances of profiles. + * + * @property regionCode the lowercase two-letter region code in this profile */ - fun computeIetfLanguageTag(): String { - return when { - languageCode.isNotEmpty() && regionCode.isNotEmptyOrWildcard() -> "$languageCode-$regionCode" - regionCode.isNotEmptyOrWildcard() -> regionCode - else -> languageCode + data class RegionOnlyProfile(val regionCode: String) : AndroidLocaleProfile() { + override val ietfLanguageTag = regionCode + + override fun matches(otherProfile: AndroidLocaleProfile): Boolean = + otherProfile is RegionOnlyProfile && regionCode == otherProfile.regionCode + + override fun computeAndroidLocale(): Locale = Locale(/* language = */ "", regionCode) + } + + /** + * An [AndroidLocaleProfile] that provides only a non-empty language code. + * + * Note that, generally, this should never need to be created directly. Instead, [Factory] should + * be used to create new instances of profiles. + * + * @property languageCode the lowercase two-letter language code in this profile + */ + data class LanguageOnlyProfile(val languageCode: String) : AndroidLocaleProfile() { + override val ietfLanguageTag = languageCode + + override fun matches(otherProfile: AndroidLocaleProfile): Boolean { + return when (otherProfile) { + is LanguageOnlyProfile -> languageCode == otherProfile.languageCode + is LanguageAndWildcardRegionProfile -> languageCode == otherProfile.languageCode + is LanguageAndRegionProfile, is RegionOnlyProfile, is RootProfile -> false + } + } + + override fun computeAndroidLocale(): Locale = Locale(languageCode) + } + + /** + * An [AndroidLocaleProfile] that provides only a non-empty language code, but matches (e.g. via + * [matches]) with any profile that has the same language code. + * + * Note that, generally, this should never need to be created directly. Instead, [Factory] should + * be used to create new instances of profiles. + * + * @property languageCode the lowercase two-letter language code in this profile + */ + data class LanguageAndWildcardRegionProfile(val languageCode: String) : AndroidLocaleProfile() { + override val ietfLanguageTag = languageCode + + override fun matches(otherProfile: AndroidLocaleProfile): Boolean { + return when (otherProfile) { + is LanguageAndRegionProfile -> languageCode == otherProfile.languageCode + is LanguageAndWildcardRegionProfile -> languageCode == otherProfile.languageCode + is LanguageOnlyProfile -> languageCode == otherProfile.languageCode + is RegionOnlyProfile, is RootProfile -> false + } } + + override fun computeAndroidLocale(): Locale = Locale(languageCode) } - companion object { - /** A wildcard that will match against any region when provided. */ - const val REGION_WILDCARD = "*" + /** + * An [AndroidLocaleProfile] that provides the system's root locale ([Locale.ROOT]). + * + * Note that, generally, this should never need to be used directly. Instead, [Factory] should be + * used to create new instances of profiles. + */ + object RootProfile : AndroidLocaleProfile() { + override val ietfLanguageTag = "" + + override fun matches(otherProfile: AndroidLocaleProfile): Boolean = otherProfile is RootProfile + override fun computeAndroidLocale(): Locale = Locale.ROOT + } + + /** An application-injectable factory for creating new [AndroidLocaleProfile]s. */ + class Factory @Inject constructor(private val machineLocale: OppiaLocale.MachineLocale) { /** Returns a new [AndroidLocaleProfile] that represents the specified Android [Locale]. */ - fun createFrom(androidLocale: Locale): AndroidLocaleProfile = - AndroidLocaleProfile(androidLocale.language, androidLocale.country) + fun createFrom(androidLocale: Locale): AndroidLocaleProfile { + val languageCode = androidLocale.language + val regionCode = androidLocale.country + return when { + languageCode.isNotEmpty() && regionCode.isNotEmpty() -> { + LanguageAndRegionProfile( + languageCode.asLowerCase(), regionCode.asLowerCase(), regionCode.asUpperCase() + ) + } + regionCode.isNotEmpty() -> RegionOnlyProfile(regionCode.asLowerCase()) + languageCode.isNotEmpty() -> LanguageOnlyProfile(languageCode.asLowerCase()) + else -> RootProfile + } + } /** * Returns a new [AndroidLocaleProfile] using the IETF BCP 47 tag in the provided [LanguageId]. @@ -100,17 +206,48 @@ data class AndroidLocaleProfile(val languageCode: String, val regionCode: String return maybeConstructProfile(languageCode, regionCode) } + /** + * Returns a new [AndroidLocaleProfile] using the provided [languageId]'s + * [LanguageId.getAndroidResourcesLanguageId] as the basis of the profile, or null if none can + * be created. + * + * This is meant to be used in cases when an [AndroidLocaleProfile] is needed to match a + * specific Android-compatible [Locale] (e.g. via [AndroidLocaleProfile.computeAndroidLocale]) + * that can correctly match to specific Android app strings. + */ + fun createFromAndroidResourcesLanguageId(languageId: LanguageId): AndroidLocaleProfile? { + val languageCode = languageId.androidResourcesLanguageId.languageCode + val regionCode = languageId.androidResourcesLanguageId.regionCode + return when { + !languageId.hasAndroidResourcesLanguageId() -> null + languageCode.isEmpty() -> null + // Empty region codes are allowed for Android resource IDs since they should always be used + // verbatim to ensure the correct Android resource string can be computed (such as for macro + // languages). + regionCode.isEmpty() -> LanguageAndWildcardRegionProfile(languageCode.asLowerCase()) + else -> { + LanguageAndRegionProfile( + languageCode.asLowerCase(), regionCode.asLowerCase(), regionCode.asUpperCase() + ) + } + } + } + private fun maybeConstructProfile( languageCode: String, regionCode: String, emptyRegionAsWildcard: Boolean = false ): AndroidLocaleProfile? { - return if (languageCode.isNotEmpty() && (regionCode.isNotEmpty() || emptyRegionAsWildcard)) { - val adjustedRegionCode = if (emptyRegionAsWildcard && regionCode.isEmpty()) { - REGION_WILDCARD - } else regionCode - AndroidLocaleProfile(languageCode, adjustedRegionCode) - } else null + return when { + languageCode.isEmpty() -> null + regionCode.isNotEmpty() -> { + LanguageAndRegionProfile( + languageCode.asLowerCase(), regionCode.asLowerCase(), regionCode.asUpperCase() + ) + } + emptyRegionAsWildcard -> LanguageAndWildcardRegionProfile(languageCode.asLowerCase()) + else -> null + } } private fun String.divide(delimiter: String): Pair? { @@ -120,6 +257,8 @@ data class AndroidLocaleProfile(val languageCode: String, val regionCode: String } else null } - private fun String.isNotEmptyOrWildcard() = isNotEmpty() && this != REGION_WILDCARD + private fun String.asLowerCase() = machineLocale.run { toMachineLowerCase() } + + private fun String.asUpperCase() = machineLocale.run { toMachineUpperCase() } } } diff --git a/utility/src/main/java/org/oppia/android/util/locale/BUILD.bazel b/utility/src/main/java/org/oppia/android/util/locale/BUILD.bazel index dcec5fbc2c9..9635257d80e 100644 --- a/utility/src/main/java/org/oppia/android/util/locale/BUILD.bazel +++ b/utility/src/main/java/org/oppia/android/util/locale/BUILD.bazel @@ -12,6 +12,7 @@ kt_android_library( ], visibility = ["//:oppia_api_visibility"], deps = [ + ":dagger", ":oppia_locale", ], ) @@ -75,6 +76,8 @@ kt_android_library( deps = [ ":android_locale_profile", ":dagger", + "//third_party:org_jetbrains_kotlinx_kotlinx-coroutines-core", + "//utility/src/main/java/org/oppia/android/util/threading:annotations", ], ) diff --git a/utility/src/main/java/org/oppia/android/util/locale/DisplayLocaleImpl.kt b/utility/src/main/java/org/oppia/android/util/locale/DisplayLocaleImpl.kt index 838c8b192f7..f8bff8b2b06 100644 --- a/utility/src/main/java/org/oppia/android/util/locale/DisplayLocaleImpl.kt +++ b/utility/src/main/java/org/oppia/android/util/locale/DisplayLocaleImpl.kt @@ -12,17 +12,21 @@ import java.util.Date import java.util.Locale import java.util.Objects -// TODO(#3766): Restrict to be 'internal'. -/** Implementation of [OppiaLocale.DisplayLocale]. */ +// TODO(#3766): Restrict DisplayLocaleImpl and formattingLocale to be 'internal'. +/** + * Implementation of [OppiaLocale.DisplayLocale]. + * + * @property localeContext the [OppiaLocaleContext] that this locale is representing + * @property formattingLocale the [Locale] used for user-facing string formatting + * @property machineLocale the application-wide [MachineLocale] used for string formatting + * @property formatterFactory the application-wide factory for creating a new [OppiaBidiFormatter] + */ class DisplayLocaleImpl( localeContext: OppiaLocaleContext, + val formattingLocale: Locale, private val machineLocale: MachineLocale, - private val androidLocaleFactory: AndroidLocaleFactory, private val formatterFactory: OppiaBidiFormatter.Factory ) : OppiaLocale.DisplayLocale(localeContext) { - // TODO(#3766): Restrict to be 'internal'. - /** The [Locale] used for user-facing string formatting in this display locale. */ - val formattingLocale: Locale by lazy { androidLocaleFactory.createAndroidLocale(localeContext) } private val dateFormat by lazy { DateFormat.getDateInstance(DATE_FORMAT_LENGTH, formattingLocale) } diff --git a/utility/src/test/java/org/oppia/android/util/data/DataProvidersTest.kt b/utility/src/test/java/org/oppia/android/util/data/DataProvidersTest.kt index d4697e891b7..596b43e2334 100644 --- a/utility/src/test/java/org/oppia/android/util/data/DataProvidersTest.kt +++ b/utility/src/test/java/org/oppia/android/util/data/DataProvidersTest.kt @@ -972,6 +972,40 @@ class DataProvidersTest { } } + @Test + fun testTransformAsync_toLiveData_throwsException_deliversFailure() { + val baseProvider = createSuccessfulDataProvider(BASE_PROVIDER_ID_0, STR_VALUE_0) + val dataProvider = baseProvider.transformAsync(TRANSFORMED_PROVIDER_ID) { + throw IllegalStateException("Transform failure") + } + + dataProvider.toLiveData().observeForever(mockIntLiveDataObserver) + testCoroutineDispatchers.advanceUntilIdle() + + // Note that the exception type here is not chained since the failure occurred in the transform + // function. + verify(mockIntLiveDataObserver).onChanged(intResultCaptor.capture()) + assertThat(intResultCaptor.value).isFailureThat().apply { + isInstanceOf(IllegalStateException::class.java) + hasMessageThat().contains("Transform failure") + } + } + + @Test + fun testTransformAsync_toLiveData_throwsException_deliversFailure_logsException() { + val baseProvider = createSuccessfulDataProvider(BASE_PROVIDER_ID_0, STR_VALUE_0) + val dataProvider = baseProvider.transformAsync(TRANSFORMED_PROVIDER_ID) { + throw IllegalStateException("Transform failure") + } + + dataProvider.toLiveData().observeForever(mockIntLiveDataObserver) + testCoroutineDispatchers.advanceUntilIdle() + val exception = fakeExceptionLogger.getMostRecentException() + + assertThat(exception).isInstanceOf(IllegalStateException::class.java) + assertThat(exception).hasMessageThat().contains("Transform failure") + } + @Test fun testTransformAsync_toLiveData_basePending_deliversPending() { val baseProvider = createPendingDataProvider(BASE_PROVIDER_ID_0) diff --git a/utility/src/test/java/org/oppia/android/util/locale/AndroidLocaleFactoryTest.kt b/utility/src/test/java/org/oppia/android/util/locale/AndroidLocaleFactoryTest.kt index c009baad4e0..581668c878c 100644 --- a/utility/src/test/java/org/oppia/android/util/locale/AndroidLocaleFactoryTest.kt +++ b/utility/src/test/java/org/oppia/android/util/locale/AndroidLocaleFactoryTest.kt @@ -9,6 +9,10 @@ import dagger.BindsInstance import dagger.Component import dagger.Module import dagger.Provides +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -22,9 +26,14 @@ import org.oppia.android.app.model.OppiaLocaleContext import org.oppia.android.app.model.OppiaRegion import org.oppia.android.app.model.RegionSupportDefinition import org.oppia.android.testing.assertThrows +import org.oppia.android.testing.robolectric.RobolectricModule +import org.oppia.android.testing.threading.TestCoroutineDispatchers +import org.oppia.android.testing.threading.TestDispatcherModule import org.oppia.android.testing.time.FakeOppiaClockModule +import org.oppia.android.util.threading.BackgroundDispatcher import org.robolectric.annotation.Config import org.robolectric.annotation.LooperMode +import java.util.Locale import javax.inject.Inject import javax.inject.Singleton @@ -41,18 +50,63 @@ import javax.inject.Singleton @LooperMode(LooperMode.Mode.PAUSED) @Config(manifest = Config.NONE) class AndroidLocaleFactoryTest { - @Inject - lateinit var androidLocaleFactory: AndroidLocaleFactory + @Inject lateinit var androidLocaleFactory: AndroidLocaleFactory + @field:[Inject BackgroundDispatcher] lateinit var backgroundDispatcher: CoroutineDispatcher + @Inject lateinit var testCoroutineDispatchers: TestCoroutineDispatchers @Before fun setUp() { setUpTestApplicationComponent() } + /* Basic tests for one-off Locale creation (latter functions indirectly test in more detail. */ + + @Test + fun testCreateOneOffAndroidLocale_default_throwsException() { + val exception = assertThrows() { + androidLocaleFactory.createOneOffAndroidLocale(OppiaLocaleContext.getDefaultInstance()) + } + + // The operation should fail since there's no language type defined. + assertThat(exception).hasMessageThat().contains("Invalid language case") + } + + @Test + fun testCreateOneOffAndroidLocale_appStrings_defaultLanguage_returnsRootLocale() { + val context = + createAppStringsContext( + language = OppiaLanguage.LANGUAGE_UNSPECIFIED, + appStringId = LanguageId.getDefaultInstance(), + regionDefinition = RegionSupportDefinition.getDefaultInstance() + ) + + val locale = androidLocaleFactory.createOneOffAndroidLocale(context) + + assertThat(locale).isEqualTo(Locale.ROOT) + } + + @Test + fun testCreateOneOffAndroidLocale_appStrings_withAndroidId_compatible_returnsAndroidIdLocale() { + val context = + createAppStringsContext( + language = OppiaLanguage.BRAZILIAN_PORTUGUESE, + appStringId = createLanguageId(androidLanguageId = PT_BR_ANDROID_LANGUAGE_ID), + regionDefinition = REGION_BRAZIL + ) + + val locale = androidLocaleFactory.createOneOffAndroidLocale(context) + + // The context should be matched to a valid locale. + assertThat(locale.language).isEqualTo("pt") + assertThat(locale.country).isEqualTo("BR") + } + + /* Begin createAndroidLocaleAsync tests. */ + @Test fun testCreateLocale_default_throwsException() { val exception = assertThrows() { - androidLocaleFactory.createAndroidLocale(OppiaLocaleContext.getDefaultInstance()) + androidLocaleFactory.createAndroidLocaleBlocking(OppiaLocaleContext.getDefaultInstance()) } // The operation should fail since there's no language type defined. @@ -70,7 +124,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_BRAZIL ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // The context should be matched to a valid locale. assertThat(locale.language).isEqualTo("pt") @@ -86,7 +140,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_BRAZIL ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // The context should be matched to a valid locale. assertThat(locale.language).isEqualTo("pt") @@ -102,7 +156,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_US ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // The context should be matched to a valid locale. Note that BR is matched since the IETF // language tag includes the region. @@ -119,7 +173,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_BRAZIL ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // The context should be matched to a valid locale. assertThat(locale.language).isEqualTo("pt") @@ -138,7 +192,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_BRAZIL ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // The Android is preferred when both are present. Note no region is provided since the Android // language is missing a region definition. @@ -158,7 +212,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_BRAZIL ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // The Android is preferred when both are present. Note no region is provided since the Android // language is missing a region definition. @@ -176,7 +230,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_BRAZIL ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // 'qq' is picked because it's an Android ID for app strings, so it's always taken as a forced // locale over any fallback options. @@ -194,7 +248,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_BRAZIL ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // pt-BR should be picked because the primary language doesn't match a real locale, and it's not // an Android ID that would take precedence. @@ -212,7 +266,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_BRAZIL ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // pt-BR should be picked because the primary language doesn't match a real locale. assertThat(locale.language).isEqualTo("pt") @@ -229,7 +283,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_BRAZIL ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // pt-BR should be picked because the primary language's region doesn't match a real locale. assertThat(locale.language).isEqualTo("pt") @@ -246,7 +300,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_ZZ ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // pt-BR should be picked because the supplied region doesn't match a real locale. assertThat(locale.language).isEqualTo("pt") @@ -263,7 +317,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_BRAZIL ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // pt-BR should be picked because the primary language doesn't match a real locale. assertThat(locale.language).isEqualTo("pt") @@ -280,7 +334,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_BRAZIL ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // pt-BR should be picked because the primary language is invalid. assertThat(locale.language).isEqualTo("pt") @@ -298,7 +352,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_BRAZIL ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // pt-BR should be picked because the primary language isn't compatible with the current SDK. assertThat(locale.language).isEqualTo("pt") @@ -318,7 +372,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_INDIA ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // 'qq' is picked because it's an Android ID for app strings, so it's always taken as a forced // locale over any fallback options. @@ -339,7 +393,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_INDIA ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // pt-BR should be picked because the primary language doesn't match a real locale, and it's not // an Android ID that would take precedence. Beyond that, the fallback's Android ID should take @@ -361,7 +415,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_INDIA ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // 'qq' is picked because it's an Android ID for app strings, so it's always taken as a forced // locale over any fallback options. @@ -382,7 +436,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_INDIA ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // pt-BR should be picked since Android IDs take precedence among multiple fallback options, and // none of the primary options are viable. @@ -400,7 +454,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_INDIA ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // 'qq' is picked over the primary language because it's an Android ID and the primary language // doesn't match any locales. @@ -418,7 +472,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_INDIA ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // 'qq' is the exact locale being requested. assertThat(locale.language).isEqualTo("qq") @@ -438,7 +492,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_INDIA ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // 'qq' takes precedence over the IETF language since Android IDs are picked first when // creating a forced locale. @@ -459,7 +513,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_INDIA ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // 'qq' takes precedence over the macaronic language since Android IDs are picked first when // creating a forced locale. @@ -477,7 +531,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_INDIA ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // The IETF language ID is used for the forced locale (note that fallback languages are ignored // when computing the forced locale). @@ -495,7 +549,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_INDIA ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // The Hinglish macaronic language ID is used for the forced locale (note that fallback // languages are ignored when computing the forced locale). @@ -514,7 +568,7 @@ class AndroidLocaleFactoryTest { ) val exception = assertThrows() { - androidLocaleFactory.createAndroidLocale(context) + androidLocaleFactory.createAndroidLocaleBlocking(context) } assertThat(exception).hasMessageThat().contains("Invalid ID") @@ -532,7 +586,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_INDIA ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // The 'qq' language should be matched as a forced profile since both language IDs are // SDK-incompatible (despite the fallback being a matchable language). @@ -549,7 +603,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_INDIA ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // Simple macro languages may not match any internal locales due to missing regions. They should // still become a valid locale (due to wildcard matching internally). @@ -558,7 +612,7 @@ class AndroidLocaleFactoryTest { } @Test - fun testCreateLocale_appStrings_allIncompat_invalidLangType_throwsException() { + fun testCreateLocale_appStrings_allIncompat_invalidLangType_returnsRootLocale() { val context = createAppStringsContext( language = OppiaLanguage.ENGLISH, @@ -566,11 +620,9 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_INDIA ) - val exception = assertThrows() { - androidLocaleFactory.createAndroidLocale(context) - } + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) - assertThat(exception).hasMessageThat().contains("Invalid language case") + assertThat(locale).isEqualTo(Locale.ROOT) } /* Tests for written content strings. */ @@ -584,7 +636,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_BRAZIL ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // The context should be matched to a valid locale. assertThat(locale.language).isEqualTo("pt") @@ -600,7 +652,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_BRAZIL ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // The context should be matched to a valid locale. assertThat(locale.language).isEqualTo("pt") @@ -616,7 +668,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_US ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // The context should be matched to a valid locale. Note that BR is matched since the IETF // language tag includes the region. @@ -633,7 +685,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_BRAZIL ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // The context should be matched to a valid locale. assertThat(locale.language).isEqualTo("pt") @@ -652,7 +704,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_BRAZIL ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // The Android is preferred when both are present. Note no region is provided since the Android // language is missing a region definition. @@ -672,7 +724,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_BRAZIL ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // The Android is preferred when both are present. Note no region is provided since the Android // language is missing a region definition. @@ -690,7 +742,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_BRAZIL ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // pt-BR should be picked because the primary language doesn't match a real locale. assertThat(locale.language).isEqualTo("pt") @@ -707,7 +759,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_BRAZIL ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // pt-BR should be picked because the primary language doesn't match a real locale. assertThat(locale.language).isEqualTo("pt") @@ -724,7 +776,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_BRAZIL ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // pt-BR should be picked because the primary language's region doesn't match a real locale. assertThat(locale.language).isEqualTo("pt") @@ -741,7 +793,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_ZZ ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // pt-BR should be picked because the supplied region doesn't match a real locale. assertThat(locale.language).isEqualTo("pt") @@ -758,7 +810,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_BRAZIL ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // pt-BR should be picked because the primary language doesn't match a real locale. assertThat(locale.language).isEqualTo("pt") @@ -775,7 +827,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_BRAZIL ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // pt-BR should be picked because the primary language is invalid. assertThat(locale.language).isEqualTo("pt") @@ -793,7 +845,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_BRAZIL ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // pt-BR should be picked because the primary language isn't compatible with the current SDK. assertThat(locale.language).isEqualTo("pt") @@ -813,7 +865,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_INDIA ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // pt-BR should be picked since Android IDs take precedence among multiple fallback options. assertThat(locale.language).isEqualTo("pt") @@ -833,7 +885,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_INDIA ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // pt-BR should be picked since Android IDs take precedence among multiple fallback options. assertThat(locale.language).isEqualTo("pt") @@ -850,7 +902,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_INDIA ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // 'qq' is the exact locale being requested. assertThat(locale.language).isEqualTo("qq") @@ -870,7 +922,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_INDIA ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // 'qq' takes precedence over the IETF language since Android IDs are picked first when // creating a forced locale. @@ -891,7 +943,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_INDIA ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // 'qq' takes precedence over the macaronic language since Android IDs are picked first when // creating a forced locale. @@ -909,7 +961,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_INDIA ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // The IETF language ID is used for the forced locale (note that fallback languages are ignored // when computing the forced locale). @@ -927,7 +979,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_INDIA ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // The Hinglish macaronic language ID is used for the forced locale (note that fallback // languages are ignored when computing the forced locale). @@ -946,7 +998,7 @@ class AndroidLocaleFactoryTest { ) val exception = assertThrows() { - androidLocaleFactory.createAndroidLocale(context) + androidLocaleFactory.createAndroidLocaleBlocking(context) } assertThat(exception).hasMessageThat().contains("Invalid ID") @@ -964,7 +1016,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_INDIA ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // The 'qq' language should be matched as a forced profile since both language IDs are // SDK-incompatible (despite the fallback being a matchable language). @@ -981,7 +1033,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_INDIA ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // Simple macro languages may not match any internal locales due to missing regions. They should // still become a valid locale (due to wildcard matching internally). @@ -999,7 +1051,7 @@ class AndroidLocaleFactoryTest { ) val exception = assertThrows() { - androidLocaleFactory.createAndroidLocale(context) + androidLocaleFactory.createAndroidLocaleBlocking(context) } assertThat(exception).hasMessageThat().contains("Invalid language case") @@ -1016,7 +1068,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_BRAZIL ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // The context should be matched to a valid locale. assertThat(locale.language).isEqualTo("pt") @@ -1032,7 +1084,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_BRAZIL ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // The context should be matched to a valid locale. assertThat(locale.language).isEqualTo("pt") @@ -1048,7 +1100,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_US ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // The context should be matched to a valid locale. Note that BR is matched since the IETF // language tag includes the region. @@ -1065,7 +1117,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_BRAZIL ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // The context should be matched to a valid locale. assertThat(locale.language).isEqualTo("pt") @@ -1084,7 +1136,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_BRAZIL ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // The Android is preferred when both are present. Note no region is provided since the Android // language is missing a region definition. @@ -1104,7 +1156,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_BRAZIL ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // The Android is preferred when both are present. Note no region is provided since the Android // language is missing a region definition. @@ -1122,7 +1174,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_BRAZIL ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // pt-BR should be picked because the primary language doesn't match a real locale. assertThat(locale.language).isEqualTo("pt") @@ -1139,7 +1191,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_BRAZIL ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // pt-BR should be picked because the primary language doesn't match a real locale. assertThat(locale.language).isEqualTo("pt") @@ -1156,7 +1208,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_BRAZIL ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // pt-BR should be picked because the primary language's region doesn't match a real locale. assertThat(locale.language).isEqualTo("pt") @@ -1173,7 +1225,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_ZZ ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // pt-BR should be picked because the supplied region doesn't match a real locale. assertThat(locale.language).isEqualTo("pt") @@ -1190,7 +1242,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_BRAZIL ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // pt-BR should be picked because the primary language doesn't match a real locale. assertThat(locale.language).isEqualTo("pt") @@ -1207,7 +1259,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_BRAZIL ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // pt-BR should be picked because the primary language is invalid. assertThat(locale.language).isEqualTo("pt") @@ -1225,7 +1277,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_BRAZIL ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // pt-BR should be picked because the primary language isn't compatible with the current SDK. assertThat(locale.language).isEqualTo("pt") @@ -1245,7 +1297,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_INDIA ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // pt-BR should be picked since Android IDs take precedence among multiple fallback options. assertThat(locale.language).isEqualTo("pt") @@ -1265,7 +1317,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_INDIA ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // pt-BR should be picked since Android IDs take precedence among multiple fallback options. assertThat(locale.language).isEqualTo("pt") @@ -1282,7 +1334,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_INDIA ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // 'qq' is the exact locale being requested. assertThat(locale.language).isEqualTo("qq") @@ -1302,7 +1354,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_INDIA ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // 'qq' takes precedence over the IETF language since Android IDs are picked first when // creating a forced locale. @@ -1323,7 +1375,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_INDIA ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // 'qq' takes precedence over the macaronic language since Android IDs are picked first when // creating a forced locale. @@ -1341,7 +1393,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_INDIA ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // The IETF language ID is used for the forced locale (note that fallback languages are ignored // when computing the forced locale). @@ -1359,7 +1411,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_INDIA ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // The Hinglish macaronic language ID is used for the forced locale (note that fallback // languages are ignored when computing the forced locale). @@ -1378,7 +1430,7 @@ class AndroidLocaleFactoryTest { ) val exception = assertThrows() { - androidLocaleFactory.createAndroidLocale(context) + androidLocaleFactory.createAndroidLocaleBlocking(context) } assertThat(exception).hasMessageThat().contains("Invalid ID") @@ -1396,7 +1448,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_INDIA ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // The 'qq' language should be matched as a forced profile since both language IDs are // SDK-incompatible (despite the fallback being a matchable language). @@ -1413,7 +1465,7 @@ class AndroidLocaleFactoryTest { regionDefinition = REGION_INDIA ) - val locale = androidLocaleFactory.createAndroidLocale(context) + val locale = androidLocaleFactory.createAndroidLocaleBlocking(context) // Simple macro languages may not match any internal locales due to missing regions. They should // still become a valid locale (due to wildcard matching internally). @@ -1431,12 +1483,22 @@ class AndroidLocaleFactoryTest { ) val exception = assertThrows() { - androidLocaleFactory.createAndroidLocale(context) + androidLocaleFactory.createAndroidLocaleBlocking(context) } assertThat(exception).hasMessageThat().contains("Invalid language case") } + private fun AndroidLocaleFactory.createAndroidLocaleBlocking( + context: OppiaLocaleContext + ): Locale { + val deferred = + CoroutineScope(backgroundDispatcher).async { createAndroidLocaleAsync(context).await() } + testCoroutineDispatchers.runCurrent() + assertThat(deferred.isCompleted).isTrue() + return runBlocking { deferred.await() } + } + private fun createLanguageId(androidLanguageId: AndroidLanguageId): LanguageId { return LanguageId.newBuilder().apply { androidResourcesLanguageId = androidLanguageId @@ -1602,7 +1664,8 @@ class AndroidLocaleFactoryTest { @Singleton @Component( modules = [ - TestModule::class, LocaleProdModule::class, FakeOppiaClockModule::class + TestModule::class, LocaleProdModule::class, FakeOppiaClockModule::class, + TestDispatcherModule::class, RobolectricModule::class ] ) interface TestApplicationComponent { diff --git a/utility/src/test/java/org/oppia/android/util/locale/AndroidLocaleProfileTest.kt b/utility/src/test/java/org/oppia/android/util/locale/AndroidLocaleProfileTest.kt index 42384c8531a..d686a5aaea9 100644 --- a/utility/src/test/java/org/oppia/android/util/locale/AndroidLocaleProfileTest.kt +++ b/utility/src/test/java/org/oppia/android/util/locale/AndroidLocaleProfileTest.kt @@ -12,6 +12,7 @@ import dagger.Provides import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.oppia.android.app.model.LanguageSupportDefinition.AndroidLanguageId import org.oppia.android.app.model.LanguageSupportDefinition.IetfBcp47LanguageId import org.oppia.android.app.model.LanguageSupportDefinition.LanguageId import org.oppia.android.app.model.LanguageSupportDefinition.MacaronicLanguageId @@ -19,6 +20,11 @@ import org.oppia.android.app.model.OppiaRegion import org.oppia.android.app.model.RegionSupportDefinition import org.oppia.android.app.model.RegionSupportDefinition.IetfBcp47RegionId import org.oppia.android.testing.time.FakeOppiaClockModule +import org.oppia.android.util.locale.AndroidLocaleProfile.LanguageAndRegionProfile +import org.oppia.android.util.locale.AndroidLocaleProfile.LanguageAndWildcardRegionProfile +import org.oppia.android.util.locale.AndroidLocaleProfile.LanguageOnlyProfile +import org.oppia.android.util.locale.AndroidLocaleProfile.RegionOnlyProfile +import org.oppia.android.util.locale.AndroidLocaleProfile.RootProfile import org.robolectric.annotation.Config import org.robolectric.annotation.LooperMode import java.util.Locale @@ -32,11 +38,10 @@ import javax.inject.Singleton @LooperMode(LooperMode.Mode.PAUSED) @Config(manifest = Config.NONE) class AndroidLocaleProfileTest { - @Inject - lateinit var machineLocale: OppiaLocale.MachineLocale + @Inject lateinit var androidLocaleProfileFactory: AndroidLocaleProfile.Factory - private val portugueseLocale by lazy { Locale("pt") } private val brazilianPortugueseLocale by lazy { Locale("pt", "BR") } + private val kenyaOnlyLocale by lazy { Locale(/* language = */ "", "KE") } @Before fun setUp() { @@ -46,27 +51,38 @@ class AndroidLocaleProfileTest { /* Tests for createFrom */ @Test - fun testCreateProfile_fromRootLocale_returnsProfileWithoutLanguageAndRegionCode() { - val profile = AndroidLocaleProfile.createFrom(Locale.ROOT) + fun testCreateProfile_fromRootLocale_returnsRootProfile() { + val profile = androidLocaleProfileFactory.createFrom(Locale.ROOT) - assertThat(profile.languageCode).isEmpty() - assertThat(profile.regionCode).isEmpty() + assertThat(profile).isEqualTo(RootProfile) } @Test - fun testCreateProfile_fromEnglishLocale_returnsProfileWithLanguageAndWithoutRegion() { - val profile = AndroidLocaleProfile.createFrom(Locale.ENGLISH) + fun testCreateProfile_fromEnglishLocale_returnsLanguageOnlyProfile() { + val profile = androidLocaleProfileFactory.createFrom(Locale.ENGLISH) - assertThat(profile.languageCode).isEqualTo("en") - assertThat(profile.regionCode).isEmpty() + val languageOnlyProfile = profile as? LanguageOnlyProfile + assertThat(profile).isInstanceOf(LanguageOnlyProfile::class.java) + assertThat(languageOnlyProfile?.languageCode).isEqualTo("en") } @Test fun testCreateProfile_fromBrazilianPortuguese_returnsProfileWithLanguageAndRegion() { - val profile = AndroidLocaleProfile.createFrom(brazilianPortugueseLocale) + val profile = androidLocaleProfileFactory.createFrom(brazilianPortugueseLocale) - assertThat(profile.languageCode).isEqualTo("pt") - assertThat(profile.regionCode).isEqualTo("BR") + val languageAndRegionProfile = profile as? LanguageAndRegionProfile + assertThat(profile).isInstanceOf(LanguageAndRegionProfile::class.java) + assertThat(languageAndRegionProfile?.languageCode).isEqualTo("pt") + assertThat(languageAndRegionProfile?.regionCode).isEqualTo("br") + } + + @Test + fun testCreateProfile_fromKenyaLocale_returnsRegionOnlyProfile() { + val profile = androidLocaleProfileFactory.createFrom(kenyaOnlyLocale) + + val regionOnlyProfile = profile as? RegionOnlyProfile + assertThat(profile).isInstanceOf(RegionOnlyProfile::class.java) + assertThat(regionOnlyProfile?.regionCode).isEqualTo("ke") } /* Tests for createFromIetfDefinitions */ @@ -74,7 +90,7 @@ class AndroidLocaleProfileTest { @Test fun testCreateProfileFromIetf_defaultLanguageId_nullRegion_returnsNull() { val profile = - AndroidLocaleProfile.createFromIetfDefinitions( + androidLocaleProfileFactory.createFromIetfDefinitions( languageId = LanguageId.getDefaultInstance(), regionDefinition = null ) @@ -89,7 +105,8 @@ class AndroidLocaleProfileTest { }.build() }.build() - val profile = AndroidLocaleProfile.createFromIetfDefinitions(languageWithoutIetf, REGION_INDIA) + val profile = + androidLocaleProfileFactory.createFromIetfDefinitions(languageWithoutIetf, REGION_INDIA) // The language ID needs to have an IETF BCP 47 ID defined. assertThat(profile).isNull() @@ -103,7 +120,8 @@ class AndroidLocaleProfileTest { }.build() }.build() - val profile = AndroidLocaleProfile.createFromIetfDefinitions(languageWithIetf, REGION_INDIA) + val profile = + androidLocaleProfileFactory.createFromIetfDefinitions(languageWithIetf, REGION_INDIA) // The language ID needs to have an IETF BCP 47 ID defined. assertThat(profile).isNull() @@ -117,7 +135,8 @@ class AndroidLocaleProfileTest { }.build() }.build() - val profile = AndroidLocaleProfile.createFromIetfDefinitions(languageWithIetf, REGION_INDIA) + val profile = + androidLocaleProfileFactory.createFromIetfDefinitions(languageWithIetf, REGION_INDIA) // The language ID needs to have a well-formed IETF BCP 47 ID defined. assertThat(profile).isNull() @@ -131,12 +150,15 @@ class AndroidLocaleProfileTest { }.build() }.build() - val profile = AndroidLocaleProfile.createFromIetfDefinitions(languageWithIetf, REGION_INDIA) + val profile = + androidLocaleProfileFactory.createFromIetfDefinitions(languageWithIetf, REGION_INDIA) // The constituent language code should come from the language ID, and the region code from the // provided region definition. - assertThat(profile?.languageCode).isEqualTo("pt") - assertThat(profile?.regionCode).isEqualTo("IN") + val languageAndRegionProfile = profile as? LanguageAndRegionProfile + assertThat(profile).isInstanceOf(LanguageAndRegionProfile::class.java) + assertThat(languageAndRegionProfile?.languageCode).isEqualTo("pt") + assertThat(languageAndRegionProfile?.regionCode).isEqualTo("in") } @Test @@ -147,11 +169,14 @@ class AndroidLocaleProfileTest { }.build() }.build() - val profile = AndroidLocaleProfile.createFromIetfDefinitions(languageWithIetf, REGION_INDIA) + val profile = + androidLocaleProfileFactory.createFromIetfDefinitions(languageWithIetf, REGION_INDIA) // In this case, the region comes from the IETF language tag since it's included. - assertThat(profile?.languageCode).isEqualTo("pt") - assertThat(profile?.regionCode).isEqualTo("BR") + val languageAndRegionProfile = profile as? LanguageAndRegionProfile + assertThat(profile).isInstanceOf(LanguageAndRegionProfile::class.java) + assertThat(languageAndRegionProfile?.languageCode).isEqualTo("pt") + assertThat(languageAndRegionProfile?.regionCode).isEqualTo("br") } @Test @@ -163,7 +188,7 @@ class AndroidLocaleProfileTest { }.build() val profile = - AndroidLocaleProfile.createFromIetfDefinitions( + androidLocaleProfileFactory.createFromIetfDefinitions( languageWithIetf, regionDefinition = RegionSupportDefinition.getDefaultInstance() ) @@ -180,7 +205,7 @@ class AndroidLocaleProfileTest { }.build() val profile = - AndroidLocaleProfile.createFromIetfDefinitions( + androidLocaleProfileFactory.createFromIetfDefinitions( languageWithIetf, regionDefinition = RegionSupportDefinition.getDefaultInstance() ) @@ -197,11 +222,14 @@ class AndroidLocaleProfileTest { }.build() val profile = - AndroidLocaleProfile.createFromIetfDefinitions(languageWithIetf, regionDefinition = null) + androidLocaleProfileFactory.createFromIetfDefinitions( + languageWithIetf, regionDefinition = null + ) // A null region specifically means to use a wildcard match for regions. - assertThat(profile?.languageCode).isEqualTo("pt") - assertThat(profile?.regionCode).isEqualTo(AndroidLocaleProfile.REGION_WILDCARD) + val languageAndWildcardRegionProfile = profile as? LanguageAndWildcardRegionProfile + assertThat(profile).isInstanceOf(LanguageAndWildcardRegionProfile::class.java) + assertThat(languageAndWildcardRegionProfile?.languageCode).isEqualTo("pt") } /* Tests for createFromMacaronicLanguage */ @@ -209,7 +237,9 @@ class AndroidLocaleProfileTest { @Test fun testCreateProfileFromMacaronic_defaultLanguageId_returnsNull() { val profile = - AndroidLocaleProfile.createFromMacaronicLanguage(languageId = LanguageId.getDefaultInstance()) + androidLocaleProfileFactory.createFromMacaronicLanguage( + languageId = LanguageId.getDefaultInstance() + ) assertThat(profile).isNull() } @@ -222,8 +252,7 @@ class AndroidLocaleProfileTest { }.build() }.build() - val profile = - AndroidLocaleProfile.createFromMacaronicLanguage(languageWithoutMacaronic) + val profile = androidLocaleProfileFactory.createFromMacaronicLanguage(languageWithoutMacaronic) // The provided language ID must have a macaronic ID defined. assertThat(profile).isNull() @@ -237,8 +266,7 @@ class AndroidLocaleProfileTest { }.build() }.build() - val profile = - AndroidLocaleProfile.createFromMacaronicLanguage(languageWithMacaronic) + val profile = androidLocaleProfileFactory.createFromMacaronicLanguage(languageWithMacaronic) // The provided language ID must have a macaronic ID defined. assertThat(profile).isNull() @@ -252,8 +280,7 @@ class AndroidLocaleProfileTest { }.build() }.build() - val profile = - AndroidLocaleProfile.createFromMacaronicLanguage(languageWithMacaronic) + val profile = androidLocaleProfileFactory.createFromMacaronicLanguage(languageWithMacaronic) // The provided language ID must have a well-formed macaronic ID defined. assertThat(profile).isNull() @@ -267,8 +294,7 @@ class AndroidLocaleProfileTest { }.build() }.build() - val profile = - AndroidLocaleProfile.createFromMacaronicLanguage(languageWithMacaronic) + val profile = androidLocaleProfileFactory.createFromMacaronicLanguage(languageWithMacaronic) // The provided language ID must have a well-formed macaronic ID defined, that is, it must have // two language parts defined. @@ -283,8 +309,7 @@ class AndroidLocaleProfileTest { }.build() }.build() - val profile = - AndroidLocaleProfile.createFromMacaronicLanguage(languageWithMacaronic) + val profile = androidLocaleProfileFactory.createFromMacaronicLanguage(languageWithMacaronic) // The macaronic ID has two parts as expected, but the second language ID must be filled in. assertThat(profile).isNull() @@ -298,262 +323,593 @@ class AndroidLocaleProfileTest { }.build() }.build() - val profile = - AndroidLocaleProfile.createFromMacaronicLanguage(languageWithMacaronic) + val profile = androidLocaleProfileFactory.createFromMacaronicLanguage(languageWithMacaronic) // The macaronic ID was valid. Verify that both language IDs correctly populate the profile. - assertThat(profile?.languageCode).isEqualTo("hi") - assertThat(profile?.regionCode).isEqualTo("en") + val languageAndRegionProfile = profile as? LanguageAndRegionProfile + assertThat(profile).isInstanceOf(LanguageAndRegionProfile::class.java) + assertThat(languageAndRegionProfile?.languageCode).isEqualTo("hi") + assertThat(languageAndRegionProfile?.regionCode).isEqualTo("en") + } + + /* Tests for createFromAndroidResourcesLanguageId(). */ + + @Test + fun testCreateFromAndroidResourcesLanguageId_defaultLanguageId_returnsNull() { + val profile = + androidLocaleProfileFactory.createFromAndroidResourcesLanguageId( + languageId = LanguageId.getDefaultInstance() + ) + + assertThat(profile).isNull() + } + + @Test + fun testCreateFromAndroidResourcesLanguageId_ietfBcp47LanguageId_returnsNull() { + val profile = + androidLocaleProfileFactory.createFromAndroidResourcesLanguageId( + languageId = LanguageId.newBuilder().apply { + ietfBcp47Id = IetfBcp47LanguageId.newBuilder().apply { + ietfLanguageTag = "pt-BR" + }.build() + }.build() + ) + + // This method only creates a profile when provided with a valid Android resources language ID. + assertThat(profile).isNull() + } + + @Test + fun testCreateFromAndroidResourcesLanguageId_macaronicLanguageId_returnsNull() { + val profile = + androidLocaleProfileFactory.createFromAndroidResourcesLanguageId( + languageId = LanguageId.newBuilder().apply { + macaronicId = MacaronicLanguageId.newBuilder().apply { + combinedLanguageCode = "hi-en" + }.build() + }.build() + ) + + // This method only creates a profile when provided with a valid Android resources language ID. + assertThat(profile).isNull() + } + + @Test + fun testCreateFromAndroidResourcesLanguageId_defaultAndroidLanguageId_returnsNull() { + val profile = + androidLocaleProfileFactory.createFromAndroidResourcesLanguageId( + languageId = LanguageId.newBuilder().apply { + androidResourcesLanguageId = AndroidLanguageId.getDefaultInstance() + }.build() + ) + + // This method only creates a profile when provided with a valid Android resources language ID. + assertThat(profile).isNull() + } + + @Test + fun testCreateFromAndroidResourcesLanguageId_androidLanguageId_regionOnly_returnsNull() { + val profile = + androidLocaleProfileFactory.createFromAndroidResourcesLanguageId( + languageId = LanguageId.newBuilder().apply { + androidResourcesLanguageId = AndroidLanguageId.newBuilder().apply { + regionCode = "BR" + }.build() + }.build() + ) + + // A valid Android language ID must include at least a language code. + assertThat(profile).isNull() + } + + @Test + fun testCreateFromAndroidResourcesLanguageId_androidLanguageId_langOnly_returnsLangWildcard() { + val profile = + androidLocaleProfileFactory.createFromAndroidResourcesLanguageId( + languageId = LanguageId.newBuilder().apply { + androidResourcesLanguageId = AndroidLanguageId.newBuilder().apply { + languageCode = "pt" + }.build() + }.build() + ) + + // If no region is provided, match against all regions. + val languageAndWildcardRegionProfile = profile as? LanguageAndWildcardRegionProfile + assertThat(profile).isInstanceOf(LanguageAndWildcardRegionProfile::class.java) + assertThat(languageAndWildcardRegionProfile?.languageCode).isEqualTo("pt") + } + + @Test + fun testCreateFromAndroidResourcesLanguageId_androidLanguageId_returnsLangAndRegionProfile() { + val profile = + androidLocaleProfileFactory.createFromAndroidResourcesLanguageId( + languageId = LanguageId.newBuilder().apply { + androidResourcesLanguageId = AndroidLanguageId.newBuilder().apply { + languageCode = "pt" + regionCode = "BR" + }.build() + }.build() + ) + + // Both the language & region codes should be represented in the profile. + val languageAndRegionProfile = profile as? LanguageAndRegionProfile + assertThat(profile).isInstanceOf(LanguageAndRegionProfile::class.java) + assertThat(languageAndRegionProfile?.languageCode).isEqualTo("pt") + assertThat(languageAndRegionProfile?.regionCode).isEqualTo("br") } /* Tests for matches() */ @Test - fun testMatchProfile_rootProfile_withItself_match() { - val profile = AndroidLocaleProfile.createFrom(Locale.ROOT) + fun testMatchProfile_rootProfile_andRootProfile_matches() { + val profile1 = RootProfile + val profile2 = RootProfile - val matches = profile.matches(machineLocale, profile) + val matches = profile1.matches(profile2) assertThat(matches).isTrue() } @Test - fun testMatchProfile_englishProfile_withItself_match() { - val profile = AndroidLocaleProfile.createFrom(Locale.ENGLISH) + fun testMatchProfile_rootProfile_andPtLanguageOnly_doNotMatch() { + val profile1 = RootProfile + val profile2 = LanguageOnlyProfile(languageCode = "pt") - val matches = profile.matches(machineLocale, profile) + val matches = profile1.matches(profile2) - assertThat(matches).isTrue() + assertThat(matches).isFalse() } @Test - fun testMatchProfile_brazilianPortuguese_withItself_match() { - val profile = AndroidLocaleProfile.createFrom(brazilianPortugueseLocale) + fun testMatchProfile_rootProfile_andBrRegionOnly_doNotMatch() { + val profile1 = RootProfile + val profile2 = RegionOnlyProfile(regionCode = "br") - val matches = profile.matches(machineLocale, profile) + val matches = profile1.matches(profile2) - assertThat(matches).isTrue() + assertThat(matches).isFalse() } @Test - fun testMatchProfile_englishProfile_withItselfInDifferentCase_match() { - val englishProfileLowercase = AndroidLocaleProfile(languageCode = "en", regionCode = "") - val englishProfileUppercase = AndroidLocaleProfile(languageCode = "EN", regionCode = "") + fun testMatchProfile_rootProfile_andPtBrProfile_doNotMatch() { + val profile1 = RootProfile + val profile2 = + LanguageAndRegionProfile(languageCode = "pt", regionCode = "br", regionCodeUpperCase = "BR") - val matches = englishProfileLowercase.matches(machineLocale, englishProfileUppercase) + val matches = profile1.matches(profile2) - assertThat(matches).isTrue() + assertThat(matches).isFalse() } @Test - fun testMatchProfile_englishProfile_withItselfInDifferentCase_reversed_match() { - val englishProfileLowercase = AndroidLocaleProfile(languageCode = "en", regionCode = "") - val englishProfileUppercase = AndroidLocaleProfile(languageCode = "EN", regionCode = "") + fun testMatchProfile_rootProfile_andPtWildcardProfile_doNotMatch() { + val profile1 = RootProfile + val profile2 = LanguageAndWildcardRegionProfile(languageCode = "pt") - val matches = englishProfileUppercase.matches(machineLocale, englishProfileLowercase) + val matches = profile1.matches(profile2) - assertThat(matches).isTrue() + assertThat(matches).isFalse() } @Test - fun testMatchProfile_brazilianPortuguese_withItselfInDifferentCase_match() { - val brazilianPortugueseProfileLowercase = - AndroidLocaleProfile(languageCode = "pt", regionCode = "br") - val brazilianPortugueseProfileUppercase = - AndroidLocaleProfile(languageCode = "PT", regionCode = "BR") + fun testMatchProfile_ptLanguageOnly_andRootProfile_doNotMatch() { + val profile1 = LanguageOnlyProfile(languageCode = "pt") + val profile2 = RootProfile - val matches = - brazilianPortugueseProfileLowercase.matches( - machineLocale, brazilianPortugueseProfileUppercase - ) + val matches = profile1.matches(profile2) + + assertThat(matches).isFalse() + } + + @Test + fun testMatchProfile_ptLanguageOnly_andPtLanguageOnly_matches() { + val profile1 = LanguageOnlyProfile(languageCode = "pt") + val profile2 = LanguageOnlyProfile(languageCode = "pt") + + val matches = profile1.matches(profile2) assertThat(matches).isTrue() } - fun testMatchProfile_brazilianPortuguese_withItselfInDifferentCase_reversed_match() { - val brazilianPortugueseProfileLowercase = - AndroidLocaleProfile(languageCode = "pt", regionCode = "br") - val brazilianPortugueseProfileUppercase = - AndroidLocaleProfile(languageCode = "PT", regionCode = "BR") + @Test + fun testMatchProfile_ptLanguageOnly_andSwLanguageOnly_doNotMatch() { + val profile1 = LanguageOnlyProfile(languageCode = "pt") + val profile2 = LanguageOnlyProfile(languageCode = "sw") - val matches = - brazilianPortugueseProfileUppercase.matches( - machineLocale, brazilianPortugueseProfileLowercase - ) + val matches = profile1.matches(profile2) + + assertThat(matches).isFalse() + } + + @Test + fun testMatchProfile_ptLanguageOnly_andBrRegionOnly_doNotMatch() { + val profile1 = LanguageOnlyProfile(languageCode = "pt") + val profile2 = RegionOnlyProfile(regionCode = "br") + + val matches = profile1.matches(profile2) + + assertThat(matches).isFalse() + } + + @Test + fun testMatchProfile_ptLanguageOnly_andPtBrProfile_doNotMatch() { + val profile1 = LanguageOnlyProfile(languageCode = "pt") + val profile2 = + LanguageAndRegionProfile(languageCode = "pt", regionCode = "br", regionCodeUpperCase = "BR") + + val matches = profile1.matches(profile2) + + assertThat(matches).isFalse() + } + + @Test + fun testMatchProfile_ptLanguageOnly_andSwWildcardProfile_doNotMatch() { + val profile1 = LanguageOnlyProfile(languageCode = "pt") + val profile2 = LanguageAndWildcardRegionProfile(languageCode = "sw") + + val matches = profile1.matches(profile2) + + assertThat(matches).isFalse() + } + + @Test + fun testMatchProfile_brRegionOnly_andRootProfile_doNotMatch() { + val profile1 = RegionOnlyProfile(regionCode = "br") + val profile2 = RootProfile + + val matches = profile1.matches(profile2) + + assertThat(matches).isFalse() + } + + @Test + fun testMatchProfile_brRegionOnly_andPtLanguageOnly_doNotMatch() { + val profile1 = RegionOnlyProfile(regionCode = "br") + val profile2 = LanguageOnlyProfile(languageCode = "pt") + + val matches = profile1.matches(profile2) + + assertThat(matches).isFalse() + } + + @Test + fun testMatchProfile_brRegionOnly_andBrRegionOnly_matches() { + val profile1 = RegionOnlyProfile(regionCode = "br") + val profile2 = RegionOnlyProfile(regionCode = "br") + + val matches = profile1.matches(profile2) assertThat(matches).isTrue() } @Test - fun testMatchProfile_rootProfile_english_doNotMatch() { - val rootProfile = AndroidLocaleProfile.createFrom(Locale.ROOT) - val englishProfile = AndroidLocaleProfile.createFrom(Locale.ENGLISH) + fun testMatchProfile_brRegionOnly_andKeRegionOnly_doNotMatch() { + val profile1 = RegionOnlyProfile(regionCode = "br") + val profile2 = RegionOnlyProfile(regionCode = "ke") - val matches = rootProfile.matches(machineLocale, englishProfile) + val matches = profile1.matches(profile2) assertThat(matches).isFalse() } @Test - fun testMatchProfile_rootProfile_brazilianPortuguese_doNotMatch() { - val rootProfile = AndroidLocaleProfile.createFrom(Locale.ROOT) - val brazilianPortugueseProfile = AndroidLocaleProfile.createFrom(brazilianPortugueseLocale) + fun testMatchProfile_brRegionOnly_andPtBrProfile_doNotMatch() { + val profile1 = RegionOnlyProfile(regionCode = "br") + val profile2 = + LanguageAndRegionProfile(languageCode = "pt", regionCode = "br", regionCodeUpperCase = "BR") - val matches = rootProfile.matches(machineLocale, brazilianPortugueseProfile) + val matches = profile1.matches(profile2) assertThat(matches).isFalse() } @Test - fun testMatchProfile_english_brazilianPortuguese_doNotMatch() { - val englishProfile = AndroidLocaleProfile.createFrom(Locale.ENGLISH) - val brazilianPortugueseProfile = AndroidLocaleProfile.createFrom(brazilianPortugueseLocale) + fun testMatchProfile_brRegionOnly_andPtWildcardProfile_doNotMatch() { + val profile1 = RegionOnlyProfile(regionCode = "br") + val profile2 = LanguageAndWildcardRegionProfile(languageCode = "pt") - val matches = englishProfile.matches(machineLocale, brazilianPortugueseProfile) + val matches = profile1.matches(profile2) assertThat(matches).isFalse() } @Test - fun testMatchProfile_rootProfile_englishWithWildcard_doNotMatch() { - val rootProfile = AndroidLocaleProfile.createFrom(Locale.ROOT) - val englishWithWildcardProfile = createProfileWithWildcard(languageCode = "en") + fun testMatchProfile_ptBrProfile_andRootProfile_doNotMatch() { + val profile1 = + LanguageAndRegionProfile(languageCode = "pt", regionCode = "br", regionCodeUpperCase = "BR") + val profile2 = RootProfile - val matches = rootProfile.matches(machineLocale, englishWithWildcardProfile) + val matches = profile1.matches(profile2) assertThat(matches).isFalse() } @Test - fun testMatchProfile_rootProfile_rootProfileWithWildcard_match() { - val rootProfile = AndroidLocaleProfile.createFrom(Locale.ROOT) - val rootProfileWithWildcardProfile = createProfileWithWildcard(languageCode = "") + fun testMatchProfile_ptBrProfile_andPtLanguageOnly_doNotMatch() { + val profile1 = + LanguageAndRegionProfile(languageCode = "pt", regionCode = "br", regionCodeUpperCase = "BR") + val profile2 = LanguageOnlyProfile(languageCode = "pt") - val matches = rootProfile.matches(machineLocale, rootProfileWithWildcardProfile) + val matches = profile1.matches(profile2) + + assertThat(matches).isFalse() + } + + @Test + fun testMatchProfile_ptBrProfile_andBrRegionOnly_doNotMatch() { + val profile1 = + LanguageAndRegionProfile(languageCode = "pt", regionCode = "br", regionCodeUpperCase = "BR") + val profile2 = RegionOnlyProfile(regionCode = "br") + + val matches = profile1.matches(profile2) + + assertThat(matches).isFalse() + } + + @Test + fun testMatchProfile_ptBrProfile_andPtBrProfile_matches() { + val profile1 = + LanguageAndRegionProfile(languageCode = "pt", regionCode = "br", regionCodeUpperCase = "BR") + val profile2 = + LanguageAndRegionProfile(languageCode = "pt", regionCode = "br", regionCodeUpperCase = "BR") + + val matches = profile1.matches(profile2) assertThat(matches).isTrue() } @Test - fun testMatchProfile_rootProfileWithWildcard_rootProfile_match() { - val rootProfile = AndroidLocaleProfile.createFrom(Locale.ROOT) - val rootProfileWithWildcardProfile = createProfileWithWildcard(languageCode = "") + fun testMatchProfile_ptBrProfile_andSwBrProfile_doNotMatch() { + val profile1 = + LanguageAndRegionProfile(languageCode = "pt", regionCode = "br", regionCodeUpperCase = "BR") + val profile2 = + LanguageAndRegionProfile(languageCode = "sw", regionCode = "br", regionCodeUpperCase = "BR") - val matches = rootProfileWithWildcardProfile.matches(machineLocale, rootProfile) + val matches = profile1.matches(profile2) + + assertThat(matches).isFalse() + } + + @Test + fun testMatchProfile_ptBrProfile_andPtKeProfile_doNotMatch() { + val profile1 = + LanguageAndRegionProfile(languageCode = "pt", regionCode = "br", regionCodeUpperCase = "BR") + val profile2 = + LanguageAndRegionProfile(languageCode = "pt", regionCode = "ke", regionCodeUpperCase = "KE") + + val matches = profile1.matches(profile2) + + assertThat(matches).isFalse() + } + + @Test + fun testMatchProfile_ptBrProfile_andSwKeProfile_doNotMatch() { + val profile1 = + LanguageAndRegionProfile(languageCode = "pt", regionCode = "br", regionCodeUpperCase = "BR") + val profile2 = + LanguageAndRegionProfile(languageCode = "sw", regionCode = "ke", regionCodeUpperCase = "KE") + + val matches = profile1.matches(profile2) + + assertThat(matches).isFalse() + } + + @Test + fun testMatchProfile_ptBrProfile_andSwWildcardProfile_doNotMatch() { + val profile1 = + LanguageAndRegionProfile(languageCode = "pt", regionCode = "br", regionCodeUpperCase = "BR") + val profile2 = LanguageAndWildcardRegionProfile(languageCode = "sw") + + val matches = profile1.matches(profile2) + + assertThat(matches).isFalse() + } + + @Test + fun testMatchProfile_ptWildcardProfile_andRootProfile_doNotMatch() { + val profile1 = LanguageAndWildcardRegionProfile(languageCode = "pt") + val profile2 = RootProfile + + val matches = profile1.matches(profile2) + + assertThat(matches).isFalse() + } + + @Test + fun testMatchProfile_ptWildcardProfile_andPtLanguageOnly_matches() { + val profile1 = LanguageAndWildcardRegionProfile(languageCode = "pt") + val profile2 = LanguageOnlyProfile(languageCode = "pt") + + val matches = profile1.matches(profile2) assertThat(matches).isTrue() } @Test - fun testMatchProfile_englishProfile_rootProfileWithWildcard_doNotMatch() { - val englishProfile = AndroidLocaleProfile.createFrom(Locale.ENGLISH) - val rootProfileWithWildcardProfile = createProfileWithWildcard(languageCode = "") + fun testMatchProfile_ptWildcardProfile_andSwLanguageOnly_doNotMatch() { + val profile1 = LanguageAndWildcardRegionProfile(languageCode = "pt") + val profile2 = LanguageOnlyProfile(languageCode = "sw") - val matches = englishProfile.matches(machineLocale, rootProfileWithWildcardProfile) + val matches = profile1.matches(profile2) assertThat(matches).isFalse() } @Test - fun testMatchProfile_englishWithWildcard_brazilianPortuguese_doNotMatch() { - val englishWithWildcardProfile = createProfileWithWildcard(languageCode = "en") - val brazilianPortugueseProfile = AndroidLocaleProfile.createFrom(brazilianPortugueseLocale) + fun testMatchProfile_ptWildcardProfile_andBrRegionOnly_doNotMatch() { + val profile1 = LanguageAndWildcardRegionProfile(languageCode = "pt") + val profile2 = RegionOnlyProfile(regionCode = "br") - val matches = englishWithWildcardProfile.matches(machineLocale, brazilianPortugueseProfile) + val matches = profile1.matches(profile2) assertThat(matches).isFalse() } @Test - fun testMatchProfile_brazilianPortuguese_portuguese_doNotMatch() { - val portugueseProfile = AndroidLocaleProfile.createFrom(portugueseLocale) - val brazilianPortugueseProfile = AndroidLocaleProfile.createFrom(brazilianPortugueseLocale) + fun testMatchProfile_ptWildcardProfile_andPtBrProfile_matches() { + val profile1 = LanguageAndWildcardRegionProfile(languageCode = "pt") + val profile2 = + LanguageAndRegionProfile(languageCode = "pt", regionCode = "br", regionCodeUpperCase = "BR") + + val matches = profile1.matches(profile2) + + assertThat(matches).isTrue() + } + + @Test + fun testMatchProfile_ptWildcardProfile_andSwBrProfile_doNotMatch() { + val profile1 = LanguageAndWildcardRegionProfile(languageCode = "pt") + val profile2 = + LanguageAndRegionProfile(languageCode = "sw", regionCode = "br", regionCodeUpperCase = "BR") - val matches = portugueseProfile.matches(machineLocale, brazilianPortugueseProfile) + val matches = profile1.matches(profile2) assertThat(matches).isFalse() } @Test - fun testMatchProfile_brazilianPortuguese_portugueseWithWildcard_match() { - val portugueseWithWildcardProfile = createProfileWithWildcard(languageCode = "pt") - val brazilianPortugueseProfile = AndroidLocaleProfile.createFrom(brazilianPortugueseLocale) + fun testMatchProfile_ptWildcardProfile_andPtKeProfile_matches() { + val profile1 = LanguageAndWildcardRegionProfile(languageCode = "pt") + val profile2 = + LanguageAndRegionProfile(languageCode = "pt", regionCode = "ke", regionCodeUpperCase = "KE") - val matches = brazilianPortugueseProfile.matches(machineLocale, portugueseWithWildcardProfile) + val matches = profile1.matches(profile2) assertThat(matches).isTrue() } @Test - fun testMatchProfile_portugueseWithWildcard_brazilianPortuguese_match() { - val portugueseWithWildcardProfile = createProfileWithWildcard(languageCode = "pt") - val brazilianPortugueseProfile = AndroidLocaleProfile.createFrom(brazilianPortugueseLocale) + fun testMatchProfile_ptWildcardProfile_andSwKeProfile_doNotMatch() { + val profile1 = LanguageAndWildcardRegionProfile(languageCode = "pt") + val profile2 = + LanguageAndRegionProfile(languageCode = "sw", regionCode = "ke", regionCodeUpperCase = "KE") - val matches = portugueseWithWildcardProfile.matches(machineLocale, brazilianPortugueseProfile) + val matches = profile1.matches(profile2) + + assertThat(matches).isFalse() + } + + @Test + fun testMatchProfile_ptWildcardProfile_andPtWildcardProfile_matches() { + val profile1 = LanguageAndWildcardRegionProfile(languageCode = "pt") + val profile2 = LanguageAndWildcardRegionProfile(languageCode = "pt") + + val matches = profile1.matches(profile2) assertThat(matches).isTrue() } + @Test + fun testMatchProfile_ptWildcardProfile_andSwWildcardProfile_doNotMatch() { + val profile1 = LanguageAndWildcardRegionProfile(languageCode = "pt") + val profile2 = LanguageAndWildcardRegionProfile(languageCode = "sw") + + val matches = profile1.matches(profile2) + + assertThat(matches).isFalse() + } + /* Tests for computeIetfLanguageTag */ @Test - fun testComputeIetfLanguageTag_noLanguageCode_noRegionCode_returnsEmptyString() { - val emptyProfile = AndroidLocaleProfile(languageCode = "", regionCode = "") + fun testIetfLanguageTag_rootProfile_isEmptyString() { + val profile = RootProfile - val ietfLanguageTag = emptyProfile.computeIetfLanguageTag() + val ietfLanguageTag = profile.ietfLanguageTag assertThat(ietfLanguageTag).isEmpty() } @Test - fun testComputeIetfLanguageTag_languageCode_noRegionCode_returnsLanguageCode() { - val portugueseProfile = AndroidLocaleProfile(languageCode = "pt", regionCode = "") + fun testIetfLanguageTag_languageOnlyProfile_isLanguageCode() { + val profile = LanguageOnlyProfile(languageCode = "pt") - val ietfLanguageTag = portugueseProfile.computeIetfLanguageTag() + val ietfLanguageTag = profile.ietfLanguageTag assertThat(ietfLanguageTag).isEqualTo("pt") } @Test - fun testComputeIetfLanguageTag_languageCode_wildcardRegionCode_returnsLanguageCode() { - val portugueseAndRegionProfile = createProfileWithWildcard(languageCode = "pt") + fun testIetfLanguageTag_regionOnlyProfile_isRegionCode() { + val profile = RegionOnlyProfile(regionCode = "br") - val ietfLanguageTag = portugueseAndRegionProfile.computeIetfLanguageTag() + val ietfLanguageTag = profile.ietfLanguageTag + + assertThat(ietfLanguageTag).isEqualTo("br") + } + + @Test + fun testIetfLanguageTag_languageWithRegionProfile_isIetfBcp47CombinedLanguageTag() { + val profile = + LanguageAndRegionProfile(languageCode = "pt", regionCode = "br", regionCodeUpperCase = "BR") + + val ietfLanguageTag = profile.ietfLanguageTag + + assertThat(ietfLanguageTag).isEqualTo("pt-BR") + } + + @Test + fun testIetfLanguageTag_languageWithWildcardProfile_isLanguageCode() { + val profile = LanguageAndWildcardRegionProfile(languageCode = "pt") + + val ietfLanguageTag = profile.ietfLanguageTag // The wildcard shouldn't be part of the IETF BCP 47 tag since that standard doesn't define such // a concept. assertThat(ietfLanguageTag).isEqualTo("pt") } + /* Tests for computeAndroidLocale() */ + @Test - fun testComputeIetfLanguageTag_noLanguageCode_regionCode_returnsRegionCode() { - val brazilianProfile = AndroidLocaleProfile(languageCode = "", regionCode = "BR") + fun testComputeAndroidLocale_rootProfile_returnsRootLocale() { + val profile = RootProfile - val ietfLanguageTag = brazilianProfile.computeIetfLanguageTag() + val locale = profile.computeAndroidLocale() - assertThat(ietfLanguageTag).isEqualTo("BR") + assertThat(locale).isEqualTo(Locale.ROOT) } @Test - fun testComputeIetfLanguageTag_noLanguageCode_wildcardRegionCode_returnsEmptyString() { - val matchNothingProfile = createProfileWithWildcard(languageCode = "") + fun testComputeAndroidLocale_languageOnlyProfile_returnsLocaleWithLanguageAndEmptyCountry() { + val profile = LanguageOnlyProfile(languageCode = "pt") - val ietfLanguageTag = matchNothingProfile.computeIetfLanguageTag() + val locale = profile.computeAndroidLocale() - assertThat(ietfLanguageTag).isEmpty() + assertThat(locale.language).ignoringCase().isEqualTo("pt") + assertThat(locale.country).isEmpty() } @Test - fun testComputeIetfLanguageTag_languageCode_regionCode_returnsIetfBcp47CombinedLanguageTag() { - val brazilianPortugueseProfile = AndroidLocaleProfile(languageCode = "pt", regionCode = "BR") + fun testComputeAndroidLocale_regionOnlyProfile_returnsLocaleWithCountryAndEmptyLanguage() { + val profile = RegionOnlyProfile(regionCode = "br") - val ietfLanguageTag = brazilianPortugueseProfile.computeIetfLanguageTag() + val locale = profile.computeAndroidLocale() - assertThat(ietfLanguageTag).isEqualTo("pt-BR") + assertThat(locale.country).ignoringCase().isEqualTo("br") + assertThat(locale.language).isEmpty() + } + + @Test + fun testComputeAndroidLocale_languageWithWildcardProfile_returnsLocaleWithLangAndEmptyCountry() { + val profile = LanguageAndWildcardRegionProfile(languageCode = "pt") + + val locale = profile.computeAndroidLocale() + + assertThat(locale.language).ignoringCase().isEqualTo("pt") + assertThat(locale.country).isEmpty() } - private fun createProfileWithWildcard(languageCode: String): AndroidLocaleProfile = - AndroidLocaleProfile(languageCode, regionCode = AndroidLocaleProfile.REGION_WILDCARD) + @Test + fun testComputeAndroidLocale_languageAndRegionProfile_returnsLocaleWithLanguageAndCountry() { + val profile = + LanguageAndRegionProfile(languageCode = "pt", regionCode = "br", regionCodeUpperCase = "BR") + + val locale = profile.computeAndroidLocale() + + assertThat(locale.language).ignoringCase().isEqualTo("pt") + assertThat(locale.country).ignoringCase().isEqualTo("br") + } private fun setUpTestApplicationComponent() { DaggerAndroidLocaleProfileTest_TestApplicationComponent.builder() diff --git a/utility/src/test/java/org/oppia/android/util/locale/BUILD.bazel b/utility/src/test/java/org/oppia/android/util/locale/BUILD.bazel index 850e0d05370..d1bc33a3988 100644 --- a/utility/src/test/java/org/oppia/android/util/locale/BUILD.bazel +++ b/utility/src/test/java/org/oppia/android/util/locale/BUILD.bazel @@ -14,6 +14,8 @@ oppia_android_test( deps = [ ":dagger", "//testing", + "//testing/src/main/java/org/oppia/android/testing/robolectric:test_module", + "//testing/src/main/java/org/oppia/android/testing/threading:test_module", "//testing/src/main/java/org/oppia/android/testing/time:test_module", "//third_party:androidx_test_ext_junit", "//third_party:com_google_truth_truth", @@ -72,6 +74,8 @@ oppia_android_test( ":dagger", "//model/src/main/proto:languages_java_proto_lite", "//testing", + "//testing/src/main/java/org/oppia/android/testing/robolectric:test_module", + "//testing/src/main/java/org/oppia/android/testing/threading:test_module", "//testing/src/main/java/org/oppia/android/testing/time:test_module", "//third_party:androidx_test_ext_junit", "//third_party:com_google_truth_extensions_truth-liteproto-extension", diff --git a/utility/src/test/java/org/oppia/android/util/locale/DisplayLocaleImplTest.kt b/utility/src/test/java/org/oppia/android/util/locale/DisplayLocaleImplTest.kt index e300a47d6fa..dfc1ba84b79 100644 --- a/utility/src/test/java/org/oppia/android/util/locale/DisplayLocaleImplTest.kt +++ b/utility/src/test/java/org/oppia/android/util/locale/DisplayLocaleImplTest.kt @@ -21,6 +21,8 @@ import org.oppia.android.app.model.OppiaLocaleContext import org.oppia.android.app.model.OppiaRegion import org.oppia.android.app.model.RegionSupportDefinition import org.oppia.android.testing.assertThrows +import org.oppia.android.testing.robolectric.RobolectricModule +import org.oppia.android.testing.threading.TestDispatcherModule import org.oppia.android.testing.time.FakeOppiaClockModule import org.oppia.android.util.locale.testing.LocaleTestModule import org.oppia.android.util.locale.testing.TestOppiaBidiFormatter @@ -56,13 +58,6 @@ class DisplayLocaleImplTest { setUpTestApplicationComponent() } - @Test - fun testCreateDisplayLocaleImpl_defaultInstance_hasDefaultInstanceContext() { - val impl = createDisplayLocaleImpl(OppiaLocaleContext.getDefaultInstance()) - - assertThat(impl.localeContext).isEqualToDefaultInstance() - } - @Test fun testCreateDisplayLocaleImpl_forProvidedContext_hasCorrectInstanceContext() { val impl = createDisplayLocaleImpl(EGYPT_ARABIC_CONTEXT) @@ -72,7 +67,7 @@ class DisplayLocaleImplTest { @Test fun testToString_returnsNonDefaultString() { - val impl = createDisplayLocaleImpl(OppiaLocaleContext.getDefaultInstance()) + val impl = createDisplayLocaleImpl(EGYPT_ARABIC_CONTEXT) val str = impl.toString() @@ -83,14 +78,14 @@ class DisplayLocaleImplTest { @Test fun testEquals_withNullValue_returnsFalse() { - val impl = createDisplayLocaleImpl(OppiaLocaleContext.getDefaultInstance()) + val impl = createDisplayLocaleImpl(EGYPT_ARABIC_CONTEXT) assertThat(impl).isNotEqualTo(null) } @Test fun testEquals_withSameObject_returnsTrue() { - val impl = createDisplayLocaleImpl(OppiaLocaleContext.getDefaultInstance()) + val impl = createDisplayLocaleImpl(EGYPT_ARABIC_CONTEXT) assertThat(impl).isEqualTo(impl) } @@ -615,8 +610,10 @@ class DisplayLocaleImplTest { } } - private fun createDisplayLocaleImpl(context: OppiaLocaleContext): DisplayLocaleImpl = - DisplayLocaleImpl(context, machineLocale, androidLocaleFactory, formatterFactory) + private fun createDisplayLocaleImpl(context: OppiaLocaleContext): DisplayLocaleImpl { + val formattingLocale = androidLocaleFactory.createOneOffAndroidLocale(context) + return DisplayLocaleImpl(context, formattingLocale, machineLocale, formatterFactory) + } private fun setUpTestApplicationComponent() { DaggerDisplayLocaleImplTest_TestApplicationComponent.builder() @@ -639,7 +636,8 @@ class DisplayLocaleImplTest { @Singleton @Component( modules = [ - TestModule::class, LocaleTestModule::class, FakeOppiaClockModule::class + TestModule::class, LocaleTestModule::class, FakeOppiaClockModule::class, + TestDispatcherModule::class, RobolectricModule::class ] ) interface TestApplicationComponent { diff --git a/utility/src/test/java/org/oppia/android/util/parser/html/CustomHtmlContentHandlerTest.kt b/utility/src/test/java/org/oppia/android/util/parser/html/CustomHtmlContentHandlerTest.kt index 5cba77e357d..2042ef72ec8 100644 --- a/utility/src/test/java/org/oppia/android/util/parser/html/CustomHtmlContentHandlerTest.kt +++ b/utility/src/test/java/org/oppia/android/util/parser/html/CustomHtmlContentHandlerTest.kt @@ -311,8 +311,10 @@ class CustomHtmlContentHandlerTest { ) } - private fun createDisplayLocaleImpl(context: OppiaLocaleContext): DisplayLocaleImpl = - DisplayLocaleImpl(context, machineLocale, androidLocaleFactory, formatterFactory) + private fun createDisplayLocaleImpl(context: OppiaLocaleContext): DisplayLocaleImpl { + val formattingLocale = androidLocaleFactory.createOneOffAndroidLocale(context) + return DisplayLocaleImpl(context, formattingLocale, machineLocale, formatterFactory) + } private class FakeTagHandler : CustomHtmlContentHandler.CustomTagHandler { var handleTagCalled = false diff --git a/utility/src/test/java/org/oppia/android/util/parser/html/LiTagHandlerTest.kt b/utility/src/test/java/org/oppia/android/util/parser/html/LiTagHandlerTest.kt index 46e4ca83387..1341bc68776 100644 --- a/utility/src/test/java/org/oppia/android/util/parser/html/LiTagHandlerTest.kt +++ b/utility/src/test/java/org/oppia/android/util/parser/html/LiTagHandlerTest.kt @@ -137,8 +137,10 @@ class LiTagHandlerTest { .hasLength(4) } - private fun createDisplayLocaleImpl(context: OppiaLocaleContext): DisplayLocaleImpl = - DisplayLocaleImpl(context, machineLocale, androidLocaleFactory, formatterFactory) + private fun createDisplayLocaleImpl(context: OppiaLocaleContext): DisplayLocaleImpl { + val formattingLocale = androidLocaleFactory.createOneOffAndroidLocale(context) + return DisplayLocaleImpl(context, formattingLocale, machineLocale, formatterFactory) + } private fun Spannable.getSpansFromWholeString(spanClass: KClass): Array = getSpans(/* start= */ 0, /* end= */ length, spanClass.javaObjectType)